@ultimat3/pwa 9.0.0 → 11.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
@@ -10,11 +10,13 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
10
10
  | Rule | Detail |
11
11
  |---|---|
12
12
  | `sw.js` | generated, never hand-written, never committed. No file in this package is a SW. |
13
- | Determinism | no `Date.now()`, no randomness, sort every collection. Same input → same bytes. |
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`. |
15
+ | A generated header | names something that regenerates the file. `sw.js` said `regenerate: x build` and no `x build` target emits a service worker — `generateServiceWorker` has no caller anywhere in the tree — so it names the CALL until a build writes it. |
14
16
  | Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
15
17
  | Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. |
16
18
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
17
- | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. `addAll` still does the fetching, because its all-or-nothing failure is what stops a half-populated precache from activating; the install block only re-keys what it stored. `service-worker.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
19
+ | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. `addAll` still does the fetching, because its all-or-nothing failure is what stops a half-populated precache from activating; the install block only re-keys what it stored. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
18
20
  | 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. |
19
21
  | 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. |
20
22
  | 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. |
@@ -24,7 +26,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
24
26
  | Capabilities | gate the manifest member **and** the SW block, where the capability has one. Disabled → zero bytes. `push`, `backgroundSync` and `badging` emit worker code; `shareTarget`, `fileHandlers` and `protocolHandlers` are **manifest-only** — the OS delivers to a route the app already serves — and their `CAPABILITY_SW_MARKERS` list is empty, which `service-worker.test.ts` checks in both directions: every declared marker is in the worker when its capability is on, none is when they are all off. `shareTarget` named `/_x/share-target` there while no block emitted it. |
25
27
  | `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. |
26
28
  | 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. |
27
- | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger. |
29
+ | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger — and schedules **no retry**: the handler rejects and the PLATFORM decides when to wake it again. `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23; of the policy only `maxAttempts` ever reached the worker, as a `SYNC_MAX_ATTEMPTS` constant nothing read, and the emitted `X_PWA_SYNC_INCOMPLETE` fix told the reader to raise `pwa.backgroundSync.retry.maxAttempts`, a key `PwaConfig` has never had. `background-sync.test.ts` asserts that every constant the worker declares is one the worker reads. |
28
30
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
29
31
  | 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. |
30
32
  | Colours | token values passed in via `PwaConfig.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. |
package/README.md CHANGED
@@ -93,7 +93,7 @@ capability nothing implements.
93
93
  | `planIcons`, `requireSourceIcon`, `maskableSafeZone` | icons and splashes from one source |
94
94
  | `BuiltinImagePipeline` | renders that plan: one square PNG per entry, deterministic |
95
95
  | `requireOfflineFallback` | the mandatory offline route |
96
- | `backgroundSyncSource`, `retryDelayMs` | the Background Sync trigger |
96
+ | `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
97
97
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
98
98
  | `createInstallController`, `iosInstallGuidance` | install prompt, never on first paint |
99
99
  | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "9.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "9.0.0",
35
- "@ultimat3/seo": "9.0.0"
34
+ "@ultimat3/core": "11.0.0",
35
+ "@ultimat3/seo": "11.0.0"
36
36
  }
37
37
  }
@@ -14,51 +14,37 @@
14
14
  * `UltimateError` does everywhere else in the framework.
15
15
  */
16
16
 
17
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
17
18
  import { PwaSyncFlushFailedError, PwaSyncIncompleteError } from './errors';
18
19
  import { BUILD_ID_HEADER } from './version-skew';
19
20
 
20
21
  export const SYNC_TAG = 'x-outbox';
21
22
 
