Skip to content

The module system

The kit ships as the full repo; the setup wizard prunes the modules you don’t keep. What makes that safe is a contract: every prunable feature module carries a module.json manifest declaring everything it owns (code, npm dependencies, env vars, manifest permissions, and docs). One declaration removes all of it together.

Manifests live at the package root for workspace packages (packages/gate/module.json) or under apps/extension/modules/<id>/module.json for app-level modules (broadcasts, error-reporting, content-demo, demo-newtab, demo-devtools), validated against tooling/config/module.schema.json.

{
"$schema": "../../tooling/config/module.schema.json",
"id": "billing",
"title": "Billing & entitlements",
"description": "Stripe checkout/portal routes, webhook-written entitlements, useEntitlement('paid').",
"files": ["packages/core-billing/**", "backend/functions/src/billing/**"],
"dependsOn": ["auth"],
"npmDependencies": { "@extensionstart/core-billing": "workspace:*" },
"env": [{ "name": "WXT_API_URL", "description": "deployed Functions base URL" }],
"permissions": [],
"wiring": ["apps/extension/entrypoints/background/index.ts"],
"docs": []
}
  • files globs are repo-root-relative; a module can own files outside its package (the auth module owns apps/extension/entrypoints/offscreen/**).
  • dependsOn is by module ID. The resolver keeps dependencies of any kept module (keeping gate force-keeps billing) and drops dependents of any dropped one (dropping billing drops gate too).
  • Core modules set "removable": false; the wizard never offers to prune them.
  • Shared last-owner rule: env vars and permissions listed by several modules (e.g. WXT_API_URL) are pruned only when no kept module lists them.
  • permissions is why the generated manifest.json shrinks when you prune: each module declares the chrome.* permissions and host patterns it needs, and a smaller permission surface means a faster, safer store review.

A module’s code often touches shared files it doesn’t own: the background import order, the messaging protocol, surface roots, e2e specs. Those touchpoints carry marker comments so the pruner can strip them without codemods:

  • Line marker: a // module:<id> suffix (or {/* module:<id> */} in JSX, /* module:<id> */ in CSS) removes that single line when <id> is dropped.
  • Block marker: everything from a line containing module:<id>:start through the line containing module:<id>:end (inclusive) is removed. Blocks of different modules may nest.

Each manifest lists the shared files carrying its markers under wiring; the pruner strips exactly those files. The hard pass criterion: any prune combination leaves pnpm typecheck and pnpm lint green with zero dangling imports.

Manifests list backend files a module owns as documentation of ownership, but the pruner leaves backend/** in place. The Hono app is one self-contained function; unused routes are harmless, and deleting them would require invasive edits to the backend entrypoint. Delete them manually if you want a minimal backend.

  1. Create the manifest (apps/extension/modules/<id>/module.json for an app-level feature) with id, title, description, and files globs for everything the module owns.
  2. Where your module touches shared files (adding an import to background/index.ts, a message to the protocol, a component to a surface), tag each touchpoint with a // module:<id> line marker or a module:<id>:start / module:<id>:end block, and list those files under wiring.
  3. Declare npmDependencies, env (names must exist in apps/extension/.env.example), permissions, and dependsOn as they apply.
  4. Keep the manifest in sync: adding a file, dependency, env var, or permission to the module means updating its module.json in the same change.
  5. Prove it prunes cleanly:
    Terminal window
    pnpm create extstart --dry-run --keep none # your module in the plan?
    Then, on a scratch branch, run a real prune that drops your module and check pnpm typecheck and pnpm lint stay green.