Skip to content

Architecture Overview

This is the map. Pullwatch is a handful of small pieces spread across three separate runtime contexts, and this page names every one of them and shows how they fit together. Each service in the tables below links straight to the deep dive that explains it, so if you read only one technical page, make it this one.

Imports use shared path aliases (@common/*, @src/*, @background/*, and others) so TypeScript, Vite, and Vitest agree; prefer them over long ../ paths into extension/ or across top-level src/ folders (see tsconfig.json and vite.aliases.ts). Full conventions: Import paths and aliases.


Pullwatch is split into three runtime contexts that Chrome treats as separate lifetimes. They do not call each other directly; they communicate through chrome.storage and occasional runtime messages.

ContextWhat lives thereLifetime
PopupThe React app, TanStack Query caches, Zustand UI stores. Reads from chrome.storage.local, writes settings to chrome.storage.sync.Lives only while the popup window is open. Every re open is a fresh React root.
Service workerAll background logic: alarms, fetching GitHub, running the parser waterfall, rate limit handling, notification dispatch.Chrome wakes it on demand and tears it down after about 30 seconds of inactivity. Every wake is cold state.
Offscreen documentA hidden page whose only job is to hold an AudioContext and play notification sounds. Exists because MV3 service workers cannot play audio.Created on demand by the service worker and torn down when Chrome decides it is idle.

The rest of this page zooms into each context.


The diagram below is the single picture worth holding in your head. Read it in two passes. First, top to bottom: the three boxes are the runtime contexts from the table above (Popup, Service worker, Offscreen document), and the cylinders along the side are the storage areas and external hosts they reach. Then follow the arrows: solid edges are the steady-state path that starts at the alarm and ends at a stored PR list, and the popup’s edges (bottom) only ever touch storage and the dispatch table, never GitHub directly. Notice that no arrow crosses from the popup straight to github.com.

flowchart TB
  subgraph Popup
    React[React UI]
    RQ[TanStack Query]
    Zustand[Zustand stores]
  end

  subgraph SW [Service worker]
    BM[BackgroundManager]
    EV[EventService]
    PR[PRService]
    GH[GitHubService]
    AL[AlarmService]
    RL[RateLimitService]
    PT[PatternRegistryService]
    NT[NotificationService]
    SO[SoundService]
  end

  subgraph Off [Offscreen document]
    Audio[AudioContext]
  end

  Local[(chrome.storage.local)]
  Sync[(chrome.storage.sync)]
  GitHub[(github.com)]
  Config[(pr-live-config)]

  AL -->|onAlarm| EV
  EV --> PR
  PR --> GH
  GH -->|fetch HTML| GitHub
  PT -->|fetch patterns.json| Config
  PR --> Local
  PR --> NT
  NT --> SO
  SO --> Off

  React --> RQ
  RQ -->|read on open| Local
  RQ -->|live updates| Local
  React --> EV
  React -->|settings| Sync

A few things worth noticing before you move on:

  • The popup never talks to github.com directly. Every arrow into GitHub comes out of the service worker.
  • The popup reads PR data from Local, not from a runtime message reply. That is on purpose; Popup and Background Communication explains why.
  • The alarm is the main clock. Everything that happens in steady state starts with AL -->|onAlarm| EV.

Each row below is a service in the background. Each has a single job and a small, clearly bounded surface. The right hand column points at the wiki page where that service is fully explained.

ServiceSourceRoleDeep dive
BackgroundManagerBackgroundManager.tsOrchestrates init on every wake. Calls permissions check, alarm setup, and badge sync from storage. Never fetches PRs.Service Worker Lifecycle
EventServiceEventService.tsRoutes runtime messages through a Map based dispatch table. Owns the depth counter that tracks overlapping fetch waves.Popup and Background Communication
PRServicePRService.tsCoordinates per list fetches. Dedupes concurrent calls, applies a 60 second TTL cache, runs account swap detection against github_viewer_identity.Service Worker Lifecycle
GitHubServiceGitHubService.tsThe only place the extension calls fetch() against github.com. Picks the route using a 24 hour route hint, then runs the parser gauntlet.The Parser Waterfall
AlarmServiceAlarmService.tsOwns the periodic fetch alarm. Default cadence is FETCH_INTERVAL_MINUTES = 3. Persists any dev override across worker restarts.Service Worker Lifecycle
RateLimitServiceRateLimitService.tsTracks GitHub 429 responses, applies exponential backoff (capped at 30 minutes), and persists the state so it survives a worker restart.Service Worker Lifecycle
PatternRegistryServicePatternRegistryService.tsPulls remote regex patterns every 6 hours. Validates with Valibot, compiles, and falls back to bundled defaults if anything is wrong.Remote Configuration
NotificationServiceNotificationService.tsBuilds and shows Chrome notifications for assigned and merged PRs only. Encodes the PR URL into the notification ID so click handling survives a wake.Notifications and Sound
SoundService + offscreenSoundService.ts, offscreen/Plays notification sounds. Manifest V3 workers cannot play audio, so a one off offscreen document holds the AudioContext.Notifications and Sound
StorageServiceStorageService.tsType safe wrapper around chrome.storage.local and chrome.storage.sync with retry on transient post wake failures.Data Hydration and Storage

These do narrower jobs and exist so the core services can stay small. You will run into them while reading code but they rarely need their own section.

ServiceSourceWhat it does
AvatarServiceAvatarService.tsNormalises and resolves avatar URLs, so parsers do not each rebuild the same logic.
BadgeServiceBadgeService.tsOwns the toolbar icon badge: the pending To Review count (draft-filtered), or ! when a parser breakage or outage flag is set. Derived from storage on every wake. See Inside the Popup.
DebugServiceDebugService.tsExposes structured diagnostics consumed by the in popup debug panel.
DevTestServiceDevTestService.tsDev only helpers for triggering notifications or clearing storage from the debug panel.
HealthStatusServiceHealthStatusService.tsOwns two persisted health flags (parser breakage, GitHub outage with reason tag), refreshes lastSeenAt on repeated outage signals, drops STORAGE_KEY_LAST_UNTRUSTED_FETCH_AT on outage clear, and broadcasts every transition. See GitHub Health and Outages.
GitHubStatusClientgithub-status-client.tsPolls summary.json once per wave (bypassCache: true on the alarm path). Two-minute cache, fail-OPEN to 'unknown'. Used by the popup banner to gate the Statuspage link. See Outage Banner and Statuspage.
AlarmSeqClockAlarmSeqClock.tsMonotonic per-wave counter advanced once per completed alarm by EventService. Anchors PrTombstoneStore to alarm waves rather than wall-clock milliseconds.
List-trust domainextension/background/domain/pr-list-trust/Decides whether a fresh fetch is allowed to replace the stored baseline. Houses PrListTrustAssessor, EmptyConfirmationTracker, MergedLimboPromoter, PrTombstoneStore, and MergedNotificationEligibility. See List Trust and Suspect Lists.
PermissionServicePermissionService.tsRuns the “do we actually have the permissions we declared” check on every wake.

The React popup boots once per open. Before the first render, main.tsx awaits a hydration step that reads the three PR list keys from chrome.storage.local and seeds them into TanStack Query. That is why the popup paints with real data on frame one, no spinner, no round trip. While it is open, a storage listener forwards any onChanged event into the same TanStack Query keys, so if the alarm fires mid session the lists update live. Zustand stores (global-error, debug, tab-control) hold UI only state that does not belong on the server. Full mechanics are on Data Hydration and Storage.


Three Chrome storage areas, each with a very specific job.

AreaWhat lives thereSynced across devices?
chrome.storage.localPR lists (github_assigned_prs, github_merged_prs, github_authored_prs), route hint, rate limit state, parser pattern registry, viewer identity for account swap detection, install gate flags, custom sound metadata, health flags (parser_breakage, github_outage with reason tag and lastSeenAt), Statuspage snapshot cache (github_status_cache), trust metadata (pr_list_trust_state, pr_tombstones_v1, alarm_seq, last_untrusted_fetch_at).No. This device only.
chrome.storage.syncUser settings: theme, notification preferences, sound choices.Yes, if Chrome sync is on.
chrome.storage.sessionManual refresh throttle timestamp (last_manual_refresh_at). Cleared when the browser quits.No. In memory only.

Data Hydration and Storage explains the full key list and the hydration contract.


Pullwatch is scoped so tightly at the manifest level that you can audit every HTTP request it can make just by reading the manifest.

HostWhy
https://github.com/*Fetches the pulls list HTML using the cookie your browser already has. Read only.
https://avatars.githubusercontent.com/*Serves avatar images for PR rows. Images only, same as on GitHub itself.
https://raw.githubusercontent.com/dragosdev-code/pr-live-config/*Downloads patterns.json for the parser. The path prefix is scoped to a single repo owned by the author.
https://www.githubstatus.com/*Reads the public Statuspage summary.json so the outage banner can be corroborated against a real incident. Anonymous, cached locally, read by GitHubStatusClient.

Those four origins are the whole list. No analytics endpoint, no crash reporter, no CDN owned by the project. If you ever see Pullwatch contact anything else in DevTools, that is a bug worth an issue.


You can read the deep dives in any order, but this sequence tends to flow well if you want a narrative tour:

  1. The Service Worker Lifecycle: why the worker is ephemeral and how Pullwatch deals with it.
  2. The Parser Waterfall: how the three stage parser stays resilient across GitHub’s two experiences.
  3. Remote Configuration: how parser regexes can be hot fixed without shipping a new build.
  4. Data Hydration and Storage: where every piece of state lives, and why.
  5. Popup and Background Communication: the runtime messaging surface, and why data flows through storage instead.
  6. Inside the Popup: what the popup actually shows: tabs, sorting, badges, empty states, and settings.
  7. Onboarding and Session Gates: the first run, signed out, and re auth flows.
  8. Notifications and Sound: the notification pipeline and the offscreen document.
  9. The Canary Monitor: how Pullwatch finds out GitHub’s DOM changed before users do.
  10. GitHub Health and Outages: the hub that maps “what could go wrong with a fetch” to “what the popup says”. Children: List Trust and Suspect Lists and Outage Banner and Statuspage.