@ultimat3/pwa 9.0.0 → 10.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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "9.0.0",
3
+ "version": "10.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": "10.0.0",
35
+ "@ultimat3/seo": "10.0.0"
36
36
  }
37
37
  }
@@ -14,6 +14,7 @@
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
 
@@ -54,11 +55,13 @@ export interface BackgroundSyncOptions {
54
55
  export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush';
55
56
 
56
57
  /**
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.
58
+ * Where the emitted class sends a reader — `@ultimat3/core`'s `ERROR_DOCS_URL`, interpolated into
59
+ * the worker source rather than restated, because a URL retyped in a generated string is a second
60
+ * constant that drifts silently. One page for every code, not one per code: `wiki/` is the
61
+ * framework's only public documentation surface and a code lives there in a table row, which has no
62
+ * anchor. `background-sync.test.ts` asserts the emitted value still equals what the registry says.
60
63
  */
61
- const SYNC_DOCS_BASE = 'https://ultimate.dev/errors/';
64
+ const SYNC_DOCS = ERROR_DOCS_URL;
62
65
 
63
66
  /**
64
67
  * The generated realm's own coded error, as source. Small on purpose — this ships in `sw.js` — and
@@ -70,7 +73,7 @@ const SYNC_DOCS_BASE = 'https://ultimate.dev/errors/';
70
73
  const SYNC_ERROR_CLASS = `
71
74
  class PwaSyncError extends Error{
72
75
  constructor(code,cause,fix){
73
- const docs=${JSON.stringify(SYNC_DOCS_BASE)}+code;
76
+ const docs=${JSON.stringify(SYNC_DOCS)};
74
77
  super(code+': '+cause+'\\n fix: '+fix+'\\n docs: '+docs);
75
78
  this.name='PwaSyncError';this.code=code;this.cause=cause;this.fix=fix;this.docs=docs;
76
79
  }
@@ -92,7 +95,9 @@ ${SYNC_ERROR_CLASS}
92
95
  async function flushOutbox(){
93
96
  const res=await fetch(FLUSH_ENDPOINT,{method:'POST',headers:{${JSON.stringify(BUILD_ID_HEADER)}:BUILD_ID}});
94
97
  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}));
98
+ // ||{} rather than a default inside the catch: json() on a 200 body of null RESOLVES with null,
99
+ // so the catch never fires and body.remaining raised inside waitUntil instead of refusing coded.
100
+ const body=(await res.json().catch(()=>null))||{};
96
101
  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');
97
102
  }
98
103
  self.addEventListener('sync',(event)=>{
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/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,56 @@ 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
+ */
87
128
  export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
88
129
  return [...routes]
89
130
  .filter((route) => route.surface !== 'api')
90
- .sort((a, b) => a.path.localeCompare(b.path))
131
+ .sort(
132
+ (a, b) =>
133
+ Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
134
+ specificityOf(b.path) - specificityOf(a.path) ||
135
+ a.path.localeCompare(b.path),
136
+ )
91
137
  .map((route) => {
92
138
  const strategy = strategyFor(route);
93
139
  return {
@@ -175,8 +221,13 @@ export function generateServiceWorker(
175
221
  }
176
222
 
177
223
  function header(buildId: string): string {
224
+ // `regenerate:` names the CALL, not a command: `x build` emits no service worker — nothing in
225
+ // the tree calls `generateServiceWorker` at all — so the line sent whoever found this file in a
226
+ // diff to a command that would leave it exactly as they found it. It becomes `x build` on the day
227
+ // the build actually writes this file, and not before.
178
228
  return `// GENERATED by @ultimat3/pwa from the route table — do not edit.
179
- // build: ${buildId} regenerate: x build`;
229
+ // build: ${buildId}
230
+ // regenerate: generateServiceWorker(routes, config, buildId) from @ultimat3/pwa`;
180
231
  }
181
232
 
182
233
  function constants(