FitShield is one product with four separated compartments and a build step that recombines them into the flat artifacts each store expects. Keeping them apart is deliberate: the blocking engine is reusable across the browser extension and the Android app, and neither ships a copy of the other's code.
FS Engine/ the blocking engine — CODE ONLY, zero data, zero deps
data/ the canonical datasets — blocklists, alternatives, android app maps
extension/ the Chrome/Firefox extension shell — manifest, pages, shims, _locales, icons
android/ the Android app — reuses the engine's rules + the shared web assets
build.js recombines FS Engine + data + extension → dist/<browser>/ + zips
Everything in the extension talks to exactly two shared modules, and never to each other's internals:
| Global | File | Owns |
|---|---|---|
FitShieldBlocklist |
blocklist.js (generated from FS Engine/) |
Should this host be interrupted? Hostname normalization, entry matching, country/category policy. |
FitShieldCore |
extension/fitshield-core.js (hand-authored) |
What should happen around that? Storage schema and migrations, friction profiles, schedule evaluation, temporary-pass scopes and expiry, repeat-access friction, the statistics vocabulary, and validation of anything the user typed. |
FitShieldCore is deliberately pure — no DOM, no chrome.*, no network, and no
clock read it was not handed — because the same file runs in four places: the
Chromium service worker (importScripts), the Firefox event page
(background.scripts), every extension page (<script src>), and Node under
node --test. That is what makes a schedule, a pass expiry, or a migration
testable without a browser, and what stops the popup and the worker disagreeing
about what "evenings" or "until tomorrow" means.
Load order matters and is enforced: background.js references both globals, so
it is imported last in the Firefox background.scripts array
(tools/extension-audit.js) and importScripts only those two files
(tools/service-worker-audit.js).
The same rule applies to the pages, and is easier to get wrong there. These
are classic scripts sharing one global scope, so a <script> that captures
FitShieldCore at load time silently gets undefined if its tag sits above
fitshield-core.js — no error, just a module that quietly stops validating.
That exact bug shipped in backup.js and made restored backups skip clamping
and re-validation.
The enforced rule is ordering: every page lists fitshield-core.js before any
consumer, and test/page-scripts.test.js evaluates each page's real script chain
in document order, failing on a ReferenceError or on a shared constant nothing
in the chain declares. backup.js additionally resolves the global lazily, on
first use, and throws rather than continuing without it — it is the one path
where a missing validator degraded silently instead of failing, which is worse
than a crash. Other consumers bind at load and use core.* directly, so a broken
order throws immediately; that is acceptable precisely because the order is
tested.
FitShieldCore is shared across the extension's own surfaces — not with the
Android app, which does not bundle it and re-implements the subset it needs in
Kotlin. Every resulting behavioral difference is enumerated and classified in
docs/ANDROID.md §2e.
data/recipes.json keeps its historical name and recipes array (the Android
WebView and older readers still fetch it), but it is now generated:
data/alternatives-taxonomy.json vocabularies + blocked-category → craving maps
data/alternatives/*.json the authored entries, grouped by craving
↓ npm run generate:alternatives (tools/build-alternatives.js)
data/recipes.json one file, fetched in one request by the block page
Authored in parts because an 88-entry file is unreviewable in a diff; shipped as
one file because the block page must load everything it needs before a countdown
that may only last twenty seconds. tools/alternatives-audit.js runs in
npm run validate and npm test, and separates decidable errors (a missing
quantity, a vegan entry containing dairy, heat with no temperature or doneness
cue, an unknown tag) from fuzzy warnings (near-duplicate titles, an
ingredient that looks unreferenced) — a natural-language guess can never fail a
build. It also fails if data/recipes.json is stale relative to its sources.
Matching lives in extension/recipes.js (page-side, FitShieldRecipes), not in
FS Engine/: the engine stays code-only and blocking-only.
Deep dives: FS Engine/README.md (engine API + data contract) and docs/EXTENSION.md (build, engine linkage, manifests, and the block-page debugging runbook). This file is the map that ties them together.
| Concern | Source of truth (edit here) | Generated (never hand-edit) |
|---|---|---|
| Blocking logic | FS Engine/*.js |
extension/blocklist.js (synced), dist/*/blocklist.js |
| Decision layer | extension/fitshield-core.js |
— (hand-authored, copied verbatim by the build) |
| Blocklists | data/blocklists/*.json |
data/generated/*, extension/blocklists/, dist/*/blocklists/ |
| Alternatives | data/alternatives-taxonomy.json, data/alternatives/*.json |
data/recipes.json, extension/data/, dist/*/data/ |
| Changelog | changelog.json (root) |
extension/changelog.json (synced) |
| Extension shell | extension/ (hand-authored js/html/manifest) |
dist/chrome/, dist/firefox/, dist/apple/, dist/*.zip |
| Manifests | extension/manifest.json (Chromium base) |
dist/chrome/manifest.json, dist/firefox/manifest.json, dist/apple/extension/manifest.json (Safari, nightly) |
node build.js compiles every browser target on each run — Chrome, Firefox,
and Apple/Safari (macOS + iOS/iPadOS, nightly, staged to dist/apple/ and
wrapped into an Xcode app on macOS via tools/build-safari.js). All three are
the same payload with a per-browser manifest derivation (chromeManifest /
firefoxManifest / safariManifest in build.js). Android is a separate native
pipeline (npm run build:android); npm run build:all runs everything.
dist/ is git-ignored — it is entirely reproducible with node build.js.
The extension/ folder loads directly as an unpacked extension: the runtime
artifacts it fetches (blocklist.js, blocklists/, data/recipes.json,
changelog.json) are committed there, generated/copied from canonical
FS Engine/ + data/ by npm run sync. After editing the engine or data, run
npm run sync — which also refreshes the Android app's copy of the same shared
web assets, so the two can never drift from one ordinary edit.
tools/sync-audit.js (in npm run validate) and
test/extension-synced.test.js (in npm test) fail if a committed copy drifts.
The repo root is still not loadable — load extension/ (fastest) or the built
dist/chrome/ / dist/firefox/ (store-shaped).
The engine is authored as CommonJS modules in FS Engine/ and is never
hand-copied into the extension. build.js (bundleEngine) wraps those modules
into one deterministic classic script — blocklist.js — whose only public
surface is the FitShieldBlocklist global. That same bundle is committed at
extension/blocklist.js by npm run sync (so the source folder loads unpacked)
and written to the package root by build.js (so the store artifact ships it);
both come from the one bundleEngine, and tools/sync-audit.js proves the
committed copy equals it. test/engine-bundle.test.js proves the global is
byte-for-byte the same API as require("./FS Engine").
That global is the stable adapter boundary. Nothing in the extension reaches into engine internals by relative path:
background.js(service worker / Firefox event page) loadsblocklist.jsand calls the engine's public API (loadBlocklists,getEntryDomains,shouldBlockByCountry, …) to turn entries + user settings intodeclarativeNetRequestrules.browser-shim.jsis the platform adapter: it exposes a platform-agnosticfitshield.*façade overchrome.*so the same UI runs on the Android WebView (which shipsandroid-shim.jsinstead). The engine (FitShieldBlocklist) and the platform (fitshield) are the two boundaries — UI code targets those names, not raw APIs or engine files.- UI pages (
warning.html,popup.html) never touch the engine directly. They message the background worker (runtime.sendMessage), so every block decision, bypass, and stat flows through the single engine-backed worker.
The block page is the surface most sensitive to the engine split, because it depends on the engine transitively through the worker. The chain:
blocked site
→ declarativeNetRequest redirect (background.js, rules built from the engine)
→ warning.html (web_accessible_resource; the DNR redirect target)
loads: ambient.js, browser-shim.js, i18n.js, fitshield-core.js,
recipes.js, warning.js
fetches: data/recipes.json (the alternatives catalog),
_locales/<lang>/messages.json (localization)
→ warning.js messages the worker:
getBlockContext ← ONE round trip: brand, pause length (including any
repeat-visit addition and why), pass options, and the
user's matching preferences
then, as the user acts:
recordInterruption · recordBlockedBrand · recordAlternativeShown ·
recordAlternativeSelected · recordAlternativeDismissed · recordLeft ·
grantPass
→ background.js resolves each against FitShieldBlocklist + FitShieldCore
getBlockContext is deliberately one message rather than six: the page has to
render before a countdown that may only be twenty seconds long, and every extra
round trip to an MV3 worker can pay a cold-start cost.
Every recording path takes the same preview flag. warning.html?preview=1
runs the identical flow and records nothing — no interruption counted, no
rotation history written, no pass granted, no site unblocked. That is what
Settings → Preview and onboarding's "Show me" both open.
Every hop is guarded so a future path/layout change fails loudly instead of shipping a blank page:
build.js→verifyStagere-reads the staged package and assertswarning.html, each asset it loads,blocklist.js,data/recipes.json, and the engine datasets are all present — the build aborts otherwise.tools/extension-audit.jsproves the whole package graph is closed and no page carries an inline script (MV3 CSP).test/block-page.test.jschecks the packaged graph and renders the block page end-to-end against the real engine-backed worker (brand, block reason, recipes, stats, locale, theme).
node build.js # validate → stage FS Engine + data + extension → dist/ + zips
npm test # full suite (includes every audit + the block-page tests)
npm run validate # human-readable audit report
See docs/EXTENSION.md for per-browser loading, the manifest strategy, and what to check first when the block page misbehaves.