@ultimat3/pwa 24.0.0 → 25.0.0

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
@@ -13,24 +13,25 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
13
13
  | Determinism | no `Date.now()`, no randomness, sort every collection. Same input → same bytes. `subscriptionState` took `now = Date.now()` and was the one exception; it takes a `Clock` `As of 2026-08-23`, so a frozen clock freezes this package too. |
14
14
  | Rule ORDER | most specific first, then the path. `ruleFor` in the emitted worker returns the FIRST pattern that matches and has no notion of specificity, so the sort IS the routing decision. Alphabetical, `:` (0x3A) and `*` (0x2A) sort before every letter: `/posts/:id` shadowed `/posts/new`, and one `/*` catch-all shadowed the whole table — every `PRECACHE_MANIFEST` entry downloaded at install and never looked up, and a cacheable route served `network-only`, which offline is the `/offline` document. The order is `@ultimat3/core`'s `routeRank` — the ONE route precedence, also `@ultimat3/render`'s `CompiledPattern.specificity`: the request router's order, segment by segment. Never a local copy, never a sum. |
15
15
  | A generated header | names something that regenerates the file. `sw.js` says `regenerate: x build`, and `As of 2026-08` a build really writes it (#390); it named the CALL for as long as none did. |
16
- | What is WIRED, and what is not | **every generator here has a caller, `As of 2026-08`.** `generateWebManifest`, `renderThemeColorMeta`, `planIcons`, `appleTouchLinks` and `BuiltinImagePipeline` through `pwa-artifacts.ts` / `dev-assets.ts` (#362); `generateServiceWorker` — and with it `buildPrecacheManifest`, `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` — through `sw-artifacts.ts` (#390). So `x dev`, the container and the static export emit `manifest.webmanifest`, `sw.js` and `x-sw-register.js` alike. The worker half landed a release later because a bad `sw.js` is sticky: it waited on a real browser check (`packages/cli/e2e/service-worker.e2e.test.ts`), which nothing in the gate could run until #400. **`pwa.push` is the one capability still unwired** — the generator emits a push handler only when a VAPID key comes with it and there is no `pwa.vapid` config key, so `x build` reports it as a `serviceWorkerWarnings` entry rather than dropping it in silence. |
16
+ | What is WIRED, and what is not | **every generator here has a caller, `As of 2026-08`.** `generateWebManifest`, `renderThemeColorMeta`, `planIcons`, `appleTouchLinks` and `BuiltinImagePipeline` through `pwa-artifacts.ts` / `runtime-assets.ts` (#362); `generateServiceWorker` — and with it `buildPrecacheManifest`, `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` — through `sw-artifacts.ts` (#390). So `x dev`, the container and the static export emit `manifest.webmanifest`, `sw.js` and `x-sw-register.js` alike. The worker half landed a release later because a bad `sw.js` is sticky: it waited on a real browser check (`packages/cli/e2e/service-worker.e2e.test.ts`), which nothing in the gate could run until #400. **`pwa.push` is the one capability still unwired** — the generator emits a push handler only when a VAPID key comes with it and there is no `pwa.vapid` config key, so `x build` reports it as a `serviceWorkerWarnings` entry rather than dropping it in silence. |
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. Every row is `network-first` `As of 22.3.2`: every rule is a DOCUMENT, and `static: 'cache-first'` / `isr`,`stream: 'stale-while-revalidate'` answered online navigations with the old deploy's HTML (and so its old hashed assets) until Shift+F5. Precache ≠ cache-first — the precached copy is the offline answer. |
19
19
  | Worker update | `self.skipWaiting()` in `install`, `As of 22.3.2` — worker-side, because the browsers that must converge run a 22.3.1 worker and register script that never post `skip-waiting`. Safe for an open tab: the worker routes no chunk. Never a reload — the next navigation is fresh. The `skip-waiting` message stays. |
20
20
  | Navigation preload | enabled on `activate`; every emitted strategy reaches the network through `net(req,pre)` (`NETWORK_SOURCE`), never a bare `fetch`, so a navigation is answered by `event.preloadResponse`. The preload carries no build id: `seenBuild` reads skew off the ANSWER. `fromNetwork` is the exported mirror. `service-worker-preload.test.ts` executes it. |
21
- | Install fill | `PRECACHE_CONCURRENCY` (4) in flight, never `Promise.all` over the manifest: it runs on the first visit against the connection the first click needs. Still one entry per failure. An answer stamped with another build's `BUILD_ID_HEADER` is not stored (an old pod mid-rollout); an unstamped one is. |
21
+ | Install fill | `PRECACHE_CONCURRENCY` (4) in flight, never `Promise.all` over the manifest: it runs on the first visit against the connection the first click needs. Still one entry per failure. An answer stamped with another build's core `BUILD_ID_HEADER` is not stored (an old pod mid-rollout); an unstamped one is. |
22
22
  | Rule patterns | built from the DECODED route path, matched against the PERCENT-ENCODED `url.pathname`: a literal segment goes through the WHATWG serializer (`URL.pathname`, never `encodeURI` — it escapes `\|`, `[`, `^`, `%` a browser leaves literal), either hex case. A catch-all is `(?:/.*)?`, so `/docs/*path` owns `/docs`. `route-rules.test.ts`; the e2e drives both in Chrome. |
23
23
  | Runtime assets | `ServiceWorkerConfig.runtimeAssets` prefixes (`/islands/`) are `cache-first` runtime rules AHEAD of every page rule (`assetRules`, `route-rules.ts`), and go to the network as the browser asked — no build-id header, because a classic script is a `no-cors` request whose headers a worker may not extend. The precache names only what a precached page boots (the CLI chooses: `sw-precache-plan.ts`); all 43 islands (~1 MB) were precached on a first anonymous visit until 22.3.3. |
24
24
  | Locales | the worker matches pathnames and the server strips `/en/` before it matches, so the CALLER spells every route once per routed locale (`@ultimat3/cli`'s `sw-precache-plan.ts`). `localePrefixes` is only for the offline document: a navigation under `/en/` tries `/en` + `OFFLINE_DOC` first. |
25
+ | What is a navigation | to `offlineFallback`: `mode === 'navigate'`, or the client router's soft visit — a `fetch` carrying `CLIENT_NAVIGATION_HEADER: soft` (core's constant, interpolated). Its `prefetch` is a guess: the 503, or the offline page would be cached as a later click's answer. `offline-fallback.test.ts`. |
25
26
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
26
27
  | 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. |
27
28
  | 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. |
28
- | 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. |
29
+ | 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), `installController`'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. |
29
30
  | 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. Each entry is fetched and `put` on its own, `As of 22.3.4`: `addAll`'s all-or-nothing let one 404 (a stylesheet another pod minted) fail the install of every deploy, so every visitor stayed on the first worker that ever installed (notificado.co). A failed entry costs its offline copy only. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
30
31
  | 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. |
31
32
  | SW scope | `assertScope` refuses a relative `swPath`. `lastIndexOf('/')` on `sw.js` is `-1`, so the directory was `''` and `scope.startsWith('')` passed for every scope — the check waved through exactly the config most likely to be wrong. |
32
33
  | HTML sinks | one escaper: `escapeHtml` from `@ultimat3/core`, the framework's one character table (`bun run flight-copies`, `X_HELPER_COPY`). `appleTouchLinks` and `renderThemeColorMeta` interpolate app config into attributes; both escape. Never a second escaper here. |
33
- | 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. |
34
+ | 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. Imported from core by callers too: 25.0.0 dropped this barrel's re-export. |
34
35
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
35
36
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
36
37
  | 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. |
@@ -49,7 +50,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
49
50
  | 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. |
50
51
  | `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. |
51
52
  | Icons | one source image → `BuiltinImagePipeline` → a square PNG per `ICON_MATRIX` entry, rendered by `@ultimat3/core`'s image pipeline. Never a second scaler, never `sharp`, never a vendor CDN. |
52
- | Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `dev-assets.ts` reads it at `ICON_SOURCE` and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. |
53
+ | Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `runtime-assets.ts` reads it at `ICON_SOURCE` (through `icon-assets.ts`) and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. |
53
54
  | Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. |
54
55
  | Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. |
55
56
  | Errors in `sw.js` | no bundler there, so no `UltimateError` import. Never emit a bare `throw new Error`: a coded failure in the worker gets a code in `errors.ts` and a local class carrying `code`, `cause`, `fix`, `docs`. `As of 21.0.0` the emitted worker throws nothing of its own. |
package/README.md CHANGED
@@ -5,7 +5,7 @@ whole thesis: a hand-written service worker encodes routing decisions a second t
5
5
  the second copy is the one nobody updates.
6
6
 
7
7
  ```ts
8
- const { source, precache, warnings } = generateServiceWorker(describeRoutes(), config, buildId);
8
+ const { source, precache, warnings } = generateServiceWorker(describePages(), config, buildId);
9
9
  ```
10
10
 
11
11
  ## Render mode → runtime strategy
@@ -41,7 +41,7 @@ and the app dies with a blank screen and no error anyone can act on.
41
41
  | Mechanism | Rule |
42
42
  |---|---|
43
43
  | Build id | immutable per deploy, derived from the commit sha; `X_BUILD_ID_MISSING` if absent |
44
- | Client → server | every SW-proxied request carries `x-ultimate-build` |
44
+ | Client → server | every SW-proxied request carries `x-ultimate-build` — `@ultimat3/core`'s `BUILD_ID_HEADER` |
45
45
  | Retention | `retentionPlan(deploys, keep)` keeps the last N deploys' assets alive (default 3) |
46
46
  | Stale client | gets `AppUpdateAvailable`, never a 404 |
47
47
  | Forced reload | none. This package never navigates a client — the app decides what to do with the message |
@@ -61,11 +61,12 @@ detectSkew(clientBuildId, message.to); // 'current' | 'stale' | 'unknown'
61
61
  ```
62
62
  X_PWA_NO_OFFLINE_FALLBACK: no offline fallback route
63
63
  cause: app.config.ts has no `offline` block, so an offline navigation would show the browser's error page
64
- fix: x g route offline --surface site # then set pwa.offline.fallback to '/offline' in app.config.ts
64
+ fix: set pwa: { offline: { fallback: '/offline' } } in app.config.ts, then create the route it names: x g route offline --surface site
65
65
  ```
66
66
 
67
67
  `requireOfflineFallback(config)` runs inside `generateServiceWorker`, so the build fails
68
- before an un-shippable PWA exists.
68
+ before an un-shippable PWA exists. `pwa.offline.image` and `pwa.offline.font` are the
69
+ placeholders a failed image or font request gets — each served only from the precache.
69
70
 
70
71
  ## Capabilities are opt-in, and gate bytes
71
72
 
@@ -106,7 +107,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
106
107
  | Export | Owns |
107
108
  |---|---|
108
109
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
109
- | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only` |
110
+ | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only`. Each answers what its emitted `sw.js` twin answers: the cache copy goes to `env.wait` and is never awaited (a failed copy costs the copy, not the response), and an exhausted strategy with no fallback rejects — `staleWhileRevalidate` with `X_PWA_STRATEGY_EXHAUSTED` |
110
111
  | `routeRules`, `assetRules` | the worker's rule list: routes most specific first, runtime asset prefixes (`/islands/`) ahead of them. `routeRules(routes, personalPages)` — `'last-member'` gives a personal page a `pages` rule; a pattern matches the browser's percent-encoded pathname, and a catch-all its bare prefix |
111
112
  | `CLEAR_PAGES_MESSAGE`, `PAGES_CLEARED_MESSAGE` | `{ type: 'clear-pages' }` — post it to the worker on sign-out; it empties every pages cache and answers `{ type: 'pages-cleared' }` |
112
113
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
@@ -117,7 +118,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
117
118
  | `requireOfflineFallback` | the mandatory offline route |
118
119
  | `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
119
120
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
120
- | `createInstallController`, `iosInstallGuidance` | install prompt, never on first paint |
121
+ | `installController`, `iosInstallGuidance` | install prompt, never on first paint |
121
122
  | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
122
123
 
123
124
  ## Notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "24.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,13 +25,13 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "24.0.0"
35
+ "@ultimat3/core": "25.0.0"
36
36
  }
37
37
  }
