@ultimat3/pwa 23.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
@@ -11,25 +11,27 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
11
11
  |---|---|
12
12
  | `sw.js` | generated, never hand-written, never committed. No file in this package is a SW. |
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
- | 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. Weights are `@ultimat3/render`'s `compilePattern` (literal 100, `:param` 10, `*` 1), **duplicated** because both packages are tier 4; a wildcard sorts last outright, because a sum over segments cannot say that `/` (0 segments) outranks `/*rest` (1). The shared home is `@ultimat3/core`'s `route-vocabulary.ts`. |
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. |
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
+ | 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. |
22
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. |
23
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`. |
24
26
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
25
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. |
26
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. |
27
- | 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. |
28
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. |
29
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. |
30
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. |
31
- | HTML sinks | one escaper: `escapeAttribute` from `@ultimat3/seo` (tier 1, and the only one reachable — render's `html.ts` is tier 4, sideways). `appleTouchLinks` and `renderThemeColorMeta` interpolate app config into attributes; both escape. Never a second escaper here. |
32
- | 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. |
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. |
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. |
33
35
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
34
36
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
35
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. |
@@ -38,17 +40,17 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
38
40
  | `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. |
39
41
  | 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. |
40
42
  | A cache copy is never awaited before answering | `Cache.put` reads the whole body, so a strategy that awaited it held a streamed (or slow) document away from the tab until it had ENDED. The emitted strategies hand the copy to `later(wait, …)` — the fetch event's `waitUntil` — and return the response at once. `service-worker-runtime.test.ts` streams a gated body and asserts the page reads the first chunk before it ends; its `StubCache.put` reads the whole body, as the real one does, or the test could not fail. |
41
- | A per-member document | NEVER stored, in any cache, under any rule (22.3.3, security). `openCache` guards every cache a strategy opens: a response that is `private`/`no-store` or carries `x-ultimate-scope` (`CLIENT_SCOPE_HEADER`) is answered and dropped. 21.0.0 kept one in a per-principal partition (`PAGES~<scope>`) for offline, and after sign-out an offline navigation on a shared device showed the previous member's cases (notificado.co). A PERSONAL route (`PwaRoute.personal`: a policy, a `stream`, a `no-store`/`private` cache) is also `network-only` in `strategyFor` and skipped by `buildPrecacheManifest`. `{type:'clear-pages'}` (`CLEAR_PAGES_MESSAGE`) deletes every `x-pages-*` cache — old partitions included — and answers `pages-cleared`. `offline.personalPages: 'last-member'` (an app's explicit opt-in) restores 21.0.0's partition — `pagesCacheSource(mode)` emits one of the two. `service-worker-pages.test.ts` runs both modes. |
43
+ | A per-member document | NEVER stored, in any cache, under any rule (22.3.3, security). `openCache` guards every cache a strategy opens: a response that is `private`/`no-store` or carries `x-ultimate-scope` (`CLIENT_SCOPE_HEADER`) is answered and dropped. 21.0.0 kept one in a per-principal partition (`PAGES~<scope>`) for offline, and after sign-out an offline navigation on a shared device showed the previous member's cases (notificado.co). A PERSONAL route (`PwaRoute.personal`: a policy, a `stream`, a `no-store`/`private` cache) is also `network-only` in `strategyFor` and skipped by `buildPrecacheManifest`. `{type:'clear-pages'}` (`CLEAR_PAGES_MESSAGE`) deletes every `x-pages-*` cache — old partitions included — and answers `pages-cleared`. `offline.personalPages: 'last-member'` (an app's explicit opt-in) restores 21.0.0's partition — `pagesCacheSource(mode)` emits one of the two, and `routeRules(routes, mode)` gives a personal page a `pages` rule whatever its `offline` (a `precache` rule was never filled, so it was never kept). `service-worker-pages.test.ts` runs both modes. |
42
44
  | The installing page | cached on `activate`. It loaded before the worker controlled it, so no strategy saw it and an `offline: 'runtime'` route was unavailable offline until a second online visit. After `clients.claim()` each controlled window's URL runs through its OWN route's strategy (`warm`) — only rules with `c: 'pages'`, same origin, not `NEVER_CACHE` — never a second caching rule. Best effort; a failed warm-up never fails activation. It runs AFTER activation, never inside its `waitUntil`: a fetch event waits for the worker to finish activating, so a warm-up copying a streamed page's whole body there held the claimed tab's every request — the `/feed` e2e hang. The offline page read (`pageOffline`) awaits it instead. `service-worker-runtime.test.ts` runs it. |
