C.W.K.
Stream
Lesson 02 of 06 · published

Two Ways In — Declarative Injection vs chrome.scripting

~12 min · content-script, injection, manifest, chrome.scripting, permissions

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Either Chrome injects your script on every matching URL automatically (declarative), or your service worker injects it the moment something interesting happens (programmatic). Lesson 2 is about picking the right door for the room you're entering."

Declarative — Manifest Says So

The simpler path: list the script in manifest.json under content_scripts. Chrome reads the list at install time and injects on every navigation that matches. No background.js code, no permission prompts beyond what the manifest already declared, no per-tab decisions. Just "this script runs on these URLs."

Each entry is one logical content script. The fields you'll actually use:

  • matches — required. Array of URL match patterns (https://*.github.com/*, <all_urls>, etc.).
  • exclude_matches — optional carve-outs from matches.
  • js — array of script files inside the extension package.
  • css — array of stylesheet files; injected the same way.
  • run_atdocument_start (before DOM parse), document_end (DOM ready, resources still loading), document_idle (everything settled — the default).
  • all_frames — boolean, false by default. True if you need to reach inside iframes.
  • worldISOLATED (default, Lesson 3 covers it) or MAIN (Chrome 111+, run in the page's own JS world).

Programmatic — chrome.scripting.executeScript

The on-demand path: don't list the script in the manifest, inject it from the service worker when something triggers. Toolbar icon click, context menu activation, message from popup, alarm fire, whatever — the SW calls chrome.scripting.executeScript and pushes a function or file into a specific tab.

This is the right tool when:

  • The script should run sometimes, not on every page load.
  • You want a return value — executeScript returns the result of the injected function back to the SW.
  • You need to choose which tab or which frame, based on logic that the manifest can't express.

It costs you the "scripting" permission plus host permissions for the URLs you want to touch — Track 6 walks through the permission model.

The activeTab Shortcut

For toolbar-click injection on the current tab, "activeTab" is a special permission that grants temporary host access without prompting the user. It activates when the user clicks the toolbar icon (or triggers a context menu / keyboard shortcut) and lasts for that tab until navigation. This is the Chrome-recommended path for opt-in extensions — no scary <all_urls> warning, just "works on the page you're on right now if you ask."

Combining Both

It is completely valid (and common) to use both. ClipDeck's eventual shape:

  • Declarative for the always-on selection capture — every page, listening for the user's selection so we can save it when they ask.
  • Programmatic for one-off operations — "extract the article body of this page," "highlight the saved clips on this page," triggered by the toolbar icon or popup button.

ClipDeck Track 3 Choice

For the Track 3 milestone — saving the user's selected text — declarative wins. The script needs to be listening on every page so the selection is already in scope the moment the user invokes the save action. Programmatic injection would race with the user's intent: by the time the SW finishes injecting, the selection might be gone (clicking the toolbar collapses popups, sometimes clears selections). Declarative + match <all_urls> + activeTab as a fallback for sensitive sites is the canonical ClipDeck shape.

Declarative for "always-on for these URLs." Programmatic for "now, by my choice, in this specific tab." When in doubt, ask: "does this script need to be already present, or can it be injected when the user asks?"
The <all_urls> install warning. A manifest with "matches": ["<all_urls>"] triggers Chrome's "Read and change all your data on all websites" install prompt — the scariest one users see. Use it only when you genuinely need to. Prefer narrower patterns (https://*.github.com/*) or activeTab + programmatic injection where possible. The Chrome Web Store review will also push back on broad host permissions without clear justification.

Code

manifest.json — declarative content script with sensible exclusions·json
{
  "manifest_version": 3,
  "name": "ClipDeck",
  "version": "0.4.1",
  "action": { "default_popup": "popup.html" },
  "background": { "service_worker": "background.js" },
  "permissions": ["storage", "tabs", "scripting", "activeTab"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "exclude_matches": [
        "https://accounts.google.com/*",
        "https://*.bank.com/*"
      ],
      "js": ["content.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ],
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }
}
background.js — programmatic injection returning a page snapshot·javascript
// background.js — programmatic injection on toolbar click
chrome.action.onClicked.addListener(async (tab) => {
  if (!tab.id) return;
  const [result] = await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => {
      // This runs INSIDE the page (isolated world by default).
      // Returns the value back to the SW.
      return {
        url: location.href,
        title: document.title,
        wordCount: document.body.innerText.split(/\s+/).length,
      };
    },
  });
  console.log("[ClipDeck SW] page snapshot:", result.result);
});

// Note: chrome.action.onClicked only fires if there is NO default_popup
// declared in manifest.action. For the popup case, send a message from
// popup.js to the SW, then SW calls chrome.scripting.executeScript on the
// captured tab id.
Programmatic injection by filename (script must exist in the extension)·javascript
// Injecting an external file instead of an inline function
await chrome.scripting.executeScript({
  target: { tabId: tab.id, allFrames: false },
  files: ["injected.js"],
  world: "ISOLATED", // or "MAIN" to run in the page's JS world
});

External links

Exercise

Update clipdeck/manifest.json to the first code block (version 0.4.1, with scripting + activeTab permissions and the broader content_scripts block). Add the toolbar-click handler from the second code block to clipdeck/background.js. **Important:** chrome.action.onClicked only fires when there is no default_popup declared. For this exercise, temporarily remove "default_popup": "popup.html" from the action object in the manifest so clicking the icon fires onClicked instead of opening the popup. Reload. Open any real page, click the ClipDeck toolbar icon. The SW DevTools console should log a snapshot with url, title, and word count. Switch back: restore default_popup, reload, confirm the popup opens normally again.
Hint
If you get chrome.scripting is undefined, you forgot to add "scripting" to permissions and reload the extension. If executeScript throws Cannot access contents of the page, host permissions are missing — activeTab alone covers the active tab only when the user just clicked the toolbar; for arbitrary URL access you need the URL pattern in host_permissions or in content_scripts.matches. If the SW DevTools console shows nothing at all, the SW may have been evicted and the listener registered too late — make sure chrome.action.onClicked.addListener(...) is at the top level of background.js, not inside an async function.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.