22
- export interface RetryPolicy {
23
- readonly maxAttempts: number;
24
- readonly baseDelayMs: number;
25
- readonly maxDelayMs: number;
26
- }
27
-
28
- export const DEFAULT_RETRY: RetryPolicy = Object.freeze({
29
- maxAttempts: 6,
30
- baseDelayMs: 1_000,
31
- maxDelayMs: 5 * 60 * 1000,
32
- });
33
-
34
23
  /**
35
- * Deterministic exponential backoff — no jitter here on purpose: the Background Sync
36
- * scheduler already spreads wake-ups across clients, and a deterministic delay is
37
- * testable and reproducible in a bug report.
24
+ * `retry` was removed 2026-08-23, with `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs` and
25
+ * `shouldRetry`: **this package schedules no retry and never did.** The one-shot `sync` handler
26
+ * rejects, and the PLATFORM decides when to wake it again — `flushOutbox` counts no attempts and
27
+ * has nowhere to apply a delay. Of the policy only `maxAttempts` ever reached the worker, as a
28
+ * `SYNC_MAX_ATTEMPTS` constant nothing read; the two exported functions were called by their own
29
+ * test and by nothing else. Same rule as `PwaConfig.installPrompt` and `JobsConfig.driver`
30
+ * (`packages/core/src/config.ts`): a knob that produces neither a build error nor a runtime effect
31
+ * is worse than no knob, because an author sets it, ships, and nothing changes.
38
32
  */
39
- export function retryDelayMs(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): number {
40
- const clamped = Math.max(1, Math.min(attempt, policy.maxAttempts));
41
- return Math.min(policy.baseDelayMs * 2 ** (clamped - 1), policy.maxDelayMs);
42
- }
43
-
44
- export function shouldRetry(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): boolean {
45
- return attempt < policy.maxAttempts;
46
- }
47
-
48
33
  export interface BackgroundSyncOptions {
49
34
  /** Endpoint `@ultimat3/realtime` exposes to flush the outbox. */
50
35
  readonly flushEndpoint?: string;
51
- readonly retry?: RetryPolicy;
52
36
  }
53
37
 
54
38
  export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush';
55
39
 
56
40
  /**
57
- * Where the emitted class sends a reader. The same host `./errors.ts` documents these two codes
58
- * at — retyped here because the SW builds its URL from the code at throw time, and
59
- * `background-sync.test.ts` asserts the two halves still agree.
41
+ * Where the emitted class sends a reader — `@ultimat3/core`'s `ERROR_DOCS_URL`, interpolated into
42
+ * the worker source rather than restated, because a URL retyped in a generated string is a second
43
+ * constant that drifts silently. One page for every code, not one per code: `wiki/` is the
44
+ * framework's only public documentation surface and a code lives there in a table row, which has no
45
+ * anchor. `background-sync.test.ts` asserts the emitted value still equals what the registry says.
60
46
  */
61
- const SYNC_DOCS_BASE = 'https://ultimate.dev/errors/';
47
+ const SYNC_DOCS = ERROR_DOCS_URL;
62
48
 
63
49
  /**
64
50
  * The generated realm's own coded error, as source. Small on purpose — this ships in `sw.js` — and
@@ -70,7 +56,7 @@ const SYNC_DOCS_BASE = 'https://ultimate.dev/errors/';
70
56
  const SYNC_ERROR_CLASS = `
71
57
  class PwaSyncError extends Error{
72
58
  constructor(code,cause,fix){
73
- const docs=${JSON.stringify(SYNC_DOCS_BASE)}+code;
59
+ const docs=${JSON.stringify(SYNC_DOCS)};
74
60
  super(code+': '+cause+'\\n fix: '+fix+'\\n docs: '+docs);
75
61
  this.name='PwaSyncError';this.code=code;this.cause=cause;this.fix=fix;this.docs=docs;
76
62
  }
@@ -83,17 +69,17 @@ class PwaSyncError extends Error{
83
69
  */
