@ultimat3/pwa 19.2.0 → 19.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -17,6 +17,8 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
17
17
  | Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
18
18
  | Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. |
19
19
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
20
+ | A truncation of author text | CODE POINTS, never `slice`. `short_name` falls back to the first 12 of `name`, and a UTF-16 `slice` cuts inside a surrogate pair — the lone half encodes as U+FFFD, so an app named with an emoji got a home-screen label ending in a replacement character. `Array.from(name).slice(0, n).join('')` is the form. |
21
+ | Declared and never wired | `ServiceWorkerConfig.shellUrl` / `shellRevision` / `shellBytes` and `PwaRoute.dataUrl`. No caller in the framework's build path sets any of them, so `precache.ts`'s `reason: 'shell'` and `reason: 'route-data'` branches are reachable only from a hand-built `generateServiceWorker` call. Each doc comment now says so; the shell trio's said "precached for every `spa` route" while `spa` was already deleted from `RENDER_MODES`. **Kept, not deleted** — a public field is a major — and carried as candidates for the next major's declared-and-never-wired half. `revision` and `bytes` are the pair that IS fed, by the CLI's prerender pass. |
20
22
  | Every byte count and every threshold | a whole number of 0 or more, screened with `finiteCount` where it is READ — `buildPrecacheManifest`'s `warnBytes` AND each entry's own `bytes` (shell, route, asset), `createInstallController`'s `minEngagementMs`, `retentionPlan`'s `keep`. `totalBytes > warnBytes` is false when EITHER side is `NaN`, so one asset whose byte count did not arrive as a number took down the install-size warning for every other entry, silently, in the function whose output is otherwise byte-identical per commit; `now() - startedAt < NaN` is false at every instant, so the install prompt fired on first paint; and `Math.max(1, NaN)` is `NaN` with `slice(0, NaN)` `[]`, so a retention plan evicted every deploy including the running one. `Math.max` is not a validator — it propagates. `keep: 0` still means 1 (`version-skew.test.ts` pins it), so the floor is 0 everywhere here. `bun run finite-bounds` is the ratchet. |
21
23
  | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. `addAll` still does the fetching, because its all-or-nothing failure is what stops a half-populated precache from activating; the install block only re-keys what it stored. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
22
24
  | Precache FETCH | the revision is appended with `?` or `&`, picked per entry. `PrecacheAsset.url` is public API and a bundler emits `?v=<hash>` of its own, so a fixed `?` built `...?locale=en?v=<rev>`; `addAll` is all-or-nothing, so one non-200 there means `install` rejects and the worker never activates at all. |
@@ -25,11 +27,15 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
25
27
  | A byte count in a warning | `formatBytes` from `@ultimat3/core`, never a local one. The copy that lived in `precache.ts` stopped at `mb`, so a precache past a gigabyte reported a four-digit `mb` — and `@ultimat3/render`'s copy stopped at `kb`, so the two halves of one build printed different units for the same bytes. Still on this barrel: a size report is what a caller of this package prints. |
26
28
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
27
29
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
30
+ | Its `fix:` names a route the framework accepts | `x g route offline --surface site # then set pwa.offline.fallback to '/offline' in app.config.ts`. It said `create app/offline.tsx and set offline.fallback`, and BOTH halves were wrong: `<name>.tsx` is not a route file — `registerRoute` refuses it with `X_ROUTE_FILE_INVALID`, because the directory is the URL — and `offline.fallback` names no key `app.config.ts` has. A reader who followed it built a component nothing imports, left `/offline` a URL that does not exist, and met the same refusal on the next build; `wiki/Upgrading.md` had already recorded that path as the wrong one. `--surface site`, not `app`: the document answering a lost network must render with no network, no session and no database, which `app/` (`ssr \| stream`) cannot promise. `@ultimat3/cli`'s `doctor-offline.ts` writes the same line for the same code. |
31
+ | The offline document's revision | `ServiceWorkerConfig.offlineFallbackRevision` / `offlineFallbackBytes`, forwarded to `buildPrecacheManifest`. `ServiceWorkerConfig` had no field for either, so the one page an offline navigation depends on was the single precache entry stamped with the **build id** — re-downloaded on every deploy, which the `Precache revision` row above forbids for everything else — and counted as **0 bytes** against `warnBytes`, under-counting the install by exactly the size of the page that has to survive a lost network. Unlike the shell trio, this pair is meant to be fed: the CLI's prerender pass holds both values. |
28
32
  | Capabilities | gate the manifest member **and** the SW block, where the capability has one. Disabled → zero bytes. `push`, `backgroundSync` and `badging` emit worker code; `shareTarget`, `fileHandlers` and `protocolHandlers` are **manifest-only** — the OS delivers to a route the app already serves — and their `CAPABILITY_SW_MARKERS` list is empty, which `service-worker.test.ts` checks in both directions: every declared marker is in the worker when its capability is on, none is when they are all off. `shareTarget` named `/_x/share-target` there while no block emitted it. |
