@ultimat3/pwa 8.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. |
@@ -22,6 +24,8 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
22
24
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
23
25
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
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. |
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. |
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. |
25
29
  | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger. |
26
30
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
27
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. |
package/README.md CHANGED
@@ -38,13 +38,14 @@ and the app dies with a blank screen and no error anyone can act on.
38
38
  | Client → server | every SW-proxied request carries `x-ultimate-build` |
39
39
  | Retention | `retentionPlan(deploys, keep)` keeps the last N deploys' assets alive (default 3) |
40
40
  | Stale client | gets `AppUpdateAvailable`, never a 404 |
41
- | Forced reload | only for `forceOn` reasons, only after `graceMs` (default 6h) |
41
+ | Forced reload | none. This package never navigates a client — the app decides what to do with the message |
42
42
  | Preview deploys | cache names are `x-<kind>-<buildId>`, so a branch build cannot poison production |
43
43
 
44
44
  ```ts
45
- detectSkew(clientBuildId, serverBuildId); // 'current' | 'stale' | 'unknown'
46
- updateSignal({ clientBuildId, serverBuildId, policy: updatePolicy() });
47
- // → { type: 'AppUpdateAvailable', from, to, forced, deadlineAt }
45
+ // The generated worker posts this to every page it controls, on activation — and this is the
46
+ // whole message, which `version-skew.test.ts` holds the interface to.
47
+ // { type: 'AppUpdateAvailable', to: BUILD_ID }
48
+ detectSkew(clientBuildId, message.to); // 'current' | 'stale' | 'unknown'
48
49
  ```
49
50
 
50
51
  `unknown` means no id was sent — a first load or a crawler — and is never treated as stale.
@@ -87,7 +88,7 @@ capability nothing implements.
87
88
  | `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
88
89
  | `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table |
89
90
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
90
- | `buildId`, `detectSkew`, `retentionPlan`, `updatePolicy`, `updateSignal` | version skew |
91
+ | `buildId`, `detectSkew`, `retentionPlan` | version skew |
91
92
  | `generateWebManifest` | the manifest + `theme-color` metas for both schemes |
92
93
  | `planIcons`, `requireSourceIcon`, `maskableSafeZone` | icons and splashes from one source |
93
94
  | `BuiltinImagePipeline` | renders that plan: one square PNG per entry, deterministic |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "8.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": "8.0.0",
35
- "@ultimat3/seo": "8.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/index.ts CHANGED
@@ -137,17 +137,18 @@ export {
137
137
  staleWhileRevalidate,
138
138
  strategyFor,
139
139
  } from './strategies';
140
+ // The forced-reload half of this module is gone as of 9.0.0 — `updateSignal`, `updatePolicy`,
141
+ // `DEFAULT_GRACE_MS`, `ForceReason`, `UpdatePolicy`, `UpdatePolicyInput`, `UpdateSignalInput`.
142
+ // It computed `forced`/`deadlineAt` for a client-side reload no code in the framework performed,
143
+ // from a caller that never existed. What remains is what runs: an id, a comparison, a retention
144
+ // plan, and the message the generated worker really posts.
140
145
  export type {
141
146
  AppUpdateAvailable,
142
147
  BuildIdInput,
143
148
  Deploy,
144
149
  DeployChannel,
145
- ForceReason,
146
150
  RetentionPlan,
147
151
  SkewState,
148
- UpdatePolicy,
149
- UpdatePolicyInput,
150
- UpdateSignalInput,
151
152
  } from './version-skew';
