@ultimat3/pwa 23.0.0 → 24.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,14 +11,15 @@ 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
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. |
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 `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. |
24
25
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
@@ -28,7 +29,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
28
29
  | 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
30
  | 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
31
  | 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
+ | 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. |
32
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. |
33
34
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
34
35
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
@@ -38,13 +39,13 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
38
39
  | `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
40
  | 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
41
  | 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. |
42
+ | 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
43
  | 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
44
  | 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
45
  | 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
46
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
46
47
  | `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. |
48
+ | 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
49
  | 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
50
  | `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
51
  | 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. |
package/README.md CHANGED
@@ -71,7 +71,7 @@ before an un-shippable PWA exists.
71
71
 
72
72
  | Capability | Manifest member | SW code |
73
73
  |---|---|---|
74
- | `push` | — | `push` + `notificationclick` listeners |
74
+ | `push` | — | `push` + `notificationclick` listeners; a tap opens a same-origin URL only |
75
75
  | `backgroundSync` | — | `sync` listener that tells every open tab to drain realtime's outbox (`OUTBOX_DRAIN_MESSAGE`) |
76
76
  | `badging` | — | `navigator.setAppBadge` after a push |
77
77
  | `shareTarget` | `share_target` | — |
@@ -95,7 +95,6 @@ a job boundary the class is gone and the `code` is what survives — match on th
95
95
  | Class | Code | Declared in |
96
96
  |---|---|---|
97
97
  | `BuildIdMissingError` | `X_BUILD_ID_MISSING` | `src/errors.ts` |
98
- | `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
99
98
  | `PwaIconMissingError` | `X_PWA_ICON_MISSING` | `src/errors.ts` |
100
99
  | `PwaManifestInvalidError` | `X_PWA_MANIFEST_INVALID` | `src/errors.ts` |
101
100
  | `PwaNoOfflineFallbackError` | `X_PWA_NO_OFFLINE_FALLBACK` | `src/errors.ts` |
@@ -108,7 +107,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
108
107
  |---|---|
109
108
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
110
109
  | `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
+ | `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
111
  | `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
112
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
114
113
  | `buildId`, `detectSkew`, `retentionPlan` | version skew |
@@ -153,9 +152,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
153
152
  lands exactly inside `maskableSafeZone(size)`; the ring around it is `background`, which is
154
153
  hex or `transparent` (there are no named colours). Same bytes in, same bytes out.
155
154
  - **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.
155
+ interpolate app configuration into attributes, so both run it through `escapeHtml` from
156
+ `@ultimat3/core` — the framework's one HTML character table (`@ultimat3/render`'s `html.ts` is
157
+ tier 4, sideways, and re-exports the same function). Never a second escaper here.
159
158
  - **A precache URL may already carry a query.** `PrecacheAsset.url` is public API and bundlers emit
160
159
  `?v=<hash>` of their own, so the install block picks `?` or `&` per entry. A fixed `?` produced
161
160
  `...?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": "24.0.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,6 @@
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": "24.0.0"
37
36
  }
38
37
  }
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
@@ -25,7 +25,6 @@ export {
25
25
  export type { PwaErrorCode } from './errors';
26
26
  export {
27
27
  BuildIdMissingError,
28
- NotImplementedError,
29
28
  PWA_ERROR_CODES,
30
29
  PWA_ERROR_TITLES,
31
30
  PwaIconMissingError,
package/src/manifest.ts CHANGED
@@ -10,7 +10,7 @@
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
15
  import { isEnabled, resolveCapabilities } from './capabilities';
16
16
  import { PwaManifestInvalidError } from './errors';
@@ -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;
@@ -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
  }
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,
@@ -123,13 +123,12 @@ export function generateServiceWorker(
123
123
 
124
124
  const fallback = requireOfflineFallback(config.offline);
125
125
  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)];
126
+ // The precache below skips a personal page in both modes: fetched anonymously at install, it is a
127
+ // sign-in redirect. `routeRules` decides where `last-member` keeps it instead — the pages facade.
128
+ const rules = [
129
+ ...assetRules(config.runtimeAssets ?? [], scope),
130
+ ...routeRules(routes, fallback.personalPages),
131
+ ];
133
132
 
134
133
  const precache = buildPrecacheManifest({
135
134
  buildId,
@@ -278,7 +277,9 @@ self.addEventListener('install',(event)=>{
278
277
  const e=PRECACHE_MANIFEST[i++];
279
278
  try{
280
279
  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);
280
+ // Mid-rollout an OLD pod may answer: its document is not this build's offline copy.
281
+ const b=r.headers.get(BUILD_HEADER);
282
+ if(r.ok&&(b===null||b===BUILD_ID))await cache.put(new Request(e.url),r);
282
283
  }catch(err){}
283
284
  }
284
285
  };