29
33
  | `AppUpdateAvailable` | declares exactly the fields the emitted `activate` block posts, and no more. `version-skew.test.ts` reads the literal back out of the generated source and compares it to a fixture typed `Required<AppUpdateAvailable>` — a field added to the interface stops compiling, a field added to the worker fails the assertion. It declared five and posted two until 9.0.0; the three extra described a forced reload nothing performed. |
30
34
  | Forced reload | **not a capability of this package.** `updateSignal`/`updatePolicy` computed `forced`/`deadlineAt` with no runtime caller and were deleted in 9.0.0. The two runtimes holding both build ids are BELOW this one — `http`'s `ctx.clientBuildId` (tier 2), `sync`'s `update-available` frame (tier 3) — so neither could ever have called into tier 4 to act on one. The app renders its own affordance; the framework never navigates a client. |
35
+ | Outbox drain | TWO doors, and the second was missing for the framework's whole history: the `sync` event, and a `{type:'flush-outbox'}` message. `registerBackgroundSyncSource`'s fallback posts that message wherever `registration.sync` is absent — **Safari and Firefox** — and `messageBlock` answered only `skip-waiting` and `build-id`, so on exactly the browsers the fallback exists for an offline mutation queue was never drained: no rejection, no request, no log. The branch lives in the shared message handler rather than in `backgroundSyncSource`, so `CAPABILITY_SW_MARKERS.backgroundSync` carrying `d.type==='flush-outbox'` is what keeps it gated with the `flushOutbox` it calls — an ungated branch is a `ReferenceError` inside `waitUntil` in every app with the capability off. `service-worker-runtime.test.ts` posts the message and asserts the POST. |
31
36
  | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger — and schedules **no retry**: the handler rejects and the PLATFORM decides when to wake it again. `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23; of the policy only `maxAttempts` ever reached the worker, as a `SYNC_MAX_ATTEMPTS` constant nothing read, and the emitted `X_PWA_SYNC_INCOMPLETE` fix told the reader to raise `pwa.backgroundSync.retry.maxAttempts`, a key `PwaConfig` has never had. `background-sync.test.ts` asserts that every constant the worker declares is one the worker reads. |
32
37
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
38
+ | `renotify` | never without a `tag`, at BOTH ends. The Notifications spec makes `showNotification` reject with a `TypeError` for the pair, so the device shows **nothing** — the failure a `renotify: true` with no collapse key produces is the whole notification disappearing. `renderPushPayload` drops the flag and files a `warnings` entry (a knob with no effect is worse than no knob), and the emitted handler recomputes it as `!!d.renotify&&!!d.tag` because a push body is composed by whatever holds the VAPID key. `push.test.ts`'s `showNotification` stub throws the spec's TypeError, so a stub that tolerates the pair cannot go green. |
33
39
  | Push URLs | `PushPayload.url` is a PATH and `WindowClient.url` is absolute, so `notificationclick` resolves against `self.location.origin` before comparing. Unresolved, the focus-existing-tab loop matched nothing and every tap opened a second window. |
34
40
  | Colours | `@ultimat3/core`'s `PwaColors`, passed in as `WebManifestInput.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. This package declared `ThemeTokens`/`SchemeColors` itself until 2026-08-27: structurally identical to what `app.config.ts` already carried, with no map between them and nothing asserting they agreed, which is the axiom-1 defect the two `PwaConfig`s were. Deleted; tier 0 owns the shape. |