84
70
  export function backgroundSyncSource(options: BackgroundSyncOptions = {}): string {
85
71
  const endpoint = options.flushEndpoint ?? DEFAULT_FLUSH_ENDPOINT;
86
- const retry = options.retry ?? DEFAULT_RETRY;
87
72
  return `
88
73
  const SYNC_TAG=${JSON.stringify(SYNC_TAG)};
89
74
  const FLUSH_ENDPOINT=${JSON.stringify(endpoint)};
90
- const SYNC_MAX_ATTEMPTS=${retry.maxAttempts};
91
75
  ${SYNC_ERROR_CLASS}
92
76
  async function flushOutbox(){
93
77
  const res=await fetch(FLUSH_ENDPOINT,{method:'POST',headers:{${JSON.stringify(BUILD_ID_HEADER)}:BUILD_ID}});
94
78
  if(!res.ok)throw new PwaSyncError(${JSON.stringify(PwaSyncFlushFailedError.code)},'outbox flush POST '+FLUSH_ENDPOINT+' returned '+res.status,'curl -i -X POST '+FLUSH_ENDPOINT+' — @ultimat3/realtime must mount it and answer 2xx');
95
- const body=await res.json().catch(()=>({remaining:0}));
96
- if(body.remaining>0)throw new PwaSyncError(${JSON.stringify(PwaSyncIncompleteError.code)},'outbox flush at '+FLUSH_ENDPOINT+' left '+body.remaining+' mutation(s) queued','x dev --role sync # drain the outbox, or raise pwa.backgroundSync.retry.maxAttempts in app.config.ts');
79
+ // ||{} rather than a default inside the catch: json() on a 200 body of null RESOLVES with null,
80
+ // so the catch never fires and body.remaining raised inside waitUntil instead of refusing coded.
81
+ const body=(await res.json().catch(()=>null))||{};
82
+ if(body.remaining>0)throw new PwaSyncError(${JSON.stringify(PwaSyncIncompleteError.code)},'outbox flush at '+FLUSH_ENDPOINT+' left '+body.remaining+' mutation(s) queued','x dev --role sync # run the role that drains the outbox; the browser reschedules this sync on its own');
97
83
  }
98
84
  self.addEventListener('sync',(event)=>{
99
85
  if(event.tag!==SYNC_TAG)return;
package/src/errors.ts CHANGED
@@ -46,7 +46,12 @@ registerErrorCodes(
46
46
  Object.fromEntries(Object.entries(PWA_ERROR_TITLES).map(([code, title]) => [code, { title }])),
47
47
  );
48
48
 
49
- const docsFor = (code: PwaErrorCode): string => `https://ultimate.dev/errors/${code}`;
49
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
50
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
51
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
52
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
53
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
54
+ // would be the same constant in eight places waiting to drift again.
50
55
 
51
56
  /** No `app/offline.tsx`. You cannot ship a PWA that has nothing to show offline. */
52
57
  export class PwaNoOfflineFallbackError extends UltimateError {
@@ -56,7 +61,6 @@ export class PwaNoOfflineFallbackError extends UltimateError {
56
61
  code: PwaNoOfflineFallbackError.code,
57
62
  cause,
58
63
  fix,
59
- docs: docsFor(PwaNoOfflineFallbackError.code),
60
64
  });
61
65
  }
62
66
  }
@@ -69,7 +73,6 @@ export class PwaIconMissingError extends UltimateError {
69
73
  code: PwaIconMissingError.code,
70
74
  cause,
71
75
  fix,
72
- docs: docsFor(PwaIconMissingError.code),
73
76
  });
74
77
  }
75
78
  }
@@ -82,7 +85,6 @@ export class PwaManifestInvalidError extends UltimateError {
82
85
  code: PwaManifestInvalidError.code,
83
86
  cause,
84
87
  fix,
85
- docs: docsFor(PwaManifestInvalidError.code),
86
88
  });
87
89
  }
88
90
  }
@@ -95,7 +97,6 @@ export class BuildIdMissingError extends UltimateError {
95
97
  code: BuildIdMissingError.code,
96
98
  cause,
97
99
  fix,
98
- docs: docsFor(BuildIdMissingError.code),
99
100
  });
100
101
  }
101
102
  }
@@ -108,7 +109,6 @@ export class SwScopeInvalidError extends UltimateError {
108
109
  code: SwScopeInvalidError.code,
109
110
  cause,
110
111
  fix,
111
- docs: docsFor(SwScopeInvalidError.code),
112
112
  });
113
113
  }
114
114
  }
@@ -125,7 +125,6 @@ export class PwaStrategyExhaustedError extends UltimateError {
125
125
  code: PwaStrategyExhaustedError.code,
126
126
  cause: `no cached response and the network failed for "${input.cacheName}"`,
127
127
  fix: 'pass options.fallback to staleWhileRevalidate(request, env, options), or set pwa.offline.fallback in app.config.ts',
128
- docs: docsFor(PwaStrategyExhaustedError.code),
129
128
  });
130
129
  }
131
130
  }
@@ -144,7 +143,6 @@ export class PwaSyncFlushFailedError extends UltimateError {
144
143
  code: PwaSyncFlushFailedError.code,
145
144
  cause,
146
145
  fix,
147
- docs: docsFor(PwaSyncFlushFailedError.code),
148
146
  });
