Claude Skill

chrome-extension

Use when building or shipping a Manifest V3 browser extension and hitting its quirks — service worker dying and losing state, permission warnings, a Chrome Web Store rejection, content-script/worker/popup messaging, or an MV2-to-V3 migration. NOT a generic web app (that is `nextj

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download ericrisco-rsc-harness-skills_chrome-extension-953fef5.zip · 10 KB
Part of ericrisco/rsc-harness — 46 skills

Install

skills CLI npx skills add https://github.com/ericrisco/rsc-harness/tree/main/skills/chrome-extension
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install ericrisco-rsc-harness@llmmart
Git git clone https://github.com/ericrisco/rsc-harness.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole ericrisco/rsc-harness collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Chrome extension (Manifest V3)

An MV3 extension is three isolated JavaScript contexts that never share memory and only talk via messages:

  1. Service worker (background.service_worker) — the logic and lifecycle brain. Ephemeral: Chrome kills it when idle and restarts it on the next event. It has no DOM and no window.
  2. Content scripts — run inside a web page, can read/write that page's DOM, live in an isolated JS world by default. No access to most chrome.* APIs except messaging and storage.
  3. UI surfaces — popup (action.default_popup), options page, side panel. Normal web pages that load and unload as the user opens/closes them.

Internalize that picture first. Most extension bugs are someone treating one of these as if it shared state with another. They do not. The wire between them is chrome.runtime messaging and chrome.storage.

Manifest V3 is the only version the Chrome Web Store accepts; MV2 phase-out began June 2024 and is still rolling out. Build MV3 from the start.

Pick a skeleton

Setup Pick when Cost
Vanilla (raw files, load unpacked) tiny extension, no npm imports, you want the fastest possible reload loop no TS, no HMR, manual reloads
Vite + CRXJS (@crxjs/vite-plugin) TS, npm imports, React/Vue popup, you want HMR a build step; you ship dist/, not the repo

Default to Vite + CRXJS the moment you want TypeScript or a framework popup — it does the manifest wiring and HMR for you. Reach for vanilla only for a one-file experiment.

Minimal tree (Vite + CRXJS):

my-ext/
  manifest.json        # source of truth; CRXJS reads it
  src/
    background.ts       # service worker
    content.ts          # content script
    popup/
      index.html
      popup.tsx
  public/icons/         # 16, 48, 128 px PNGs
  vite.config.ts
  # build output -> dist/  (this is what you zip)

manifest.json — minimum viable shape

{
  "manifest_version": 3,
  "name": "Highlighter",
  "version": "1.0.0",
  "description": "Highlights selected text on the current page.",
  "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },
  "action": { "default_popup": "popup/index.html" },
  "background": { "service_worker": "background.js", "type": "module" },
  "permissions": ["activeTab", "storage", "scripting"],
  "host_permissions": [],
  "minimum_chrome_version": "120"
}

Rules that fail review or break the worker if you get them wrong:

  • manifest_version must be 3. There is no 2 path forward.
  • background.service_worker is a string, not an array. There is no background.scripts and no background.page in MV3. The persistent key does not exist — delete it. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
  • "type": "module" lets the worker use import. Use it if you bundle.
  • Keep host_permissions empty until you can name exactly which sites and why (see permission table).

The three contexts and how they talk

The service worker is ephemeral. It terminates when idle and wakes on an event. Two consequences govern almost all your code:

  • Register every listener synchronously at the top level. If you call chrome.runtime.onMessage.addListener inside an async callback or after an await, Chrome may not have registered it when it wakes the worker, and your event is lost. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
  • Never keep state in a global variable. The worker dies and your variable resets to its initial value. Persist to chrome.storage.
// Bad — global resets to 0 every time the worker is killed and restarts
let clickCount = 0;
chrome.action.onClicked.addListener(() => {
  clickCount++;                          // silently back to 1 after idle
});

// Good — durable across worker restarts, listener registered at top level
chrome.action.onClicked.addListener(async () => {
  const { clickCount = 0 } = await chrome.storage.local.get("clickCount");
  await chrome.storage.local.set({ clickCount: clickCount + 1 });
});

Messaging choices:

  • One-shot request/response: chrome.runtime.sendMessage(msg) / chrome.tabs.sendMessage(tabId, msg) paired with chrome.runtime.onMessage. Return true from the listener to keep the channel open for an async sendResponse.
  • Long-lived stream (e.g. a content script feeding the popup continuously): chrome.runtime.connect() → Port, with port.onMessage / port.postMessage.

Content script → service worker → popup: there is no direct content-script-to-popup channel when the popup is closed. Route through the worker or through chrome.storage and let the popup read on open.

Permissions — least privilege or you get rejected

Reviewers reject broad permissions with no justification, and broad host_permissions trigger a scary install-time warning that tanks conversion. Declare the narrowest thing that works.

Permission Grants Warning? Use when
activeTab temporary access to the current tab, only after a user gesture (toolbar click) none the user clicks your icon and you act on that one page
scripting chrome.scripting.executeScript to inject programmatically none alone (needs a host or activeTab to target) inject on demand instead of on every page
host_permissions: ["https://example.com/*"] persistent access to those origins yes, lists the sites you must run in the background on specific sites
host_permissions: ["<all_urls>"] every site loud, broad warning almost never — avoid; prefer activeTab
optional_permissions + chrome.permissions.request() runtime opt-in inside a user gesture shown only when requested a feature only some users need; ask when they enable it
declarativeNetRequest static/dynamic rules block or modify requests, no request bodies seen install-time warning ad/tracker blocking — replaces blocking webRequest
declarativeNetRequestWithHostAccess same, but access granted per host instead of install-time per-host DNR scoped to granted hosts only

Rule: write a one-sentence justification for every permission before you add it. If you cannot, drop it. activeTab covers more cases than people expect — try it first. (Source: developer.chrome.com "Declare permissions" + "declarativeNetRequest", accessed 2026-06-02.)

Content scripts: declarative vs programmatic

// Declarative — in manifest. Runs automatically on matching pages.
"content_scripts": [{
  "matches": ["https://example.com/*"],
  "js": ["content.js"],
  "run_at": "document_idle"
}]
// Programmatic — inject on a gesture. Needs "scripting" + activeTab or a host match.
chrome.action.onClicked.addListener(async (tab) => {
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ["content.js"],
  });
});
  • run_at: document_start (before DOM), document_end, or document_idle (default, after load). Pick document_start only if you must beat the page's own scripts.
  • Isolated world (default): your content script's JS is sandboxed from the page's JS — they share the DOM but not variables. Use MAIN world ("world": "MAIN") only when you must touch the page's own JS objects, and know it loses the isolation guarantee.
  • For dynamic, user-supplied injection, chrome.userScripts.execute() exists (Chrome 135, Mar 2025) — see references/store-and-migration.md.

Storage, alarms, no remote code

  • chrome.storage.local (~10 MB, larger with unlimitedStorage) for most data; chrome.storage.sync (~100 KB, ~8 KB/item) only for small settings you want to follow the user across devices.
  • setTimeout/setInterval are unreliable in the worker — it may be asleep when they fire. Use chrome.alarms for anything beyond a few seconds. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
  • Remotely hosted code is banned. No <script src="https://cdn...">, no eval-of-fetched-string. All executable JS must ship inside the package — bundle every dependency. This is enforced by MV3's CSP and by review. (Source: developer.chrome.com "What is Manifest V3", accessed 2026-06-02.)

Shipping to the Chrome Web Store

  1. Build, then zip the dist/ output — never the repo. No node_modules, no .git, no source maps you do not want public.
  2. Pay the one-time $5 USD developer registration (covers up to 20 extensions on the account).
  3. Upload the zip in the Developer Dashboard.
  4. Listing assets: a 128×128 PNG icon, at least one screenshot (1280×800 or 640×400), a clear description, a category, and a privacy-policy URL if you collect any data.
  5. Review is typically 1–3 business days (simple extensions often under 24h).

(Source: developer.chrome.com "Register your developer account" + fee guide, accessed 2026-06-02.) For the full dashboard walkthrough, data-disclosure form, staged rollout, appeals, and the MV2→MV3 migration map, see references/store-and-migration.md.

Anti-patterns

Anti-pattern Why it breaks Do instead
background.scripts / persistent: true MV2 shape; rejected, worker never registers background.service_worker: "bg.js" (string)
Listener added after an await Chrome wakes the worker without your listener; event lost register all listeners synchronously at top level
State in a global var worker dies, var resets silently persist to chrome.storage
<all_urls> when a click suffices scary install warning, review pushback activeTab triggered by the toolbar click
<script src="https://cdn…"> remote code is banned in MV3 bundle the dependency into the package
setInterval for periodic work fires only while worker is alive chrome.alarms
Zipping the repo / node_modules bloated, leaks source, may fail review zip only the built dist/
Auth token in a global / sync lost on restart, or synced off-device chrome.storage.local (or session for in-memory)
MAIN world by default loses isolation, page can tamper isolated world unless you must reach page JS

Run scripts/verify.sh <dir> to lint a produced manifest.json against the MV3 invariants above.

Files (rsc-harness)
  • evals
    • cases.yaml 2.7 KB
      skill: chrome-extension
      
      should_trigger:
        - prompt: "Build me a Chrome extension that highlights selected text on the page."
          why: "Clear scaffolding-from-zero request — the core use case (manifest + content script + popup)."
        - prompt: "My extension's background script keeps losing its state between events — the counter resets to zero."
          why: "Non-obvious symptom of the ephemeral service worker killing globals; the fix (chrome.storage, top-level listeners) is this skill's heart."
        - prompt: "The Chrome Web Store rejected my extension for excessive permissions. Help me fix the manifest."
          why: "Store review + least-privilege permission model — activeTab vs host_permissions, justifications."
        - prompt: "Migrate this Manifest V2 extension to V3 — it uses background scripts and blocking webRequest."
          why: "MV2->MV3 migration map: background page->service worker, webRequest->declarativeNetRequest."
        - prompt: "Crear una extensión para Chrome que bloquee anuncios con declarativeNetRequest."
          why: "Spanish phrasing + DNR ad-blocking, the canonical replacement for blocking webRequest."
        - prompt: "How do I send a message from my content script to the popup when the popup is closed?"
          why: "Non-obvious cross-context messaging — there is no direct channel; route via service worker or storage."
      
      should_not_trigger:
        - prompt: "Build a desktop app with web technologies that runs outside the browser."
          route_to: electron
          why: "Standalone desktop shell, not a browser extension package."
        - prompt: "Build me a Next.js dashboard with server components."
          route_to: nextjs
          why: "Generic web app — the explicit NOT boundary in the description."
        - prompt: "Write the privacy policy text for my app's data collection."
          route_to: gdpr-privacy
          why: "Legal copy; this skill only says where the privacy URL plugs into the store listing."
        - prompt: "Build a cross-platform desktop app in Rust with a web frontend."
          route_to: tauri
          why: "Native desktop via Rust, not a Chrome extension."
      
      capability:
        - scenario: "Scaffold a Manifest V3 extension that injects a content script on toolbar click (activeTab, not <all_urls>) and shows a click count in the popup that survives service worker restarts."
          must_include:
            - "manifest_version set to 3"
            - "background.service_worker declared as a string (no background.scripts/page/persistent)"
            - "activeTab permission rather than <all_urls> host_permissions"
            - "count persisted with chrome.storage, not a background global variable"
            - "event listeners registered synchronously at the top level"
            - "chrome.scripting.executeScript or a declarative content_scripts entry"
            - "icons (16/48/128) and a privacy/store note for shipping"
      
    • README.md 754 B
      # Evals: chrome-extension
      
      `cases.yaml` holds two things. The `should_trigger` / `should_not_trigger`
      prompts are routing checks: read each prompt cold and judge whether this skill
      (versus the named sibling) should fire — run them by hand or feed them to an
      LLM judge against the skill's description. The `capability` case is graded by
      handing the scenario to an agent loaded with the skill and checking the output
      against every item in `must_include` (a rubric, not a string match). There is no
      automated test runner here. The one mechanical check is `scripts/verify.sh
      <dir>`, which lint-checks any `manifest.json` the skill produces against the MV3
      invariants; it exits 0 when the target has no manifest, so it is safe to run on
      an empty directory.
      
  • references
    • store-and-migration.md 4.9 KB
      # Chrome Web Store submission + MV2→MV3 migration
      
      Two long branches offloaded from SKILL.md: getting through review, and dragging
      an old MV2 extension to MV3. Facts dated 2026-06-02 from developer.chrome.com.
      
      ## Store submission, step by step
      
      1. **Register the developer account.** One-time **$5 USD** fee, covers up to 20
         extensions on that account. Use a Google account you control long-term — it
         owns the listings.
      2. **Prepare the package.** Build, then zip the **build output only** (`dist/`).
         Strip `node_modules`, `.git`, README, and any source map you do not want
         public. The zip's root must contain `manifest.json`.
      3. **Create the item** in the Developer Dashboard and upload the zip. Bump
         `version` in `manifest.json` on every upload — the store refuses a duplicate
         version.
      4. **Listing assets:**
         - Icon: **128×128 PNG** (the store icon; separate from the toolbar icons).
         - Screenshots: at least one, **1280×800 or 640×400 PNG/JPEG**. More is better
           for conversion; show the actual UI, not marketing fluff.
         - Optional promo tiles and a YouTube link.
         - A clear, honest **description** and a **category**.
      5. **Privacy practices form.** You must disclose what data you collect and why,
         certify you do not sell it for unrelated purposes, and provide a
         **privacy-policy URL** if you collect any user data. The legal copy itself is
         out of scope here — see `../gdpr-privacy/SKILL.md` and `../data-policy/SKILL.md`;
         this skill only tells you the URL plugs into this form.
      6. **Permission justifications.** For each permission and each broad host match,
         the form asks why. Write one concrete sentence per permission. Vague answers
         ("for functionality") get rejected.
      7. **Submit.** Review is typically **1–3 business days**; simple extensions often
         under 24h. You can use **staged rollout** to ship a new version to a
         percentage of users first and halt if metrics tank.
      
      ### Surviving review
      
      - Least privilege: every permission must map to a visible feature. `activeTab`
        over `host_permissions`; specific origins over `<all_urls>`.
      - No remotely hosted code — bundle everything. Reviewers run static checks for
        remote `<script src>` and `eval` of fetched strings.
      - Single clear purpose per extension; do not bundle unrelated features.
      - If rejected, the email names the policy. Fix the named issue, reply via the
        **appeals** flow in the dashboard with what changed, and resubmit. Do not
        silently re-upload the same package.
      
      ## MV2 → MV3 migration map
      
      | MV2 | MV3 | Notes |
      |---|---|---|
      | `background.page` / `background.scripts` + `persistent: true` | `background.service_worker` (string), `"type": "module"` for imports | no DOM, no `window`; it terminates when idle |
      | long-lived global state in the background page | `chrome.storage` (+ `chrome.storage.session` for in-memory) | worker restarts wipe globals |
      | `setInterval` / `setTimeout` for periodic work | `chrome.alarms` | timers do not survive worker sleep |
      | blocking `chrome.webRequest` (modify/block requests) | `chrome.declarativeNetRequest` (static + dynamic rules) | DNR never sees request bodies; declare rule resources |
      | `chrome.tabs.executeScript(tabId, {code/file})` | `chrome.scripting.executeScript({ target: { tabId }, files })` | new signature; needs `scripting` permission |
      | `chrome.tabs.insertCSS` | `chrome.scripting.insertCSS` | same shape change |
      | remote `<script src>` / CDN libraries | bundle the library into the package | remote code is banned by MV3 CSP + policy |
      | `browser_action` / `page_action` | unified `action` | one toolbar entry point |
      | MV2-style host access by default | explicit `host_permissions` + prefer `activeTab` | broad hosts now warn loudly at install |
      
      ### Migration order that works
      
      1. Flip `manifest_version` to `3` and convert `background` to a `service_worker`
         string. Move every event listener to the top level, synchronous.
      2. Replace background-page globals with `chrome.storage` reads/writes.
      3. Swap timers for `chrome.alarms`.
      4. Convert `webRequest` blocking rules to `declarativeNetRequest` rule sets.
      5. Update every `tabs.executeScript`/`insertCSS` to the `scripting.*` signature
         and add the `scripting` permission.
      6. Remove all remote code; bundle dependencies.
      7. Re-audit permissions — MV3 is the moment to drop `<all_urls>` for `activeTab`.
      
      ## Recent platform notes (version-dated)
      
      - `chrome.userScripts.execute()` — Chrome 135 (Mar 2025): run dynamic,
        user-supplied scripts under a dedicated permission.
      - `chrome.sidePanel.getLayout()` — Chrome 140 (Sep 2025).
      - `chrome.storage` viewer/editor in DevTools — Chrome 132 (Jan 2025): inspect
        extension storage without a debug page.
      - Cross-browser `browser` namespace exposed in Chrome — Chrome 148 (May 2026):
        the `browser.*` promise-based namespace now works in Chrome too, easing
        Firefox/Edge portability.
      
      (Source: developer.chrome.com "What's new in Chrome extensions", accessed
      2026-06-02.)
      
  • scripts
    • verify.sh 2.1 KB
      #!/usr/bin/env bash
      # verify.sh — lint a Manifest V3 manifest.json against MV3 invariants.
      # Read-only. Exits 0 on a clean/empty target (no manifest = nothing to check).
      # Usage: scripts/verify.sh [target-dir]   (defaults to current directory)
      set -euo pipefail
      
      TARGET="${1:-.}"
      
      if ! command -v node >/dev/null 2>&1; then
        echo "verify.sh: node not found; skipping manifest lint." >&2
        exit 0
      fi
      
      # Find manifest.json files, ignoring build/vendor dirs. No matches => clean pass.
      MANIFESTS="$(
        find "$TARGET" \
          -type d \( -name node_modules -o -name dist -o -name .git \) -prune -o \
          -type f -name manifest.json -print 2>/dev/null
      )"
      
      if [ -z "$MANIFESTS" ]; then
        echo "verify.sh: no manifest.json under '$TARGET' — nothing to check."
        exit 0
      fi
      
      FAIL=0
      while IFS= read -r m; do
        [ -z "$m" ] && continue
        node - "$m" <<'NODE' || FAIL=1
      const fs = require("fs");
      const path = process.argv[2];
      const errs = [];
      let mf;
      try {
        mf = JSON.parse(fs.readFileSync(path, "utf8"));
      } catch (e) {
        console.error(`FAIL ${path}: invalid JSON — ${e.message}`);
        process.exit(1);
      }
      if (mf.manifest_version !== 3) errs.push(`manifest_version must be 3 (got ${JSON.stringify(mf.manifest_version)})`);
      const bg = mf.background || {};
      if ("scripts" in bg) errs.push("background.scripts is MV2; use background.service_worker (string)");
      if ("page" in bg) errs.push("background.page is MV2; use background.service_worker (string)");
      if ("persistent" in bg) errs.push("background.persistent does not exist in MV3; remove it");
      if (mf.background !== undefined) {
        if (typeof bg.service_worker !== "string") errs.push("background.service_worker must be a string");
      }
      if (typeof mf.name !== "string" || !mf.name) errs.push("missing name");
      if (typeof mf.version !== "string" || !mf.version) errs.push("missing version");
      if (mf.icons === undefined || typeof mf.icons !== "object") errs.push("missing icons");
      if (errs.length) {
        console.error(`FAIL ${path}:`);
        for (const e of errs) console.error(`  - ${e}`);
        process.exit(1);
      }
      console.log(`OK   ${path}`);
      NODE
      done <<EOF
      $MANIFESTS
      EOF
      
      exit "$FAIL"
      
  • SKILL.md 10.8 KB
    ---
    name: chrome-extension
    description: "Use when building or shipping a Manifest V3 browser extension and hitting its quirks — service worker dying and losing state, permission warnings, a Chrome Web Store rejection, content-script/worker/popup messaging, or an MV2-to-V3 migration. NOT a generic web app (that is `nextjs`), NOT a desktop shell (that is `electron`)."
    tags: [chrome-extension, manifest-v3, browser-extension, service-worker, chrome-web-store]
    recommends: [nextjs, react, gdpr-privacy, secure-coding, vercel]
    origin: risco
    ---
    
    # Chrome extension (Manifest V3)
    
    An MV3 extension is **three isolated JavaScript contexts that never share memory and only talk via messages**:
    
    1. **Service worker** (`background.service_worker`) — the logic and lifecycle brain. Ephemeral: Chrome kills it when idle and restarts it on the next event. It has no DOM and no `window`.
    2. **Content scripts** — run inside a web page, can read/write that page's DOM, live in an isolated JS world by default. No access to most `chrome.*` APIs except messaging and `storage`.
    3. **UI surfaces** — popup (`action.default_popup`), options page, side panel. Normal web pages that load and unload as the user opens/closes them.
    
    Internalize that picture first. Most extension bugs are someone treating one of these as if it shared state with another. They do not. The wire between them is `chrome.runtime` messaging and `chrome.storage`.
    
    Manifest V3 is the only version the Chrome Web Store accepts; MV2 phase-out began June 2024 and is still rolling out. Build MV3 from the start.
    
    ## Pick a skeleton
    
    | Setup | Pick when | Cost |
    |---|---|---|
    | **Vanilla** (raw files, `load unpacked`) | tiny extension, no npm imports, you want the fastest possible reload loop | no TS, no HMR, manual reloads |
    | **Vite + CRXJS** (`@crxjs/vite-plugin`) | TS, npm imports, React/Vue popup, you want HMR | a build step; you ship `dist/`, not the repo |
    
    Default to **Vite + CRXJS** the moment you want TypeScript or a framework popup — it does the manifest wiring and HMR for you. Reach for vanilla only for a one-file experiment.
    
    Minimal tree (Vite + CRXJS):
    
    ```text
    my-ext/
      manifest.json        # source of truth; CRXJS reads it
      src/
        background.ts       # service worker
        content.ts          # content script
        popup/
          index.html
          popup.tsx
      public/icons/         # 16, 48, 128 px PNGs
      vite.config.ts
      # build output -> dist/  (this is what you zip)
    ```
    
    ## manifest.json — minimum viable shape
    
    ```json
    {
      "manifest_version": 3,
      "name": "Highlighter",
      "version": "1.0.0",
      "description": "Highlights selected text on the current page.",
      "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" },
      "action": { "default_popup": "popup/index.html" },
      "background": { "service_worker": "background.js", "type": "module" },
      "permissions": ["activeTab", "storage", "scripting"],
      "host_permissions": [],
      "minimum_chrome_version": "120"
    }
    ```
    
    Rules that fail review or break the worker if you get them wrong:
    
    - `manifest_version` **must be `3`**. There is no `2` path forward.
    - `background.service_worker` is a **string**, not an array. There is no `background.scripts` and no `background.page` in MV3. The `persistent` key does not exist — delete it. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
    - `"type": "module"` lets the worker use `import`. Use it if you bundle.
    - Keep `host_permissions` empty until you can name exactly which sites and why (see permission table).
    
    ## The three contexts and how they talk
    
    The service worker is **ephemeral**. It terminates when idle and wakes on an event. Two consequences govern almost all your code:
    
    - **Register every listener synchronously at the top level.** If you call `chrome.runtime.onMessage.addListener` inside an `async` callback or after an `await`, Chrome may not have registered it when it wakes the worker, and your event is lost. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
    - **Never keep state in a global variable.** The worker dies and your variable resets to its initial value. Persist to `chrome.storage`.
    
    ```js
    // Bad — global resets to 0 every time the worker is killed and restarts
    let clickCount = 0;
    chrome.action.onClicked.addListener(() => {
      clickCount++;                          // silently back to 1 after idle
    });
    
    // Good — durable across worker restarts, listener registered at top level
    chrome.action.onClicked.addListener(async () => {
      const { clickCount = 0 } = await chrome.storage.local.get("clickCount");
      await chrome.storage.local.set({ clickCount: clickCount + 1 });
    });
    ```
    
    Messaging choices:
    
    - **One-shot** request/response: `chrome.runtime.sendMessage(msg)` / `chrome.tabs.sendMessage(tabId, msg)` paired with `chrome.runtime.onMessage`. Return `true` from the listener to keep the channel open for an async `sendResponse`.
    - **Long-lived** stream (e.g. a content script feeding the popup continuously): `chrome.runtime.connect()` → `Port`, with `port.onMessage` / `port.postMessage`.
    
    Content script → service worker → popup: there is no direct content-script-to-popup channel when the popup is closed. Route through the worker or through `chrome.storage` and let the popup read on open.
    
    ## Permissions — least privilege or you get rejected
    
    Reviewers reject broad permissions with no justification, and broad `host_permissions` trigger a scary install-time warning that tanks conversion. Declare the **narrowest** thing that works.
    
    | Permission | Grants | Warning? | Use when |
    |---|---|---|---|
    | `activeTab` | temporary access to the current tab, only after a user gesture (toolbar click) | none | the user clicks your icon and you act on that one page |
    | `scripting` | `chrome.scripting.executeScript` to inject programmatically | none alone (needs a host or `activeTab` to target) | inject on demand instead of on every page |
    | `host_permissions: ["https://example.com/*"]` | persistent access to those origins | yes, lists the sites | you must run in the background on specific sites |
    | `host_permissions: ["<all_urls>"]` | every site | loud, broad warning | almost never — avoid; prefer `activeTab` |
    | `optional_permissions` + `chrome.permissions.request()` | runtime opt-in inside a user gesture | shown only when requested | a feature only some users need; ask when they enable it |
    | `declarativeNetRequest` | static/dynamic rules block or modify requests, no request bodies seen | install-time warning | ad/tracker blocking — replaces blocking `webRequest` |
    | `declarativeNetRequestWithHostAccess` | same, but access granted per host instead of install-time | per-host | DNR scoped to granted hosts only |
    
    Rule: write a one-sentence justification for **every** permission before you add it. If you cannot, drop it. `activeTab` covers more cases than people expect — try it first. (Source: developer.chrome.com "Declare permissions" + "declarativeNetRequest", accessed 2026-06-02.)
    
    ## Content scripts: declarative vs programmatic
    
    ```json
    // Declarative — in manifest. Runs automatically on matching pages.
    "content_scripts": [{
      "matches": ["https://example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }]
    ```
    
    ```js
    // Programmatic — inject on a gesture. Needs "scripting" + activeTab or a host match.
    chrome.action.onClicked.addListener(async (tab) => {
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        files: ["content.js"],
      });
    });
    ```
    
    - `run_at`: `document_start` (before DOM), `document_end`, or `document_idle` (default, after load). Pick `document_start` only if you must beat the page's own scripts.
    - **Isolated world** (default): your content script's JS is sandboxed from the page's JS — they share the DOM but not variables. Use **MAIN world** (`"world": "MAIN"`) only when you must touch the page's own JS objects, and know it loses the isolation guarantee.
    - For dynamic, user-supplied injection, `chrome.userScripts.execute()` exists (Chrome 135, Mar 2025) — see [references/store-and-migration.md](references/store-and-migration.md).
    
    ## Storage, alarms, no remote code
    
    - `chrome.storage.local` (~10 MB, larger with `unlimitedStorage`) for most data; `chrome.storage.sync` (~100 KB, ~8 KB/item) only for small settings you want to follow the user across devices.
    - **`setTimeout`/`setInterval` are unreliable** in the worker — it may be asleep when they fire. Use `chrome.alarms` for anything beyond a few seconds. (Source: developer.chrome.com "Migrate to a service worker", accessed 2026-06-02.)
    - **Remotely hosted code is banned.** No `<script src="https://cdn...">`, no `eval`-of-fetched-string. All executable JS must ship inside the package — bundle every dependency. This is enforced by MV3's CSP and by review. (Source: developer.chrome.com "What is Manifest V3", accessed 2026-06-02.)
    
    ## Shipping to the Chrome Web Store
    
    1. **Build**, then **zip the `dist/` output** — never the repo. No `node_modules`, no `.git`, no source maps you do not want public.
    2. Pay the **one-time $5 USD** developer registration (covers up to 20 extensions on the account).
    3. Upload the zip in the Developer Dashboard.
    4. Listing assets: a **128×128 PNG** icon, **at least one screenshot** (1280×800 or 640×400), a clear description, a category, and a **privacy-policy URL if you collect any data**.
    5. Review is typically **1–3 business days** (simple extensions often under 24h).
    
    (Source: developer.chrome.com "Register your developer account" + fee guide, accessed 2026-06-02.) For the full dashboard walkthrough, data-disclosure form, staged rollout, appeals, and the MV2→MV3 migration map, see [references/store-and-migration.md](references/store-and-migration.md).
    
    ## Anti-patterns
    
    | Anti-pattern | Why it breaks | Do instead |
    |---|---|---|
    | `background.scripts` / `persistent: true` | MV2 shape; rejected, worker never registers | `background.service_worker: "bg.js"` (string) |
    | Listener added after an `await` | Chrome wakes the worker without your listener; event lost | register all listeners synchronously at top level |
    | State in a global var | worker dies, var resets silently | persist to `chrome.storage` |
    | `<all_urls>` when a click suffices | scary install warning, review pushback | `activeTab` triggered by the toolbar click |
    | `<script src="https://cdn…">` | remote code is banned in MV3 | bundle the dependency into the package |
    | `setInterval` for periodic work | fires only while worker is alive | `chrome.alarms` |
    | Zipping the repo / `node_modules` | bloated, leaks source, may fail review | zip only the built `dist/` |
    | Auth token in a global / `sync` | lost on restart, or synced off-device | `chrome.storage.local` (or `session` for in-memory) |
    | MAIN world by default | loses isolation, page can tamper | isolated world unless you must reach page JS |
    
    Run `scripts/verify.sh <dir>` to lint a produced `manifest.json` against the MV3 invariants above.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related