35
41
  | `WebManifestInput` | what `generateWebManifest` takes, and **not** the `pwa` block of `app.config.ts` — it was called `PwaConfig` and said it was, while `@ultimat3/core` exported a different type of the same name that really is the block. An app declares `name` and `colors`; every other member is a caller's (`icons` from `planIcons`, `capabilities` from the build). `packages/cli/src/pwa-artifacts.ts` is the one composer of the two, and the only caller of `generateWebManifest` in the tree. |
package/README.md CHANGED
@@ -55,7 +55,7 @@ detectSkew(clientBuildId, message.to); // 'current' | 'stale' | 'unknown'
55
55
  ```
56
56
  X_PWA_NO_OFFLINE_FALLBACK: no offline fallback route
57
57
  cause: app.config.ts has no `offline` block, so an offline navigation would show the browser's error page
58
- fix: create app/offline.tsx and set offline.fallback
58
+ fix: x g route offline --surface site # then set pwa.offline.fallback to '/offline' in app.config.ts
59
59
  ```
60
60
 
61
61
  `requireOfflineFallback(config)` runs inside `generateServiceWorker`, so the build fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "19.2.0",
35
- "@ultimat3/seo": "19.2.0"
34
+ "@ultimat3/core": "19.3.2",
35
+ "@ultimat3/seo": "19.3.2"
36
36
  }
37
37
  }
@@ -58,6 +58,10 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
58
58
  * worker when its capability is on, and none of them is when they are all off. `PwaSyncError` is
59
59
  * the background-sync handler's own error class, so it ships with the handler and never without it.
60
60
  *
61
+ * `flush-outbox` is the same kind of pair the other way round: the branch lives in the shared
62
+ * message handler, not in the capability's own block, so only its presence in this table keeps it
63
+ * gated with the `flushOutbox` it calls.
64
+ *
61
65
  * An EMPTY list is a claim too, and the true one for the three manifest-only capabilities: a share
62
66
  * target, a file handler and a protocol handler are all delivered by the OS to a URL the app
63
67
  * already serves, so the member is the whole feature and the worker has no branch to add.
@@ -65,7 +69,7 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
65
69
  */