@@ -34,7 +34,7 @@ export function resolveCapabilities(flags: CapabilityFlags = {}): ResolvedCapabi
34
34
  return resolved;
35
35
  }
36
36
 
37
- export function isEnabled(capabilities: ResolvedCapabilities, capability: Capability): boolean {
37
+ export function hasCapability(capabilities: ResolvedCapabilities, capability: Capability): boolean {
38
38
  return capabilities[capability];
39
39
  }
40
40
 
package/src/index.ts CHANGED
@@ -6,9 +6,6 @@
6
6
  * re-exported here; a consumer still needs one import, and now it names the real type.
7
7
  */
8
8
  export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
9
- // Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
10
- // worker's size report is what a caller of this package prints.
11
- export { formatBytes } from '@ultimat3/core';
12
9
  export {
13
10
  backgroundSyncSource,
14
11
  registerBackgroundSyncSource,
@@ -19,7 +16,7 @@ export {
19
16
  CAPABILITIES,
20
17
  CAPABILITY_SW_MARKERS,
21
18
  enabledCapabilities,
22
- isEnabled,
19
+ hasCapability,
23
20
  resolveCapabilities,
24
21
  } from './capabilities';
25
22
  export type { PwaErrorCode } from './errors';
@@ -65,7 +62,7 @@ export type {
65
62
  IosGuidance,
66
63
  ReadSignal,
67
64
  } from './install';
68
- export { createInstallController, iosInstallGuidance } from './install';
65
+ export { installController, iosInstallGuidance } from './install';
69
66
  export type {
70
67
  DisplayMode,
71
68
  FileHandler,
@@ -141,10 +138,7 @@ export type {
141
138
  SkewState,
142
139
  } from './version-skew';
143
140
  export {
144
- APP_UPDATE_AVAILABLE,
145
141
  assertBuildId,
146
- BUILD_ID_HEADER,
147
- BUILD_ID_META,
148
142
  buildId,
149
143
  cacheNamespace,
150
144
  detectSkew,
package/src/install.ts CHANGED
@@ -77,13 +77,13 @@ export interface InstallController {
77
77
  dispose(): void;
78
78
  }
79
79
 
80
- export function createInstallController(options: InstallOptions): InstallController {
80
+ export function installController(options: InstallOptions): InstallController {
81
81
  const host = options.host;
82
82
  // `now() - startedAt < NaN` is false at every instant, so a threshold that arrived non-finite
83
83
  // does not shorten the wait — it deletes it, and the prompt fires on first paint. `0` stays
84
84
  // legal: "ask as soon as the browser offers" is a decision, and it is a comparison that works.
85
85
  const minEngagementMs = finiteCount(
86
- 'createInstallController',
86
+ 'installController',
87
87
  'minEngagementMs',
88
88
  options.minEngagementMs ?? MIN_ENGAGEMENT_MS,
89
89
  );
package/src/manifest.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { PwaColors } from '@ultimat3/core';
13
13
  import { escapeHtml } from '@ultimat3/core';
14
14
  import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
15
- import { isEnabled, resolveCapabilities } from './capabilities';
15
+ import { hasCapability, resolveCapabilities } from './capabilities';
16
16
  import { PwaManifestInvalidError } from './errors';
17
17
 
18
18
  export type DisplayMode = 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
@@ -143,13 +143,13 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
143
143
  if (config.screenshots !== undefined) optional.screenshots = config.screenshots;
144
144
 
145
145
  // A disabled capability contributes no manifest member at all — not an empty one.
146
- if (isEnabled(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
146
+ if (hasCapability(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
147
147
  optional.share_target = config.shareTarget;
148
148
  }
149
- if (isEnabled(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
149
+ if (hasCapability(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
150
150
  optional.file_handlers = config.fileHandlers;
151
151
  }
152
- if (isEnabled(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
152
+ if (hasCapability(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
153
153
  optional.protocol_handlers = config.protocolHandlers;
154
154
  }
155
155
 
@@ -4,6 +4,7 @@
4
4
  * requires it and `requireOfflineFallback` fails the build if it is missing.
5
5
  */
6
6
 
7
+ import { CLIENT_NAVIGATION_HEADER, isSameOriginPath } from '@ultimat3/core';
7
8
  import { PwaNoOfflineFallbackError } from './errors';
8
9
  import type { PersonalPages } from './pages-cache-source';
9
10
 
@@ -78,39 +79,68 @@ export function requireOfflineFallback(
78
79
  `set pwa.offline.fallback to '/${fallback.replace(/^\/+/, '')}' in app.config.ts`,
79
80
  );
80
81
  }
82
+ // Core's predicate, judged by resolution: every URL here is precached at install and served as
83
+ // the answer to a failed request, so an off-origin one is a third party's bytes in the cache.
84
+ if (!isSameOriginPath(fallback)) throw offOrigin('fallback', fallback, '/offline');
81
85
 
82
86
  return {
83
87
  document: fallback,
84
- image: config.image ?? null,
85
- font: config.font ?? null,
88
+ image: placeholder('image', config.image, '/icons/offline.png'),
89
+ font: placeholder('font', config.font, '/fonts/fallback.woff2'),
86
90
  neverCache: config.neverCache ?? [],
87
91
  personalPages: config.personalPages === 'last-member' ? 'last-member' : 'never',
88
92
  };
89
93
  }
90
94
 
95
+ function offOrigin(key: string, url: string, example: string): PwaNoOfflineFallbackError {
96
+ return new PwaNoOfflineFallbackError(
97
+ `pwa.offline.${key} is ${JSON.stringify(url)}, which is not a path on this origin — it is precached and served as the offline answer`,
98
+ `defineConfig({ pwa: { offline: { ${key}: '${example}' } } }) # pwa.offline.${key} in app.config.ts — a path this app serves`,
99
+ );
100
+ }
101
+
102
+ /** `pwa.offline.image` / `.font`: absent is `null`; present must be a same-origin path. */
103
+ function placeholder(
104
+ key: 'image' | 'font',
105
+ url: string | undefined,
106
+ example: string,
107
+ ): string | null {
108
+ if (url === undefined || url === null) return null;
109
+ if (!isSameOriginPath(url)) throw offOrigin(key, url, example);
110
+ return url;
111
+ }
112
+
91
113
  /**
92
- * Emitted into `sw.js`: what to serve when a navigation cannot be answered. `localePrefixes` are
93
- * the non-default routed locales' URL segments: a navigation under `/en/` tries `/en` + the offline
94
- * document first, so an English visitor offline is not handed the default locale's page.
114
+ * Emitted into `sw.js`: what to serve when a request cannot be answered — the offline document for
115
+ * a navigation, the `image`/`font` placeholders for those destinations (each only if the app
116
+ * precached it), else a 503. `localePrefixes` are the non-default routed locales' URL segments: a
117
+ * navigation under `/en/` tries `/en` + the offline document first, so an English visitor offline
118
+ * is not handed the default locale's page. A navigation is the browser's own (`mode`) or the client
119
+ * router's soft visit — a `fetch` carrying `CLIENT_NAVIGATION_HEADER: soft`, which otherwise got the
120
+ * bare 503 (#627). Its `prefetch` is a guess, not a visit: never answered with the offline page.
95
121
  */
96
122
  export function offlineFallbackSource(
97
123
  fallback: OfflineFallback,
98
124
  localePrefixes: readonly string[] = [],
99
125
  ): string {
100
126
  const image = fallback.image === null ? 'null' : JSON.stringify(fallback.image);
127
+ const font = fallback.font === null ? 'null' : JSON.stringify(fallback.font);
101
128
  const locales = [...new Set(localePrefixes)].sort();
102
129
  return `
103
130
  const OFFLINE_DOC=${JSON.stringify(fallback.document)};
104
131
  const OFFLINE_IMAGE=${image};
132
+ const OFFLINE_FONT=${font};
105
133
  const OFFLINE_LOCALES=${JSON.stringify(locales)};
134
+ const OFFLINE_NAV_HEADER=${JSON.stringify(CLIENT_NAVIGATION_HEADER)};
106
135
  async function offlineFallback(req){
107
136
  const c=await caches.open(PRECACHE);
108
- if(req.mode==='navigate'){
137
+ if(req.mode==='navigate'||req.headers.get(OFFLINE_NAV_HEADER)==='soft'){
109
138
  const seg=new URL(req.url).pathname.split('/')[1];
110
139
  if(OFFLINE_LOCALES.indexOf(seg)!==-1){const l=await c.match('/'+seg+OFFLINE_DOC);if(l)return l}
111
140
  const d=await c.match(OFFLINE_DOC);if(d)return d
112
141
  }
113
142
  if(OFFLINE_IMAGE&&req.destination==='image'){const i=await c.match(OFFLINE_IMAGE);if(i)return i}
143
+ if(OFFLINE_FONT&&req.destination==='font'){const f=await c.match(OFFLINE_FONT);if(f)return f}
114
144
  return new Response('',{status:503,statusText:'Offline'})
115
145
  }`.trim();
116
146
  }
@@ -8,10 +8,10 @@
8
8
  * update check does not fire on a no-op deploy.
9
9
  */
10
10
 
11
- import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
11
+ import { APP_UPDATE_MESSAGE, BUILD_ID_HEADER, CLIENT_SCOPE_HEADER } from '@ultimat3/core';
12
12
  import { backgroundSyncSource } from './background-sync';
13
13
  import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
14
- import { isEnabled, resolveCapabilities } from './capabilities';
14
+ import { hasCapability, resolveCapabilities } from './capabilities';
15
15
  import { SwScopeInvalidError } from './errors';
16
16
  import type { OfflineConfig } from './offline-fallback';
17
17
  import { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
@@ -30,12 +30,7 @@ import type { RouteRule } from './route-rules';
30
30
  import { assetRules, routeRules } from './route-rules';
31
31
  import type { PwaRoute } from './strategies';
32
32
  import { NETWORK_SOURCE, STRATEGY_FN_NAMES, STRATEGY_SOURCE } from './strategies';
33
- import {
34
- APP_UPDATE_AVAILABLE,
35
- assertBuildId,
36
- BUILD_ID_HEADER,
37
- cacheNamespace,
38
- } from './version-skew';
33
+ import { assertBuildId, cacheNamespace } from './version-skew';
39
34
 
40
35
  export interface ServiceWorkerConfig {
41
36
  readonly scope?: string;
@@ -162,13 +157,13 @@ export function generateServiceWorker(
162
157
  INSTALL_BLOCK,
163
158
  activateBlock(),
164
159
  fetchBlock(fallback.personalPages),
165
- messageBlock(isEnabled(capabilities, 'backgroundSync')),
160
+ messageBlock(hasCapability(capabilities, 'backgroundSync')),
166
161
  ];
167
162
 
168
- if (isEnabled(capabilities, 'push') && config.vapid !== undefined) {
169
- blocks.push(pushSource({ badging: isEnabled(capabilities, 'badging') }));
163
+ if (hasCapability(capabilities, 'push') && config.vapid !== undefined) {
164
+ blocks.push(pushSource({ badging: hasCapability(capabilities, 'badging') }));
170
165
  }
171
- if (isEnabled(capabilities, 'backgroundSync')) {
166
+ if (hasCapability(capabilities, 'backgroundSync')) {
172
167
  blocks.push(backgroundSyncSource());
173
168
  }
174
169
 
@@ -300,7 +295,7 @@ self.addEventListener('activate',(event)=>{
300
295
  // the fetch handler answers from it (net) — start-up is off every navigation's critical path.
301
296
  try{if(self.registration&&self.registration.navigationPreload)await self.registration.navigationPreload.enable()}catch(e){}
302
297
  const cs=await self.clients.matchAll({type:'window'});
303
- for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:BUILD_ID});
298
+ for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:BUILD_ID});
304
299
  // NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
305
300
  // warm-up that copies a streamed page's whole body in here held every request of the tab it
306
301
  // just claimed — /feed's own reads included — until that body had ended. Kicked off, never
@@ -391,7 +386,7 @@ function seenBuild(res){
391
386
  const server=res.headers.get(BUILD_HEADER);
392
387
  if(SKEWED||server===null||server===BUILD_ID)return;
393
388
  SKEWED=true;
394
- self.clients.matchAll({type:'window'}).then((cs)=>{for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:server})}).catch(()=>{});
389
+ self.clients.matchAll({type:'window'}).then((cs)=>{for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:server})}).catch(()=>{});
395
390
  }
396
391
  function withBuild(headers){
397
392
  const h=new Headers(headers);
@@ -410,7 +405,7 @@ async function healSkew(req,res){
410
405
  // Answering the request for real is what breaks that loop.
411
406
  SKEWED=true;
412
407
  const cs=await self.clients.matchAll({type:'window'});
413
- for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:server});
408
+ for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:server});
414
409
  return fetch(new Request(req,{headers:withBuild(req.headers)}));
415
410
  }`.trim();
416
411
  }
package/src/strategies.ts CHANGED
@@ -157,7 +157,7 @@ export async function networkFirst(
157
157
  const cache = await env.open(options.cacheName);
158
158
  try {
159
159
  const response = await fromNetwork(request, env, options);
160
- if (response.ok) await cache.put(request, response.clone());
160
+ if (response.ok) later(env, cache.put(request, response.clone()));
161
161
  return response;
162
162
  } catch (error) {
163
163
  const hit = await cache.match(request);
@@ -176,8 +176,8 @@ export async function staleWhileRevalidate(
176
176
  const cache = await env.open(options.cacheName);
177
177
  const hit = await cache.match(request);
178
178
  const refresh = fromNetwork(request, env, options)
179
- .then(async (response) => {
180
- if (response.ok) await cache.put(request, response.clone());
179
+ .then((response) => {
180
+ if (response.ok) later(env, cache.put(request, response.clone()));
181
181
  return response;
182
182
  })
183
183
  .catch(async () => (hit !== undefined ? hit : fallbackOrThrow(options)));
@@ -185,8 +185,8 @@ export async function staleWhileRevalidate(
185
185
  if (hit !== undefined) {
186
186
  // The refresh is handed to the event's `waitUntil` NOW, before the cached answer returns:
187
187
  // taken after `respondWith` settled, a browser refuses the extension (`InvalidStateError`) and
188
- // may kill the worker mid-refresh. `later` also swallows its rejection, as `.catch` did.
189
- env.wait?.(refresh.catch(() => undefined));
188
+ // may kill the worker mid-refresh. `later` also swallows its rejection.
189
+ later(env, refresh);
190
190
  return hit;
191
191
  }
192
192
  return refresh;
@@ -225,7 +225,7 @@ async function fetchAndStore(
225
225
  ): Promise<Response> {
226
226
  try {
227
227
  const response = await fromNetwork(request, env, options);
228
- if (response.ok) await cache.put(request, response.clone());
228
+ if (response.ok) later(env, cache.put(request, response.clone()));
229
229
  return response;
230
230
  } catch (error) {
231
231
  if (options.fallback !== undefined) return options.fallback();
@@ -233,16 +233,33 @@ async function fetchAndStore(
233
233
  }
234
234
  }
235
235
 
236
+ /**
237
+ * Work behind the answer: the emitted worker's `later`. A cache copy is never awaited — `Cache.put`
238
+ * reads the whole body, so awaiting it held a streamed document away from the tab, and a put that
239
+ * REJECTED (quota) turned a fresh network answer into the stale copy or a throw. The copy goes to
240
+ * the event's `waitUntil`, which keeps the worker alive until it lands; a failed copy costs the
241
+ * copy, never the response.
242
+ */
243
+ function later(env: StrategyEnv, work: Promise<unknown>): void {
244
+ const settled = work.catch(() => undefined);
245
+ env.wait?.(settled);
246
+ }
247
+
248
+ /**
249
+ * Exhausted with nothing to answer: REJECT, as the other three strategies do on both halves. In a
250
+ * worker a rejected `respondWith` is the same network error `Response.error()` would be, and
251
+ * in-process the caller gets a code and a fix instead of a status-0 response it has to recognise.
252
+ */
236
253
  async function fallbackOrThrow(options: StrategyOptions): Promise<Response> {
237
254
  if (options.fallback !== undefined) return options.fallback();
238
255
  throw new PwaStrategyExhaustedError({ cacheName: options.cacheName });
239
256
  }
240
257
 
241
258
  /**
242
- * The emitted counterpart of the functions above. Kept as source strings because the
243
- * service worker is a generated artifact with no bundler in the loop — the shapes are
244
- * identical on purpose and `strategies.test.ts` asserts both halves stay in step. They open a
245
- * cache through the worker's `openCache`, never `caches.open`: the pages cache is a facade that
259
+ * The emitted counterpart of the functions above. Kept as source strings because the service
260
+ * worker is a generated artifact with no bundler in the loop — the shapes are identical on purpose
261
+ * and `strategies.test.ts` RUNS each source against the fake its twin gets. They open a cache
262
+ * through the worker's `openCache`, never `caches.open`: the pages cache is a facade that
246
263
  * partitions a per-member document by principal (`service-worker.ts`, `pagesCache`). And the cache
247
264
  * copy is NEVER awaited before answering: `Cache.put` reads the whole body, so awaiting it held a
248
265
  * streamed document away from the tab until it had ended. It goes to `later(wait, …)` instead —
@@ -262,7 +279,7 @@ export const STRATEGY_SOURCE = Object.freeze<Record<StrategyName, string>>({
262
279
  'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb,wait,pre){
263
280
  const c=await openCache(cn);const hit=await c.match(req);
264
281
  const refresh=net(req,pre).then((r)=>{if(r.ok)later(wait,c.put(req,r.clone()));return r})
265
- .catch(()=>hit||(fb?fb():Response.error()));
282
+ .catch((e)=>{if(hit)return hit;if(fb)return fb();throw e});
266
283
  if(hit){later(wait,refresh);return hit}
267
284
  return refresh
268
285
  }`,
@@ -10,12 +10,13 @@
10
10
  * The app decides what to do with that message. This package never navigates a client.
11
11
  */
12
12
 
13
- import { APP_UPDATE_MESSAGE, CLIENT_BUILD_META, finiteCount } from '@ultimat3/core';
13
+ import type { APP_UPDATE_MESSAGE } from '@ultimat3/core';
14
+ import { finiteCount } from '@ultimat3/core';
14
15
  import { BuildIdMissingError } from './errors';
15
16
 
16
- export const BUILD_ID_HEADER = 'x-ultimate-build';
17
- /** Shipped name for core's `CLIENT_BUILD_META` — the meta render writes, one literal for both. */
18
- export const BUILD_ID_META = CLIENT_BUILD_META;
17
+ // The header every proxied request carries is core's `BUILD_ID_HEADER`, the document meta core's
18
+ // `CLIENT_BUILD_META` and the worker's message type core's `APP_UPDATE_MESSAGE` — imported where
19
+ // they are used, never re-published under this package's names (25.0.0, `X_HELPER_COPY`).
19
20
 
20
21
  export type DeployChannel = 'production' | 'preview' | 'branch';
21
22
 
@@ -125,8 +126,8 @@ export function detectSkew(
125
126
 
126
127
  /**
127
128
  * The client-side contract, and the whole of it: what the generated worker posts to every page it
128
- * controls on activation. The page compares `to` against its own `BUILD_ID_META` — `detectSkew`
129
- * is that comparison — and renders its own "refresh to update" affordance.
129
+ * controls on activation. The page compares `to` against its own `CLIENT_BUILD_META` meta (core's)
130
+ * — `detectSkew` is that comparison — and renders its own "refresh to update" affordance.
130
131
  *
131
132
  * It declared `from`, `forced` and `deadlineAt` too, for a forced reload after a grace period that
132
133
  * NOTHING performed: `updateSignal`/`updatePolicy` computed the three and had no runtime caller,
@@ -141,6 +142,3 @@ export interface AppUpdateAvailable {
141
142
  /** The build the worker that posted this was generated for. */
142
143
  readonly to: string;
143
144
  }
144
-
145
- /** Shipped name for core's `APP_UPDATE_MESSAGE` — one literal for the worker and the page. */
146
- export const APP_UPDATE_AVAILABLE = APP_UPDATE_MESSAGE;