152
153
  export {
153
154
  APP_UPDATE_AVAILABLE,
@@ -156,9 +157,6 @@ export {
156
157
  BUILD_ID_META,
157
158
  buildId,
158
159
  cacheNamespace,
159
- DEFAULT_GRACE_MS,
160
160
  detectSkew,
161
161
  retentionPlan,
162
- updatePolicy,
163
- updateSignal,
164
162
  } from './version-skew';
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(
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * immutable build id per deploy → the client sends it on every request →
7
7
  * old builds' assets are retained for N deploys → a stale client gets
8
- * `AppUpdateAvailable`, never a 404 → forced reload only after a grace period.
8
+ * `AppUpdateAvailable`, never a 404.
9
+ *
10
+ * The app decides what to do with that message. This package never navigates a client.
9
11
  */
10
12
 
11
13
  import { BuildIdMissingError } from './errors';
@@ -115,73 +117,23 @@ export function detectSkew(
115
117
  return clientBuildId === serverBuildId ? 'current' : 'stale';
116
118
  }
117
119
 
118
- export type ForceReason = 'security' | 'breaking-protocol' | 'never';
119
-
120
- export interface UpdatePolicyInput {
121
- /** How long a stale client may keep running before the reload is forced. */
122
- readonly graceMs?: number;
123
- readonly forceOn?: readonly ForceReason[];
124
- }
125
-
126
- export interface UpdatePolicy {
127
- readonly graceMs: number;
128
- readonly forceOn: readonly ForceReason[];
129
- shouldForce(reason: ForceReason, staleForMs: number): boolean;
130
- }
131
-
132
- export const DEFAULT_GRACE_MS = 6 * 60 * 60 * 1000;
133
-
134
- export function updatePolicy(input: UpdatePolicyInput = {}): UpdatePolicy {
135
- const graceMs = input.graceMs ?? DEFAULT_GRACE_MS;
136
- const forceOn = input.forceOn ?? ['security'];
137
- return {
138
- graceMs,
139
- forceOn,
140
- // A security patch still respects the grace window; it just does not wait forever.
141
- shouldForce: (reason, staleForMs) => forceOn.includes(reason) && staleForMs >= graceMs,
142
- };
143
- }
144
-
145
120
  /**
146
- * The client-side contract. The SW posts this to every controlled page; the app shows an
147
- * unobtrusive "refresh to update" affordance and reloads on the user's terms — unless
148
- * `forced`, in which case it reloads at `deadlineAt`.
121
+ * The client-side contract, and the whole of it: what the generated worker posts to every page it
122
+ * controls on activation. The page compares `to` against its own `BUILD_ID_META` — `detectSkew`
123
+ * is that comparison — and renders its own "refresh to update" affordance.
124
+ *
125
+ * It declared `from`, `forced` and `deadlineAt` too, for a forced reload after a grace period that
126
+ * NOTHING performed: `updateSignal`/`updatePolicy` computed the three and had no runtime caller,
127
+ * and `x deploy --critical`, the flag that was to have set the reason, was removed in 4.0.0 for
128
+ * being read by nobody. The two runtimes that hold both build ids cannot call into this package
129
+ * anyway — `http`'s `ctx.clientBuildId` (tier 2) and `sync`'s `update-available` frame (tier 3)
130
+ * are both BELOW `pwa`, and imports only go down. `version-skew.test.ts` holds this interface to
131
+ * the literal the worker emits, so the two can no longer differ.
149
132
  */
150
133
  export interface AppUpdateAvailable {
151
134
  readonly type: 'AppUpdateAvailable';
152
- readonly from: string;
135
+ /** The build the worker that posted this was generated for. */
153
136
  readonly to: string;
154
- readonly forced: boolean;
155
- /** Epoch milliseconds after which the client reloads itself. Null when not forced. */
156
- readonly deadlineAt: number | null;
157
137
  }
158
138
 
159
139
  export const APP_UPDATE_AVAILABLE = 'AppUpdateAvailable' as const;
160
-
161
- export interface UpdateSignalInput {
162
- readonly clientBuildId: string | null | undefined;
163
- readonly serverBuildId: string;
164
- readonly policy: UpdatePolicy;
165
- readonly reason?: ForceReason;
166
- readonly staleForMs?: number;
167
- readonly now?: number;
168
- }
169
-
170
- /** Null when the client is current or unknown — no signal, no nag. */
171
- export function updateSignal(input: UpdateSignalInput): AppUpdateAvailable | null {
172
- const state = detectSkew(input.clientBuildId, input.serverBuildId);
173
- if (state !== 'stale') return null;
174
-
175
- const reason = input.reason ?? 'never';
176
- const staleForMs = input.staleForMs ?? 0;
177
- const forced = input.policy.shouldForce(reason, staleForMs);
178
- const now = input.now ?? Date.now();
179
-
180
- return {
181
- type: APP_UPDATE_AVAILABLE,
182
- from: String(input.clientBuildId),
183
- to: input.serverBuildId,
184
- forced,
185
- deadlineAt: forced ? now : null,
186
- };
187
- }