66
70
  export const CAPABILITY_SW_MARKERS = Object.freeze<Record<Capability, readonly string[]>>({
67
71
  push: ["addEventListener('push'", "addEventListener('notificationclick'"],
68
- backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'],
72
+ backgroundSync: ["addEventListener('sync'", 'class PwaSyncError', "d.type==='flush-outbox'"],
69
73
  badging: ['navigator.setAppBadge'],
70
74
  shareTarget: [],
71
75
  fileHandlers: [],
package/src/manifest.ts CHANGED
@@ -153,7 +153,7 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
153
153
 
154
154
  const manifest: WebManifest = {
155
155
  name: config.name,
156
- short_name: config.shortName ?? config.name.slice(0, 12),
156
+ short_name: config.shortName ?? shortNameFrom(config.name),
157
157
  start_url: config.startUrl ?? scope,
158
158
  scope,
159
159
  display: config.display ?? 'standalone',
@@ -179,6 +179,19 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
179
179
 
180
180
  type MutableManifest = { -readonly [K in keyof WebManifest]?: WebManifest[K] };
181
181
 
182
+ /** Home-screen labels are truncated by the OS well before this; the cap only bounds the string. */
183
+ const SHORT_NAME_MAX_CODE_POINTS = 12;
184
+
185
+ /**
186
+ * CODE POINTS, never `slice` — `String.prototype.slice` counts UTF-16 code units, so a name whose
187
+ * 12th unit is the high half of a surrogate pair (any emoji, any astral script) is cut mid
188
+ * character, and the lone surrogate that survives is what every UTF-8 encoder replaces with U+FFFD.
189
+ * The install prompt then offers a label ending in `` on the one surface a `short_name` exists for.
190
+ */
191
+ function shortNameFrom(name: string): string {
192
+ return Array.from(name).slice(0, SHORT_NAME_MAX_CODE_POINTS).join('');
193
+ }
194
+
182
195
  function assertValid(config: WebManifestInput): void {
183
196
  if (config.name.trim() === '') {
184
197
  throw new PwaManifestInvalidError(
@@ -23,11 +23,33 @@ export interface OfflineFallback {
23
23
  readonly neverCache: readonly string[];
24
24
  }
25
25
 
26
- const FIX = 'create app/offline.tsx and set offline.fallback';
26
+ /**
27
+ * The edit, then the command — TWO actions, and neither is optional. It was `create
28
+ * app/offline.tsx and set offline.fallback`, and both halves were wrong: `<name>.tsx` is not a
29
+ * route file at all — `registerRoute` refuses it with `X_ROUTE_FILE_INVALID` because the
30
+ * directory is the URL — and `offline.fallback` names no key `app.config.ts` has. So a reader who
31
+ * followed this line literally created a component nothing imports, left `/offline` a URL that
32
+ * does not exist, and met the same refusal on the next build. `wiki/Upgrading.md` already recorded
33
+ * `apps/web/app/offline.tsx` as the wrong path.
34
+ *
35
+ * The edit comes FIRST, and the command is not the head of the line: with `x g route … # then
36
+ * set …` the second half was a shell COMMENT, so the line read as one paste that does the whole
37
+ * job and did half of it — the route appeared, `pwa.offline.fallback` stayed unset, and the very
38
+ * next build raised this same error. A `fix:` that half-runs is worse than one that names two
39
+ * steps, because the reader has no signal that anything is left. The contract's own accepted
40
+ * shape is an edit naming its file (`set auth.signInPath = '/signin' in app.config.ts`).
41
+ *
42
+ * `--surface site`, and not `app`: the document that answers a lost network must render with no
43
+ * network, no session and no database, which `app/` (`ssr | stream`) cannot promise — and only a
44
+ * `static` route is prerendered, so an `app/` fallback has no document to precache at all. The
45
+ * command writes `apps/web/site/offline/page.tsx`, which is what `x new` scaffolds.
46
+ */
47
+ const FIX =
48
+ "set pwa: { offline: { fallback: '/offline' } } in app.config.ts, then create the route it names: x g route offline --surface site";
27
49
 
28
50
  /**
29
51
  * Build-time gate, called by `generateServiceWorker` and by `x doctor`. The fix line is
30
- * the literal two-step edit, not a doc link.
52
+ * the config edit plus the command that creates the route it names, not a doc link.
31
53
  */
32
54
  export function requireOfflineFallback(
33
55
  config: Partial<OfflineConfig> | undefined | null,
@@ -49,7 +71,7 @@ export function requireOfflineFallback(
49
71
  if (!fallback.startsWith('/')) {
50
72
  throw new PwaNoOfflineFallbackError(
51
73
  `offline.fallback is ${JSON.stringify(fallback)}, which is not an absolute route path`,
52
- `set offline.fallback to '/${fallback.replace(/^\/+/, '')}'`,
74
+ `set pwa.offline.fallback to '/${fallback.replace(/^\/+/, '')}' in app.config.ts`,
53
75
  );
54
76
  }
55
77
 
package/src/precache.ts CHANGED
@@ -34,13 +34,31 @@ export interface PrecacheInput {
34
34
  readonly buildId: string;
35
35
  readonly routes: readonly PwaRoute[];
36
36
  readonly assets?: readonly PrecacheAsset[];
37
- /** The app shell URL, precached for every `spa` route. */
37
+ /**
38
+ * A single document precached for every route, whatever its render mode — an app shell.
39
+ *
40
+ * **Nothing in the framework's build path sets this trio.** It said "precached for every `spa`
41
+ * route" until `As of 2026-09`, and `spa` was deleted from `RENDER_MODES` — so the sentence
42
+ * named a mode that cannot be declared, over a branch no caller reaches. Today the only way to
43
+ * a `reason: 'shell'` entry is a hand-built `generateServiceWorker` call: `x build`'s composer
44
+ * (`packages/cli/src/sw-artifacts.ts`) passes routes and assets and never a shell. Kept because
45
+ * deleting a public field is a major; a candidate for the next one's declared-and-never-wired
46
+ * sweep, with `PwaRoute.dataUrl`.
47
+ */
38
48
  readonly shellUrl?: string;
49
+ /** Defaults to `buildId`, which re-downloads the shell on every deploy. */
39
50
  readonly shellRevision?: string;
40
51
  readonly shellBytes?: number;
41
52
  /** The mandatory offline document. */
42
53
  readonly offlineFallbackUrl?: string;
54
+ /**
55
+ * Content hash of the built offline document. Absent, the entry is stamped with `buildId` and
56
+ * every deploy re-downloads the one page an offline navigation depends on — the rule this file's
57
+ * header states for every other entry, which this one had no field to obey.
58
+ */
43
59
  readonly offlineFallbackRevision?: string;
60
+ /** Byte size of the same document. Absent, it counts as 0 against `warnBytes`. */
61
+ readonly offlineFallbackBytes?: number;
44
62
  /** Warn past this total. Default 5 MB: past that, install stalls on a bad connection. */
45
63
  readonly warnBytes?: number;
46
64
  }
@@ -75,7 +93,7 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
75
93
  add({
76
94
  url: input.offlineFallbackUrl,
77
95
  revision: input.offlineFallbackRevision ?? input.buildId,
78
- bytes: 0,
96
+ bytes: input.offlineFallbackBytes ?? 0,
79
97
  reason: 'fallback',
80
98
  });
81
99
  }
package/src/push.ts CHANGED
@@ -66,6 +66,7 @@ export interface PushPayload {
66
66
  readonly tag?: string;
67
67
  readonly icon?: string;
68
68
  readonly badge?: string;
69
+ /** Alert again when the `tag` above is replaced. Ignored, with a warning, without one. */
69
70
  readonly renotify?: boolean;
70
71
  readonly requireInteraction?: boolean;
71
72
  readonly actions?: readonly { readonly action: string; readonly titleKey: string }[];
@@ -82,7 +83,11 @@ export interface RenderedNotification {
82
83
  readonly requireInteraction: boolean;
83
84
  readonly actions: readonly { readonly action: string; readonly title: string }[];
84
85
  readonly locale: string;
85
- /** Missing-key markers (`⟦key⟧`) surfaced instead of being shipped to a user. */
86
+ /**
87
+ * What the composing server must see and the device must not: a missing-key marker (`⟦key⟧`),
88
+ * and a `renotify` dropped for want of a `tag`. Never serialized — `serializePushMessage` picks
89
+ * the wire fields by name.
90
+ */
86
91
  readonly warnings: readonly string[];
87
92
  }
88
93
 
@@ -102,14 +107,30 @@ export function renderPushPayload(
102
107
  return value;
103
108
  };
104
109
 
110
+ // `renotify` means "replace the notification carrying this tag and alert again", so the spec
111
+ // gives it no meaning without one: `showNotification` REJECTS with a `TypeError` and the device
112
+ // shows nothing at all. Dropped here rather than passed on, and reported rather than dropped in
113
+ // silence — a flag whose only effect is that the notification disappears is the shape this
114
+ // repository keeps re-shipping (`jobs.driver`, `PwaConfig.installPrompt`).
115
+ //
116
+ // The test is EMPTINESS, not presence: `tag` defaults to the empty DOMString in
117
+ // `NotificationOptions`, so `tag: ''` is the very pair the spec rejects and `!== undefined`
118
+ // waved it through — the guard reintroducing the `TypeError` it exists to prevent. `''` is also
119
+ // what a composing server produces from an unset collapse key, which is the common case.
120
+ const tag = payload.tag === undefined || payload.tag === '' ? undefined : payload.tag;
121
+ const renotify = (payload.renotify ?? false) && tag !== undefined;
122
+ if (payload.renotify === true && tag === undefined) {
123
+ warnings.push(`renotify needs a tag to replace — dropped for ${payload.titleKey}`);
124
+ }
125
+
105
126
  return {
106
127
  title: render(payload.titleKey),
107
128
  body: render(payload.bodyKey),
108
129
  url: payload.url,
109
- tag: payload.tag ?? null,
130
+ tag: tag ?? null,
110
131
  icon: payload.icon ?? null,
111
132
  badge: payload.badge ?? null,
112
- renotify: payload.renotify ?? false,
133
+ renotify,
113
134
  requireInteraction: payload.requireInteraction ?? false,
114
135
  actions: (payload.actions ?? []).map((action) => ({
115
136
  action: action.action,
@@ -152,8 +173,15 @@ export function pushSource(options: PushSourceOptions = {}): string {
152
173
  return `
153
174
  self.addEventListener('push',(event)=>{
154
175
  const d=event.data?event.data.json():{};
176
+ // renotify needs the tag: showNotification rejects with a TypeError when the flag is set and
177
+ // the tag is empty, and the device shows nothing. renderPushPayload refuses the pair too, but a
178
+ // push body is composed by whatever holds the VAPID key, so this is the last guard there is.
179
+ // A NON-EMPTY STRING, never truthiness: WebIDL converts every tag to a DOMString, and [] and {}
180
+ // are truthy in JS while converting to '' and '[object Object]'. !!d.tag therefore enabled
181
+ // renotify for a JSON [] whose converted tag is empty — the rejection this line exists to avoid.
182
+ const tg=typeof d.tag==='string'&&d.tag!==''?d.tag:undefined;
155
183
  const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
156
- tag:d.tag||undefined,renotify:!!d.renotify,requireInteraction:!!d.requireInteraction,
184
+ tag:tg,renotify:!!d.renotify&&tg!==undefined,requireInteraction:!!d.requireInteraction,
157
185
  lang:d.lang||undefined,actions:d.actions||[],data:{url:d.url||'/'}};
158
186
  event.waitUntil(self.registration.showNotification(d.title||'',opts)${
159
187
  badging ? '.then(()=>navigator.setAppBadge&&navigator.setAppBadge())' : ''
@@ -35,9 +35,23 @@ export interface ServiceWorkerConfig {
35
35
  readonly offline: Partial<OfflineConfig>;
36
36
  readonly capabilities?: CapabilityFlags;
37
37
  readonly assets?: readonly PrecacheAsset[];
38
+ /**
39
+ * Forwarded verbatim to `buildPrecacheManifest`, which documents the whole trio: no caller in
40
+ * the framework's build path sets it, so `reason: 'shell'` is reachable only from a hand-built
41
+ * call to this function.
42
+ */
38
43
  readonly shellUrl?: string;
39
44
  readonly shellRevision?: string;
40
45
  readonly shellBytes?: number;
46
+ /**
47
+ * Content hash and byte size of the built offline document, forwarded to
48
+ * `buildPrecacheManifest`. Absent, the entry is stamped with the build id and counted as 0
49
+ * bytes — so the one page an offline navigation depends on is re-downloaded on every deploy,
50
+ * which is what `Precache revision` forbids for every other entry. Unlike the shell trio above,
51
+ * this pair is meant to be fed: the CLI's prerender pass already holds both values.
52
+ */
53
+ readonly offlineFallbackRevision?: string;
54
+ readonly offlineFallbackBytes?: number;
41
55
  readonly vapid?: VapidConfig;
42
56
  readonly backgroundSync?: BackgroundSyncOptions;
43
57
  /** Build ids whose caches must survive this activation (see `retentionPlan`). */
@@ -191,6 +205,14 @@ export function generateServiceWorker(
191
205
  ...(config.shellUrl === undefined ? {} : { shellUrl: config.shellUrl }),
192
206
  ...(config.shellRevision === undefined ? {} : { shellRevision: config.shellRevision }),
193
207
  ...(config.shellBytes === undefined ? {} : { shellBytes: config.shellBytes }),
208
+ // Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
209
+ // different answer from an absent key, and `?? input.buildId` is what reads it.
210
+ ...(config.offlineFallbackRevision === undefined
211
+ ? {}
212
+ : { offlineFallbackRevision: config.offlineFallbackRevision }),
213
+ ...(config.offlineFallbackBytes === undefined
214
+ ? {}
215
+ : { offlineFallbackBytes: config.offlineFallbackBytes }),
194
216
  ...(config.precacheWarnBytes === undefined ? {} : { warnBytes: config.precacheWarnBytes }),
195
217
  });
196
218
 
@@ -207,7 +229,7 @@ export function generateServiceWorker(
207
229
  INSTALL_BLOCK,
208
230
  activateBlock(),
209
231
  fetchBlock(),
210
- messageBlock(),
232
+ messageBlock(isEnabled(capabilities, 'backgroundSync')),
211
233
  ];
212
234
 
213
235
  if (isEnabled(capabilities, 'push') && config.vapid !== undefined) {
@@ -388,11 +410,25 @@ async function healSkew(req,res){
388
410
  }`.trim();
389
411
  }
390
412
 
391
- function messageBlock(): string {
413
+ /**
414
+ * The window → worker channel. `flush-outbox` is here and not in `backgroundSyncSource` because
415
+ * the page is what sends it: `registerOutboxSync` falls back to an `online` listener posting this
416
+ * message wherever `registration.sync` is absent — Safari and Firefox — and with no branch for it
417
+ * the offline mutation queue was never drained on exactly the browsers the fallback exists for.
418
+ * Silent, too: no rejection, no request, no log.
419
+ *
420
+ * Gated on the capability, because `flushOutbox` is only emitted with `backgroundSyncSource`. An
421
+ * unconditional branch would answer the message with a `ReferenceError` inside `waitUntil`, which
422
+ * the page that sent it cannot catch, in every app that leaves `backgroundSync` off.
423
+ */
424
+ function messageBlock(backgroundSync: boolean): string {
425
+ const flush = backgroundSync
426
+ ? "\n if(d.type==='flush-outbox')event.waitUntil(flushOutbox());"
427
+ : '';
392
428
  return `
393
429
  self.addEventListener('message',(event)=>{
394
430
  const d=event.data||{};
395
431
  if(d.type==='skip-waiting')self.skipWaiting();
396
- if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});
432
+ if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});${flush}
397
433
  });`.trim();
398
434
  }
package/src/strategies.ts CHANGED
@@ -32,10 +32,20 @@ export interface PwaRoute {
32
32
  readonly dynamic?: boolean;
33
33
  /** Explicit per-route override; wins over the derived strategy. */
34
34
  readonly strategy?: StrategyName;
35
- /** Content hash of the built HTML — the precache revision. */
35
+ /**
36
+ * Content hash of the built HTML — the precache revision. Fed by the CLI's prerender pass; the
37
+ * `buildId` is the fallback, and it re-downloads every precached page on every deploy.
38
+ */
36
39
  readonly revision?: string;
40
+ /** Byte size of the built HTML, from the same prerender pass. Counted against `warnBytes`. */
37
41
  readonly bytes?: number;
38
- /** Companion data endpoint precached alongside the HTML. */
42
+ /**
43
+ * Companion data endpoint precached alongside the HTML. **Nothing in the framework's build path
44
+ * sets it** — a hand-built `generateServiceWorker` call is the only route to a
45
+ * `reason: 'route-data'` entry, so the branch in `precache.ts` is unreachable in production.
46
+ * Kept because deleting a public field is a major; a candidate for the next one's
47
+ * declared-and-never-wired sweep, with `ServiceWorkerConfig`'s shell trio.
48
+ */
39
49
  readonly dataUrl?: string;
40
50
  }
41
51