43
45
  | Outbox drain | the worker SENDS NOTHING, `As of 21.0.0`. The outbox is `@ultimat3/realtime`'s and lives in the page (IndexedDB), so the `sync` event — and the `{type:'flush-outbox'}` fallback message, posted by `registerBackgroundSyncSource` wherever `registration.sync` is absent (Safari, Firefox) — both run `drainOutbox`, which posts `OUTBOX_DRAIN_MESSAGE` (`@ultimat3/core`) to EVERY open window client; realtime's listener drains. No client open = the handler resolves and the next load drains; it never fakes work. `/_x/outbox/flush`, `DEFAULT_FLUSH_ENDPOINT`, and `BackgroundSyncOptions` were deleted, and `X_PWA_SYNC_FLUSH_FAILED` / `X_PWA_SYNC_INCOMPLETE` are still registered but thrown by nothing (shipped codes are stable forever): the worker POSTed to a route nothing in the framework ever mounted. The fallback branch lives in the shared message handler, so `CAPABILITY_SW_MARKERS.backgroundSync` carrying `d.type==='flush-outbox'` is what keeps it gated with the `drainOutbox` it calls. `background-sync.test.ts` and `service-worker-runtime.test.ts` run the emitted code and assert the posted messages. |
44
46
  | Outbox | queue, replay, retry and idempotency are all `@ultimat3/realtime`'s. This package only registers the sync trigger and schedules **no retry** — `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23. |
45
47
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
46
48
  | `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. |
47
- | 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. |
49
+ | 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. A URL that does not resolve to `self.location.origin` (another host, a scheme, malformed, a non-string) opens the root instead (`tapTarget`). The `push` body is parsed INSIDE `waitUntil` (`pushData`): plain text is the body, JSON that is no object is `{}`. `push-handler.test.ts`. |
48
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. |
49
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. |
50
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. |
51
- | 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. |
52
54
  | Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. |
53
55
  | Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. |
54
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,17 +61,18 @@ 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
 
72
73
  | Capability | Manifest member | SW code |
73
74
  |---|---|---|
74
- | `push` | — | `push` + `notificationclick` listeners |
75
+ | `push` | — | `push` + `notificationclick` listeners; a tap opens a same-origin URL only |
75
76
  | `backgroundSync` | — | `sync` listener that tells every open tab to drain realtime's outbox (`OUTBOX_DRAIN_MESSAGE`) |
76
77
  | `badging` | — | `navigator.setAppBadge` after a push |
77
78
  | `shareTarget` | `share_target` | — |
@@ -95,7 +96,6 @@ a job boundary the class is gone and the `code` is what survives — match on th
95
96
  | Class | Code | Declared in |
96
97
  |---|---|---|
97
98
  | `BuildIdMissingError` | `X_BUILD_ID_MISSING` | `src/errors.ts` |
98
- | `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
99
99
  | `PwaIconMissingError` | `X_PWA_ICON_MISSING` | `src/errors.ts` |
100
100
  | `PwaManifestInvalidError` | `X_PWA_MANIFEST_INVALID` | `src/errors.ts` |
101
101
  | `PwaNoOfflineFallbackError` | `X_PWA_NO_OFFLINE_FALLBACK` | `src/errors.ts` |
@@ -107,8 +107,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
107
107
  | Export | Owns |
108
108
  |---|---|
109
109
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
110
- | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only` |
111
- | `routeRules`, `assetRules` | the worker's rule list: routes most specific first, runtime asset prefixes (`/islands/`) ahead of them |
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` |
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 |
112
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' }` |
113
113
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
114
114
  | `buildId`, `detectSkew`, `retentionPlan` | version skew |