149
147
  }
150
148
  }
@@ -157,7 +155,6 @@ export class PwaSyncIncompleteError extends UltimateError {
157
155
  code: PwaSyncIncompleteError.code,
158
156
  cause,
159
157
  fix,
160
- docs: docsFor(PwaSyncIncompleteError.code),
161
158
  });
162
159
  }
163
160
  }
@@ -170,7 +167,6 @@ export class NotImplementedError extends UltimateError {
170
167
  code: NotImplementedError.code,
171
168
  cause,
172
169
  fix,
173
- docs: docsFor(NotImplementedError.code),
174
170
  });
175
171
  }
176
172
  }
package/src/index.ts CHANGED
@@ -9,15 +9,12 @@ export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
9
9
  // Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
10
10
  // worker's size report is what a caller of this package prints.
11
11
  export { formatBytes } from '@ultimat3/core';
12
- export type { BackgroundSyncOptions, RetryPolicy } from './background-sync';
12
+ export type { BackgroundSyncOptions } from './background-sync';
13
13
  export {
14
14
  backgroundSyncSource,
15
15
  DEFAULT_FLUSH_ENDPOINT,
16
- DEFAULT_RETRY,
17
16
  registerBackgroundSyncSource,
18
- retryDelayMs,
19
17
  SYNC_TAG,
20
- shouldRetry,
21
18
  } from './background-sync';
22
19
  export type { Capability, CapabilityFlags, ResolvedCapabilities } from './capabilities';
23
20
  export {
package/src/precache.ts CHANGED
@@ -99,7 +99,12 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
99
99
  add({ url: asset.url, revision: asset.revision, bytes: asset.bytes, reason: 'asset' });
100
100
  }
101
101
 
102
- const sorted = [...entries.values()].sort((a, b) => a.url.localeCompare(b.url));
102
+ // CODE UNITS, never `localeCompare`: these entries are emitted into `sw.js`, whose header
103
+ // promises byte-identical output for identical input, and `localeCompare` with no locale
104
+ // argument answers from the runtime's ICU default and collation version — two machines, two
105
+ // orders, one no-op deploy that fires the SW update check. Same rule as `service-worker.ts`'s
106
+ // rule tie-break and `@ultimat3/jobs`' `job.ts`.
107
+ const sorted = [...entries.values()].sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
103
108
  const totalBytes = sorted.reduce((sum, entry) => sum + entry.bytes, 0);
104
109
  const warnBytes = input.warnBytes ?? DEFAULT_PRECACHE_WARN_BYTES;
105
110
 
package/src/push.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  * is a real bug that users report as "the app is broken".
7
7
  */
8
8
 
