1 Architecture
Richard Bergsma edited this page 2026-08-23 19:40:59 +02:00

Architecture

Manifest V3, no build step for the extension itself. The files you edit are the files that ship. Node is only used for tests and a couple of generators.

The two worlds

The manifest injects into YouTube pages twice, and the split is not incidental.

ISOLATEDdefaults.js, i18n-content.js, content.js at document_start, plus block.css. This is nearly everything: hiding, filtering, watch-time tracking, SponsorBlock, the toasts. It can call chrome.storage but cannot see the page's own JavaScript.

MAINdefaults.js, content-main.js at document_start. Only what genuinely needs the page's JS context lives here, because this world runs beside YouTube's own code with no extension APIs at all:

  • audio track selection and quality enforcement, which need #movie_player
  • the stalled-media guard, which wraps the page's fetch

The two talk over CustomEvent. Settings flow MAIN-ward on focusedyt-settings; the MAIN world reports back on focusedyt-speed-changed, and the ISOLATED world persists it. Anything the MAIN world wants stored has to travel this way.

defaults.js is not just defaults

Despite the name, it is the shared module — loaded into every world and every extension page, and the reason the test suite can reach anything at all.

  • FY_DEFAULT_SETTINGS and the sync/schedule/SponsorBlock default sets
  • Pure decision logic, exported as FY_* functions. FY_SHOULD_EXIT_FULLSCREEN_ON_END, FY_MEDIA_STALL_TIMEOUT_MS, FY_FOLD_SELECTOR_AUDIT, the blocklist parsers. The rule lives here; the DOM or network code that acts on it lives in the content scripts. That split is what makes the rules testable under node --test without a browser.
  • FY_AUDITED_SELECTORS — the registry of YouTube markup CleanWatch depends on.

When adding a feature with a real decision in it, put the decision here and give it a test. The pattern is deliberate and consistent.

The selector problem

Everything rests on YouTube's private class names and custom element tags. When YouTube renames one, the affected feature stops matching and fails silently — there is no exception, no console error, and the extension goes on looking like it works. The members badge did exactly that, matching .yt-badge-shape__text long after the label moved to .ytBadgeShapeText.

Three things push back:

  1. FY_AUDITED_SELECTORS collects the fragile selectors in one place, with the page type each applies to.
  2. auditSelectors() in content.js checks whether each still matches anything. It runs automatically a few seconds after a page settles, and is also callable as __CW_AUDIT_SELECTORS__() from the console.
  3. Selector health folds those runs into a rolling record in chrome.storage.local. A rule is only called broken after three consecutive dead readings, one live reading resets the streak, and a run in which nothing matched is discarded rather than believed — across 25 selectors on several page types a simultaneous rename does not happen, but an unrendered page does.

test/feed-dom.test.js also runs the selectors against markup fixtures copied from real pages, so a rename fails a test rather than a user.

Generated files

Two files are generated and must never be hand-edited:

File From Why
i18n-content.js scripts/build-i18n-content.js i18n.js carries all four languages for every extension page — 194 KB parsed at document_start on every YouTube navigation, to read eleven strings. The generated subset is 5.9 KB.
The wiki's Settings reference scripts/build-wiki-settings.js 115 rows kept by hand drift. The in-extension Help page was ~50 settings behind before 1.20.0 caught it.

test/i18n-content.test.js regenerates and compares bytes, and separately scans content.js for the i18n paths it actually uses — so a lookup added tomorrow is checked the moment it is written, not whenever someone remembers. build-store-zip.sh refuses to package a stale bundle.

Storage

Everything is chrome.storage.local, treated as local-first state:

  • settings, including popup quick-action order
  • watch time, lifetime stats, top channels
  • keyword blocklist, channel blocklist, trusted-channel allowlist
  • fySelectorHealth — selector names and match counts, nothing else

Server sync replicates a subset, and the subset is explicit: FY_SANITIZE_SYNCABLE_SETTINGS decides what may travel. Settings and per-device stats are encrypted with AES-256-GCM on the device, using a key derived from the sync code via HKDF, before anything is sent. The server (server-sync-api/, Fastify + SQLite) stores ciphertext it cannot read.

Two things deliberately do not sync: the stalled-media host verdicts, which are per-network and live in memory only, and the selector health record, which is a property of this browser's YouTube.

Tests

npm testnode --test test/*.test.js, no framework. jsdom where a DOM is needed.

The suites worth knowing about:

  • page-load.test.js loads every extension page exactly as the browser does, HTML then scripts in manifest order, and fails on any uncaught error. It exists because 1.17.0 and 1.18.0 shipped a settings page that was dead on arrival: a top-level call reached a const declared further down the file, still in its temporal dead zone. node --check passes such a file happily. It has caught the same class of bug since.
  • feed-dom.test.js runs selectors against real markup fixtures.
  • changelog.test.js pins the newest changelog-data.js entry to the manifest version, so bumping without writing a user-facing entry fails.
  • i18n-content.test.js guards the generated bundle both ways.
  • media-stall*.test.js, selector-health.test.js — the decision rules, plus a jsdom suite that drives the real fetch wrapper.

Release

Cut feature branches from dev, merge back with --no-ff, delete the branch. main is only ever reached through a PR from dev. Release markers are tags.

Each release updates both changelogs — CHANGELOG.md stays the complete technical record, changelog-data.js gets a short user-facing entry in four languages, which the changelog page renders and the update toast mines — bumps the manifest version, and rebuilds website/downloads/cleanwatch-latest.zip. That one zip is the website download, the release attachment, and the Web Store upload, so every channel ships identical bytes.