@@ -118,7 +118,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
118
118
  | `requireOfflineFallback` | the mandatory offline route |
119
119
  | `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
120
120
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
121
- | `createInstallController`, `iosInstallGuidance` | install prompt, never on first paint |
121
+ | `installController`, `iosInstallGuidance` | install prompt, never on first paint |
122
122
  | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
123
123
 
124
124
  ## Notes
@@ -153,9 +153,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
153
153
  lands exactly inside `maskableSafeZone(size)`; the ring around it is `background`, which is
154
154
  hex or `transparent` (there are no named colours). Same bytes in, same bytes out.
155
155
  - **Every HTML sink goes through one escaper.** `appleTouchLinks` and `renderThemeColorMeta`
156
- interpolate app configuration into attributes, so both run it through `escapeAttribute` from
157
- `@ultimat3/seo` (tier 1, and the one this package can reach — `@ultimat3/render`'s `html.ts` is
158
- tier 4, sideways). Never a second escaper here.
156
+ interpolate app configuration into attributes, so both run it through `escapeHtml` from
157
+ `@ultimat3/core` — the framework's one HTML character table (`@ultimat3/render`'s `html.ts` is
158
+ tier 4, sideways, and re-exports the same function). Never a second escaper here.
159
159
  - **A precache URL may already carry a query.** `PrecacheAsset.url` is public API and bundlers emit
160
160
  `?v=<hash>` of their own, so the install block picks `?` or `&` per entry. A fixed `?` produced
161
161
  `...?locale=en?v=<rev>`, and because `cache.addAll` is all-or-nothing a single non-200 there means
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "23.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,14 +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": "23.0.0",
36
- "@ultimat3/seo": "23.0.0"
35
+ "@ultimat3/core": "25.0.0"
37
36
  }
38
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/errors.ts CHANGED
@@ -20,8 +20,9 @@ export const PWA_OWNED_ERROR_CODES = [
20
20
  ] as const;
21
21
 
22
22
  /**
23
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. Thrown here, titled only there — the copy this file
24
- * used to keep was a second title that could drift from core's with nothing to catch it.
23
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — constructed only there, by core's own
24
+ * `NotImplementedError`, and titled only there: the copy this file used to keep was a second title
25
+ * that could drift from core's with nothing to catch it.
25
26
  */
26
27
  export const PWA_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
27
28
 
@@ -130,15 +131,3 @@ export class PwaStrategyExhaustedError extends UltimateError {
130
131
  });
131
132
  }
132
133
  }
133
-
134
- /** A driver whose interface exists and whose backing implementation does not, yet. */
135
- export class NotImplementedError extends UltimateError {
136
- static readonly code = 'X_NOT_IMPLEMENTED' as const;
137
- constructor(cause: string, fix: string) {
138
- super({
139
- code: NotImplementedError.code,
140
- cause,
141
- fix,
142
- });
143
- }
144
- }
package/src/icons.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  * icons end up with clipped logos.
5
5
  */
6
6
 
7
- import { transformImageBytes } from '@ultimat3/core';
8
- import { escapeAttribute } from '@ultimat3/seo';
7
+ import { escapeHtml, transformImageBytes } from '@ultimat3/core';
9
8
  import { PwaIconMissingError } from './errors';
10
9
  import type { ManifestIcon } from './manifest';
11
10
 
@@ -186,7 +185,7 @@ export function appleTouchLinks(plan: IconPlan): string {
186
185
  // `href` — a quote in it closed the attribute AND the tag, so `<head>` got a live element.
187
186
  // seo's escaper is tier 1 and the one this package can reach; render's html.ts is tier 4.
188
187
  (entry) =>
189
- `<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${escapeAttribute(entry.outputPath)}">`,
188
+ `<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${escapeHtml(entry.outputPath)}">`,
190
189
  )
191
190
  .join('');