9
+ import type { Clock } from '@ultimat3/core';
10
+ import { systemClock } from '@ultimat3/core';
11
+
9
12
  export interface VapidConfig {
10
13
  readonly publicKey: string;
11
14
  /** `mailto:` or an https URL — required by the spec, checked by every push service. */
@@ -30,13 +33,21 @@ export interface PushSubscriptionRecord {
30
33
 
31
34
  export type SubscriptionState = 'active' | 'expired' | 'gone';
32
35
 
33
- /** A 404/410 from the push service means the subscription is dead — delete it, don't retry. */
36
+ /**
37
+ * A 404/410 from the push service means the subscription is dead — delete it, don't retry.
38
+ *
39
+ * `clock`, never `Date.now()`: this package's boundary says `Determinism | no Date.now()` without
40
+ * qualification, and an injectable default is still a wall-clock read living in the module. Every
41
+ * other "now" in the framework goes through a `Clock`, so a caller freezing time freezes this too
42
+ * rather than having to know that one function takes epoch milliseconds instead.
43
+ */
34
44
  export function subscriptionState(
35
45
  record: PushSubscriptionRecord,
36
46
  lastStatus: number | null,
37
- now = Date.now(),
47
+ clock: Clock = systemClock,
38
48
  ): SubscriptionState {
39
49
  if (lastStatus === 404 || lastStatus === 410) return 'gone';
50
+ const now = clock.now().getTime();
40
51
  if (record.expirationTime !== null && record.expirationTime <= now) return 'expired';
41
52
  return 'active';
42
53
  }
@@ -84,10 +84,62 @@ export function assertScope(swPath: string, scope: string): void {
84
84
  }
85
85
  }
86
86
 
87
+ const segmentsOf = (path: string): readonly string[] =>
88
+ path.split('/').filter((segment) => segment.length > 0);
89
+
90
+ /**
91
+ * How specifically a path claims a URL: a literal segment beats a `:param`, which beats a `*`.
92
+ * The weights are `@ultimat3/render`'s `compilePattern`, verbatim (100 / 10 / 1), so the service
93
+ * worker and the server rank the same pathname the same way.
94
+ *
95
+ * DUPLICATED, not imported: `render` and `pwa` are both tier 4 and a sideways import is a build
96
+ * error. The shared home is `@ultimat3/core`'s `route-vocabulary.ts` — tier 0, already the owner of
97
+ * `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` for exactly this reason — and moving
98
+ * it there is the follow-up this comment exists to name.
99
+ */
100
+ function specificityOf(path: string): number {
101
+ return segmentsOf(path).reduce((total, segment) => {
102
+ if (segment.startsWith('*')) return total + 1;
103
+ if (segment.startsWith(':')) return total + 10;
104
+ return total + 100;
105
+ }, 0);
106
+ }
107
+
108
+ /**
109
+ * A catch-all is a FALLBACK, and it sorts behind every rule that is not one — a second key, because
110
+ * a sum over segments cannot say it. `/` has no segments and so scores 0, while `/*rest` scores 1:
111
+ * on specificity alone a single root catch-all outranks the home page, and with it every precached
112
+ * entry in the table. The rule this expresses is the one a reader already assumes — a pattern that
113
+ * matches everything answers only what nothing else claimed.
114
+ */
115
+ const hasWildcard = (path: string): boolean =>
116
+ segmentsOf(path).some((segment) => segment.startsWith('*'));
117
+
118
+ /**
119
+ * Ordered MOST SPECIFIC FIRST, because the emitted `ruleFor` returns the first pattern that
120
+ * matches and has no notion of specificity of its own. Sorted alphabetically it did not: `:` (0x3A)
121
+ * and `*` (0x2A) both sort before every letter, so `/posts/:id` shadowed `/posts/new` and a single
122
+ * `/*` catch-all shadowed the entire table — every entry in `PRECACHE_MANIFEST` downloaded at
123
+ * install and then never looked up, and a route the app declared cacheable served `network-only`,
124
+ * which offline is the `/offline` document.
125
+ *
126
+ * The path stays as the tie-break, so the emitted file is still byte-identical for identical input
127
+ * — compared by CODE UNIT, never `localeCompare`, which answers from the runtime's ICU default and
128
+ * collation version: `/Posts` sorted before `/posts` on one machine and after it on the next, for
129
+ * the same route table. The rule `@ultimat3/jobs`' `job.ts` states for `x.manifest.json`, applied
130
+ * to the artifact this file emits.
131
+ */
132
+ const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
133
+
87
134
  export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
88
135
  return [...routes]
89
136
  .filter((route) => route.surface !== 'api')
90
- .sort((a, b) => a.path.localeCompare(b.path))
137
+ .sort(
138
+ (a, b) =>
139
+ Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
140
+ specificityOf(b.path) - specificityOf(a.path) ||
141
+ byCodeUnit(a.path, b.path),
142
+ )
91
143
  .map((route) => {
92
144
  const strategy = strategyFor(route);
93
145
  return {
@@ -175,8 +227,13 @@ export function generateServiceWorker(
175
227
  }
176
228
 
177
229
  function header(buildId: string): string {
230
+ // `regenerate:` names the CALL, not a command: `x build` emits no service worker — nothing in
231
+ // the tree calls `generateServiceWorker` at all — so the line sent whoever found this file in a
232
+ // diff to a command that would leave it exactly as they found it. It becomes `x build` on the day
233
+ // the build actually writes this file, and not before.
178
234
  return `// GENERATED by @ultimat3/pwa from the route table — do not edit.
179
- // build: ${buildId} regenerate: x build`;
235
+ // build: ${buildId}
236
+ // regenerate: generateServiceWorker(routes, config, buildId) from @ultimat3/pwa`;
180
237
  }
181
238
 
182
239
  function constants(