Clone
1
Architecture
David Keane edited this page 2026-06-09 01:30:32 +01:00

Architecture

RangerHQ Tuner is a Chrome Manifest V3 extension built with vanilla JavaScript ES modules — no React, no Vue, no Vite, no build step. The whole thing is ~36 source files and 133 KB zipped.

Why no framework / no build step

The extension's UI surface is small (a 360 px popup + a Settings page + a New Tab Page). React or Svelte would add Vite/Rollup tooling, source maps, CSP headaches, and a dist/ build step for negligible developer-experience gain at this scale. The WordPress family (rangerhq-radio, rangerhq-buddy) is hand-rolled PHP for the same reason — pick the right tool for the size of the surface.

The exception trigger is documented at the top of the project plan: if the popup ever grows beyond ~5 distinct views, switch to Lit or Preact (both work without a build step via ESM CDN).

The Manifest V3 audio problem

MV3 service workers can be killed by Chrome at any time when they go idle (typically after 30 seconds of no events). An <audio> element instantiated in a service worker would die with it, causing playback to stop randomly. The official MV3 mechanism for persistent audio is the chrome.offscreen API, which lets the extension spawn a hidden DOM document specifically for tasks the service worker can't do.

RangerHQ Tuner creates exactly one offscreen document, lazily on the first user-initiated play, with reasons: ['AUDIO_PLAYBACK']. The document holds a single <audio> element and the 25-second metadata polling loop.

High-level component map

            ┌─────────────────┐         ┌──────────────────┐
            │  Toolbar popup  │         │   New Tab Page   │
            │  (popup/*)      │         │   (newtab/*)     │
            └──────┬──────────┘         └─────────┬────────┘
                   │                              │
                   │   chrome.runtime.sendMessage │
                   │   target: 'sw'               │
                   ▼                              ▼
            ┌──────────────────────────────────────────────┐
            │   Service Worker (background/service-worker) │
            │   - opens offscreen doc on demand            │
            │   - routes PLAY/PAUSE/SET_VOLUME messages    │
            │   - storage gateway for offscreen writes     │
            └──────┬───────────────────────────────────────┘
                   │
                   │   chrome.runtime.sendMessage
                   │   target: 'offscreen'
                   ▼
            ┌──────────────────────────────────────────────┐
            │   Offscreen document (offscreen/*)           │
            │   - owns the <audio> element                 │
            │   - 25-second metadata polling loop          │
            │   - asks SW to write history entries         │
            └──────┬───────────────────────────────────────┘
                   │
                   │   chrome.runtime.sendMessage
                   │   target: 'popup' (UI bucket)
                   ▼
            ┌──────────────────────────────────────────────┐
            │   UI surfaces (popup + newtab + options)     │
            │   re-render on STATE_CHANGED / METADATA_     │
            │   UPDATED / TRACK_LOGGED                     │
            └──────────────────────────────────────────────┘

    chrome.storage.local
    ┌───────────────────────────────────────────────────────┐
    │ tuner.history          tuner.favourites               │
    │ tuner.stationsCache    tuner.cachedAt                 │
    │ tuner.currentStationId tuner.volume   tuner.isPlaying │
    │ tuner.historyCap                                      │
    └───────────────────────────────────────────────────────┘
        ▲                                              ▲
        │  popup/newtab read/write                     │ Options page
        └─────  chrome.storage.onChanged ──────────────┘
                  (cross-surface sync)

Three architectural decisions, ranked by importance

1. The source-adapter pattern (src/sources/)

Every radio network is a single module that conforms to the RadioSource JSDoc interface in src/sources/base-source.js. The popup, the new tab, and the offscreen document never know which network is which — they all query through src/sources/index.js's registry. Adding a new network = drop a new file + add one line to the registry.

See Adding a Radio Source for the practical walkthrough.

2. Audio in an offscreen document, not the service worker

Already explained above. The cost: one extra message hop (popup → SW → offscreen). The benefit: audio that doesn't randomly cut out.

3. Storage gateway through the service worker

The offscreen document doesn't reliably have chrome.storage access in some Chrome versions, so the offscreen polling loop sends a LOG_TRACK_REQUEST message to the service worker, which has unrestricted storage access. The SW writes to tuner.history and broadcasts TRACK_LOGGED back to the UI surfaces.

The library file src/lib/history.js also defensively guards every storage call with a storage() helper that returns null if chrome.storage is absent — so even if the module is imported into an edge context, it returns empty defaults instead of throwing.

Cross-surface state sync

Both the popup and the New Tab Page subscribe to chrome.storage.onChanged. When the user picks a station in one surface, the other surface re-renders without a manual reload:

  • tuner.currentStationId change → re-resolve currentStation from the local catalogue, re-render Now Playing + quick-station chips + active-row highlight
  • tuner.isPlaying change → re-render the play/pause button state
  • tuner.history change → re-render the History tab
  • tuner.favourites change → re-render the Favourites tab AND the star indicators in History

Single source of truth = chrome.storage.local. No drift between surfaces.

Permission profile (deliberately narrow)

"permissions":      ["offscreen", "storage"],
"host_permissions": ["https://somafm.com/*", "https://*.somafm.com/*"]
  • No tabs permission. The popup's "Open in tab" / "History" / "Settings" buttons use chrome.tabs.create() and chrome.runtime.openOptionsPage(), both of which work on the extension's own URLs without needing tabs.
  • No <all_urls> permission. Never reads or writes to user-visited pages.
  • No webRequest permission. No traffic interception.
  • No cookies permission. No cookie access.
  • No remote code. Every script that runs ships inside the package — no eval, no dynamically loaded JS.

Code paths to read first

If you've cloned the repo and want to understand the flow, read these files in order:

  1. manifest.json — the entry point Chrome sees.
  2. src/popup/popup.js — UI flow from a station click to a PLAY message.
  3. src/background/service-worker.js — message router + offscreen creator + storage gateway.
  4. src/offscreen/offscreen.js<audio> host + metadata polling loop.
  5. src/sources/somafm.js — first concrete adapter, see the contract in action.
  6. src/lib/history.js — history/favourites/search-URL helpers, mirrors the WordPress sibling's inc/history.php.

Total reading time end-to-end: about 20 minutes.