192
191
  }
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,13 +16,12 @@ 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';
26
23
  export {
27
24
  BuildIdMissingError,
28
- NotImplementedError,
29
25
  PWA_ERROR_CODES,
30
26
  PWA_ERROR_TITLES,
31
27
  PwaIconMissingError,
@@ -66,7 +62,7 @@ export type {
66
62
  IosGuidance,
67
63
  ReadSignal,
68
64
  } from './install';
69
- export { createInstallController, iosInstallGuidance } from './install';
65
+ export { installController, iosInstallGuidance } from './install';
70
66
  export type {
71
67
  DisplayMode,
72
68
  FileHandler,
@@ -142,10 +138,7 @@ export type {
142
138
  SkewState,
143
139
  } from './version-skew';
144
140
  export {
145
- APP_UPDATE_AVAILABLE,
146
141
  assertBuildId,
147
- BUILD_ID_HEADER,
148
- BUILD_ID_META,
149
142
  buildId,
150
143
  cacheNamespace,
151
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
@@ -10,9 +10,9 @@
10
10
  // with no map between them and nothing asserting they agreed, which is the same axiom-1 defect the
11
11
  // two `PwaConfig`s were. An app writes them once, in the one config file.
12
12
  import type { PwaColors } from '@ultimat3/core';
13
- import { escapeAttribute } from '@ultimat3/seo';
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';
@@ -132,6 +132,8 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
132
132
  assertValid(config);
133
133
  const capabilities = resolveCapabilities(config.capabilities);
134
134
  const scope = config.scope ?? '/';
135
+ const display = config.display ?? 'standalone';
136
+ const override = displayOverrideFor(display);
135
137
 
136
138
  const optional: MutableManifest = {};
137
139
  if (config.description !== undefined) optional.description = config.description;
@@ -141,13 +143,13 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
141
143
  if (config.screenshots !== undefined) optional.screenshots = config.screenshots;
142
144
 
143
145
  // A disabled capability contributes no manifest member at all — not an empty one.
144
- if (isEnabled(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
146
+ if (hasCapability(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
145
147
  optional.share_target = config.shareTarget;
146
148
  }
147
- if (isEnabled(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
149
+ if (hasCapability(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
148
150
  optional.file_handlers = config.fileHandlers;
149
151
  }
150
- if (isEnabled(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
152
+ if (hasCapability(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
151
153
  optional.protocol_handlers = config.protocolHandlers;
152
154
  }
153
155
 
@@ -156,8 +158,9 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
156
158
  short_name: config.shortName ?? shortNameFrom(config.name),
157
159
  start_url: config.startUrl ?? scope,
158
160
  scope,
159
- display: config.display ?? 'standalone',
160
- display_override: ['standalone', 'minimal-ui'],
161
+ display,
162
+ // Right after `display`, where it always was: an unchanged config emits unchanged bytes.
163
+ ...(override.length > 0 ? { display_override: override } : {}),
161
164
  orientation: config.orientation ?? 'any',
162
165
  lang: config.lang ?? 'en',
163
166
  dir: config.dir ?? 'ltr',
@@ -177,6 +180,19 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
177
180
  };
178
181
  }
179
182
 
183
+ /** The spec's own fallback chain for `display`, app windows only — `browser` is no window. */
184
+ const DISPLAY_CHAIN: readonly DisplayMode[] = ['fullscreen', 'standalone', 'minimal-ui'];
185
+
186
+ /**
187
+ * `display_override` is read BEFORE `display`, so it must start where `display` does: a fixed
188
+ * `['standalone','minimal-ui']` opened a `browser` app in its own window and gave a `fullscreen`
189
+ * one browser UI. `browser` gets none — any override would be a window it did not ask for.
190
+ */
191
+ function displayOverrideFor(display: DisplayMode): readonly DisplayMode[] {
192
+ const from = DISPLAY_CHAIN.indexOf(display);
193
+ return from < 0 ? [] : DISPLAY_CHAIN.slice(from);
194
+ }
195
+
180
196
  type MutableManifest = { -readonly [K in keyof WebManifest]?: WebManifest[K] };
181
197
 
182
198
  /** Home-screen labels are truncated by the OS well before this; the cap only bounds the string. */
@@ -231,7 +247,7 @@ export function renderThemeColorMeta(metas: readonly ThemeColorMeta[]): string {
231
247
  // quote in one emitted a second, live attribute — same class as `appleTouchLinks`, same
232
248
  // escaper, one per package rather than one per call site.
233
249
  (meta) =>
234
- `<meta name="theme-color" content="${escapeAttribute(meta.content)}" media="${escapeAttribute(meta.media)}">`,
250
+ `<meta name="theme-color" content="${escapeHtml(meta.content)}" media="${escapeHtml(meta.media)}">`,
235
251
  )
236
252
  .join('');
237
253
  }
@@ -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
  }
package/src/push.ts CHANGED
@@ -171,28 +171,46 @@ export function pushSource(options: PushSourceOptions = {}): string {
171
171
  const badging = options.badging === true;
172
172
 
173
173
  return `
174
+ // Parsed INSIDE waitUntil: json() on a body that is not JSON threw out of the listener before it
175
+ // was extended, and nothing was shown. Plain text is the body; JSON that is no object reads as {}.
176
+ function pushData(event){
177
+ if(!event.data)return {};
178
+ let v;
179
+ try{v=event.data.json()}catch(e){return {body:event.data.text()}}
180
+ return v!==null&&typeof v==='object'&&!Array.isArray(v)?v:{}
181
+ }
174
182
  self.addEventListener('push',(event)=>{
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;
183
- const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
184
- tag:tg,renotify:!!d.renotify&&tg!==undefined,requireInteraction:!!d.requireInteraction,
185
- lang:d.lang||undefined,actions:d.actions||[],data:{url:d.url||'/'}};
186
- event.waitUntil(self.registration.showNotification(d.title||'',opts)${
187
- badging ? '.then(()=>navigator.setAppBadge&&navigator.setAppBadge())' : ''
188
- });
183
+ event.waitUntil((async()=>{
184
+ const d=pushData(event);
185
+ // renotify needs the tag: showNotification rejects with a TypeError when the flag is set and
186
+ // the tag is empty, and the device shows nothing. renderPushPayload refuses the pair too, but a
187
+ // push body is composed by whatever holds the VAPID key, so this is the last guard there is.
188
+ // A NON-EMPTY STRING, never truthiness: WebIDL converts every tag to a DOMString, and [] and {}
189
+ // are truthy in JS while converting to '' and '[object Object]'. !!d.tag therefore enabled
190
+ // renotify for a JSON [] whose converted tag is empty — the rejection this line exists to avoid.
191
+ const tg=typeof d.tag==='string'&&d.tag!==''?d.tag:undefined;
192
+ const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
193
+ tag:tg,renotify:!!d.renotify&&tg!==undefined,requireInteraction:!!d.requireInteraction,
194
+ lang:d.lang||undefined,actions:Array.isArray(d.actions)?d.actions:[],data:{url:d.url||'/'}};
195
+ await self.registration.showNotification(d.title||'',opts);${
196
+ badging ? '\n if(navigator.setAppBadge)await navigator.setAppBadge();' : ''
197
+ }
198
+ })());
189
199
  });
200
+ // A tap opens THIS app only: the URL comes from the push body, whoever composed it. Another host, a
201
+ // non-http scheme, a malformed URL or a non-string opens the app's root instead.
202
+ function tapTarget(raw){
203
+ const root=new URL('/',self.location.origin).href;
204
+ if(typeof raw!=='string')return root;
205
+ try{const u=new URL(raw,self.location.origin);return u.origin===self.location.origin?u.href:root}
206
+ catch(e){return root}
207
+ }
190
208
  self.addEventListener('notificationclick',(event)=>{
191
209
  event.notification.close();
192
210
  // PushPayload.url is a PATH and WindowClient.url is absolute, so the comparison below is only
193
211
  // ever true after resolving — unresolved it matched no client at all and every tap opened a
194
212
  // second window on an app the user already had open. Same resolve as the fetch block's.
195
- const url=new URL((event.notification.data&&event.notification.data.url)||'/',self.location.origin).href;
213
+ const url=tapTarget(event.notification.data&&event.notification.data.url);
196
214
  event.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then((ws)=>{
197
215
  for(const w of ws){if(w.url===url&&'focus'in w)return w.focus()}
198
216
  return clients.openWindow(url)
@@ -4,7 +4,9 @@
4
4
  * that are no route: content-addressed chunks, cached the first time a page asks for one.
5
5
  */
6
6
 
7
+ import { routeRank } from '@ultimat3/core';
7
8
  import { SwScopeInvalidError } from './errors';
9
+ import type { PersonalPages } from './pages-cache-source';
8
10
  import type { PwaRoute, StrategyName } from './strategies';
9
11
  import { strategyFor } from './strategies';
10
12
 
@@ -23,35 +25,8 @@ const segmentsOf = (path: string): readonly string[] =>
23
25
  path.split('/').filter((segment) => segment.length > 0);
24
26
 
25
27
  /**
26
- * How specifically a path claims a URL: a literal segment beats a `:param`, which beats a `*`.
27
- * The weights are `@ultimat3/render`'s `compilePattern`, verbatim (100 / 10 / 1), so the service
28
- * worker and the server rank the same pathname the same way.
29
- *
30
- * DUPLICATED, not imported: `render` and `pwa` are both tier 4 and a sideways import is a build
31
- * error. The shared home is `@ultimat3/core`'s `route-vocabulary.ts` — tier 0, already the owner of
32
- * `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` for exactly this reason — and moving
33
- * it there is the follow-up this comment exists to name.
34
- */
35
- function specificityOf(path: string): number {
36
- return segmentsOf(path).reduce((total, segment) => {
37
- if (segment.startsWith('*')) return total + 1;
38
- if (segment.startsWith(':')) return total + 10;
39
- return total + 100;
40
- }, 0);
41
- }
42
-
43
- /**
44
- * A catch-all is a FALLBACK, and it sorts behind every rule that is not one — a second key, because
45
- * a sum over segments cannot say it. `/` has no segments and so scores 0, while `/*rest` scores 1:
46
- * on specificity alone a single root catch-all outranks the home page, and with it every precached
47
- * entry in the table. The rule this expresses is the one a reader already assumes — a pattern that
48
- * matches everything answers only what nothing else claimed.
49
- */
50
- const hasWildcard = (path: string): boolean =>
51
- segmentsOf(path).some((segment) => segment.startsWith('*'));
52
-
53
- /**
54
- * Ordered MOST SPECIFIC FIRST, because the emitted `ruleFor` returns the first pattern that
28
+ * Ordered MOST SPECIFIC FIRST — `@ultimat3/core`'s `routeRank`, the one rule `@ultimat3/render` and
29
+ * the request router share — because the emitted `ruleFor` returns the first pattern that
55
30
  * matches and has no notion of specificity of its own. Sorted alphabetically it did not: `:` (0x3A)
56
31
  * and `*` (0x2A) both sort before every letter, so `/posts/:id` shadowed `/posts/new` and a single
57
32
  * `/*` catch-all shadowed the entire table — every entry in `PRECACHE_MANIFEST` downloaded at
@@ -66,17 +41,21 @@ const hasWildcard = (path: string): boolean =>
66
41
  */
67
42
  const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
68
43
 
69
- export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
44
+ /**
45
+ * `personalPages: 'last-member'` routes a personal page by its render mode again — the pages facade
46
+ * partitions it per member. `'never'` (the default) leaves it `network-only`.
47
+ */
48
+ export function routeRules(
49
+ routes: readonly PwaRoute[],
50
+ personalPages: PersonalPages = 'never',
51
+ ): readonly RouteRule[] {
70
52
  return [...routes]
71
53
  .filter((route) => route.surface !== 'api')
72
- .sort(
73
- (a, b) =>
74
- Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
75
- specificityOf(b.path) - specificityOf(a.path) ||
76
- byCodeUnit(a.path, b.path),
77
- )
54
+ .sort((a, b) => routeRank(b.path) - routeRank(a.path) || byCodeUnit(a.path, b.path))
78
55
  .map((route) => {
79
- const strategy = strategyFor(route);
56
+ const strategy = strategyFor(
57
+ personalPages === 'last-member' ? { ...route, personal: false } : route,
58
+ );
80
59
  return {
81
60
  pattern: toPattern(route.path),
82
61
  strategy,
@@ -85,27 +64,53 @@ export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
85
64
  });
86
65
  }
87
66
 
67
+ /**
68
+ * A personal page that reaches a cache at all goes to `pages`, never `precache`: it is never in the
69
+ * precache manifest (fetched anonymously at install it is a sign-in redirect), so a `precache` rule
70
+ * was a cache that always missed — and only the pages facade files a member's page by member.
71
+ */
88
72
  function cacheFor(route: PwaRoute, strategy: StrategyName): 'precache' | 'runtime' | 'pages' {
89
73
  if (strategy === 'network-only') return 'runtime';
74
+ if (route.personal === true) return 'pages';
90
75
  if (route.offline === 'precache' && route.dynamic !== true) return 'precache';
91
76
  return 'pages';
92
77
  }
93
78
 
79
+ const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
80
+
81
+ /**
82
+ * A literal segment as the browser's `url.pathname` spells it — the WHATWG serializer itself, so a
83
+ * route path (the DECODED directory name) matches the PERCENT-ENCODED pathname `ruleFor` tests:
84
+ * `/precios-españa` and `/a b` were never handled. Not `encodeURI`, which also escapes `|`, `[`,
85
+ * `^` and `%` where a browser leaves them literal. A `%XX` escape matches either hex case, as a
86
+ * hand-typed link may carry one.
87
+ */
88
+ function literal(segment: string): string {
89
+ const url = new URL('https://x.invalid/');
90
+ url.pathname = `/${segment}`;
91
+ return escapeRegex(url.pathname.slice(1)).replace(
92
+ /%([0-9A-Fa-f]{2})/g,
93
+ (_, hex: string) =>
94
+ `%${[...hex].map((c) => (/[A-Fa-f]/.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : c)).join('')}`,
95
+ );
96
+ }
97
+
94
98
  /**
95
99
  * A trailing slash is dropped before the pattern is built, because the pattern allows one anyway:
96
- * a locale's home is spelled `/en/`, and `^/en//?$` matched `/en/` and missed `/en`.
100
+ * a locale's home is spelled `/en/`, and `^/en//?$` matched `/en/` and missed `/en`. A catch-all
101
+ * takes its leading slash with it, `(?:/.*)?`, so `/docs/*path` matches the bare `/docs` that
102
+ * `@ultimat3/render`'s static export writes for an empty `path`.
97
103
  */
98
104
  function toPattern(path: string): string {
99
- const bare = path.replace(/\/+$/, '');
100
- if (bare === '') return '^/$';
101
- const body = bare
102
- .split('/')
105
+ const segments = segmentsOf(path);
106
+ if (segments.length === 0) return '^/$';
107
+ const body = segments
103
108
  .map((segment) => {
104
- if (segment.startsWith(':')) return '[^/]+';
105
- if (segment.startsWith('*')) return '.*';
106
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
109
+ if (segment.startsWith(':')) return '/[^/]+';
110
+ if (segment.startsWith('*')) return '(?:/.*)?';
111
+ return `/${literal(segment)}`;
107
112
  })
108
- .join('/');
113
+ .join('');
109
114
  return `^${body}/?$`;
110
115
  }
111
116
 
@@ -125,7 +130,7 @@ export function assetRules(prefixes: readonly string[], scope: string): readonly
125
130
  );
126
131
  }
127
132
  return {
128
- pattern: `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
133
+ pattern: `^${escapeRegex(prefix)}`,
129
134
  strategy: 'cache-first',
130
135
  cache: 'runtime',
131
136
  asset: true,
@@ -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;
@@ -123,13 +118,12 @@ export function generateServiceWorker(
123
118
 
124
119
  const fallback = requireOfflineFallback(config.offline);
125
120
  const capabilities = resolveCapabilities(config.capabilities);
126
- // `last-member` routes a personal page by its render mode again — the pages facade partitions it.
127
- // The precache below still skips it: fetched anonymously at install, it is a sign-in redirect.
128
- const routed =
129
- fallback.personalPages === 'last-member'
130
- ? routes.map((route) => ({ ...route, personal: false }))
131
- : routes;
132
- const rules = [...assetRules(config.runtimeAssets ?? [], scope), ...routeRules(routed)];
121
+ // The precache below skips a personal page in both modes: fetched anonymously at install, it is a
122
+ // sign-in redirect. `routeRules` decides where `last-member` keeps it instead — the pages facade.
123
+ const rules = [
124
+ ...assetRules(config.runtimeAssets ?? [], scope),
125
+ ...routeRules(routes, fallback.personalPages),
126
+ ];
133
127
 
134
128
  const precache = buildPrecacheManifest({
135
129
  buildId,
@@ -163,13 +157,13 @@ export function generateServiceWorker(
163
157
  INSTALL_BLOCK,
164
158
  activateBlock(),
165
159
  fetchBlock(fallback.personalPages),
166
- messageBlock(isEnabled(capabilities, 'backgroundSync')),
160
+ messageBlock(hasCapability(capabilities, 'backgroundSync')),
167
161
  ];
168
162
 
169
- if (isEnabled(capabilities, 'push') && config.vapid !== undefined) {
170
- blocks.push(pushSource({ badging: isEnabled(capabilities, 'badging') }));
163
+ if (hasCapability(capabilities, 'push') && config.vapid !== undefined) {
164
+ blocks.push(pushSource({ badging: hasCapability(capabilities, 'badging') }));
171
165
  }
172
- if (isEnabled(capabilities, 'backgroundSync')) {
166
+ if (hasCapability(capabilities, 'backgroundSync')) {
173
167
  blocks.push(backgroundSyncSource());
174
168
  }
175
169
 
@@ -278,7 +272,9 @@ self.addEventListener('install',(event)=>{
278
272
  const e=PRECACHE_MANIFEST[i++];
279
273
  try{
280
274
  const r=await fetch(new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
281
- if(r.ok)await cache.put(new Request(e.url),r);
275
+ // Mid-rollout an OLD pod may answer: its document is not this build's offline copy.
276
+ const b=r.headers.get(BUILD_HEADER);
277
+ if(r.ok&&(b===null||b===BUILD_ID))await cache.put(new Request(e.url),r);
282
278
  }catch(err){}
283
279
  }
284
280
  };
@@ -299,7 +295,7 @@ self.addEventListener('activate',(event)=>{
299
295
  // the fetch handler answers from it (net) — start-up is off every navigation's critical path.
300
296
  try{if(self.registration&&self.registration.navigationPreload)await self.registration.navigationPreload.enable()}catch(e){}
301
297
  const cs=await self.clients.matchAll({type:'window'});
302
- 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});
303
299
  // NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
304
300
  // warm-up that copies a streamed page's whole body in here held every request of the tab it
305
301
  // just claimed — /feed's own reads included — until that body had ended. Kicked off, never
@@ -390,7 +386,7 @@ function seenBuild(res){
390
386
  const server=res.headers.get(BUILD_HEADER);
391
387
  if(SKEWED||server===null||server===BUILD_ID)return;
392
388
  SKEWED=true;
393
- 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(()=>{});
394
390
  }
395
391
  function withBuild(headers){
396
392
  const h=new Headers(headers);
@@ -409,7 +405,7 @@ async function healSkew(req,res){
409
405
  // Answering the request for real is what breaks that loop.
410
406
  SKEWED=true;
411
407
  const cs=await self.clients.matchAll({type:'window'});
412
- 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});
413
409
  return fetch(new Request(req,{headers:withBuild(req.headers)}));
414
410
  }`.trim();
415
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;