@ultimat3/pwa 7.0.0 → 9.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
@@ -18,9 +18,12 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
18
18
  | 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
19
  | 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
20
  | 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. |
21
+ | 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. |
21
22
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
22
23
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
23
24
  | 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
+ | `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
+ | 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. |
24
27
  | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger. |
25
28
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
26
29
  | 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": "7.0.0",
3
+ "version": "9.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": "7.0.0",
35
- "@ultimat3/seo": "7.0.0"
34
+ "@ultimat3/core": "9.0.0",
35
+ "@ultimat3/seo": "9.0.0"
36
36
  }
37
37
  }
package/src/index.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  * re-exported here; a consumer still needs one import, and now it names the real type.
7
7
  */
8
8
  export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
9
+ // Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
10
+ // worker's size report is what a caller of this package prints.
11
+ export { formatBytes } from '@ultimat3/core';
9
12
  export type { BackgroundSyncOptions, RetryPolicy } from './background-sync';
10
13
  export {
11
14
  backgroundSyncSource,
@@ -94,7 +97,6 @@ export type { PrecacheAsset, PrecacheEntry, PrecacheInput, PrecacheManifest } fr
94
97
  export {
95
98
  buildPrecacheManifest,
96
99
  DEFAULT_PRECACHE_WARN_BYTES,
97
- formatBytes,
98
100
  serializePrecacheManifest,
99
101
  } from './precache';
100
102
  export type {
@@ -135,17 +137,18 @@ export {
135
137
  staleWhileRevalidate,
136
138
  strategyFor,
137
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.
138
145
  export type {
139
146
  AppUpdateAvailable,
140
147
  BuildIdInput,
141
148
  Deploy,
142
149
  DeployChannel,
143
- ForceReason,
144
150
  RetentionPlan,
145
151
  SkewState,
146
- UpdatePolicy,
147
- UpdatePolicyInput,
148
- UpdateSignalInput,
149
152
  } from './version-skew';
150
153
  export {
151
154
  APP_UPDATE_AVAILABLE,
@@ -154,9 +157,6 @@ export {
154
157
  BUILD_ID_META,
155
158
  buildId,
156
159
  cacheNamespace,
157
- DEFAULT_GRACE_MS,
158
160
  detectSkew,
159
161
  retentionPlan,
160
- updatePolicy,
161
- updateSignal,
162
162
  } from './version-skew';
package/src/precache.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  * hash, never the build id, or every deploy would re-fetch everything.
5
5
  */
6
6
 
7
+ // One formatter, in `@ultimat3/core`: the copy that lived here stopped at `mb`, and the route
8
+ // budget message on the other side of the build stopped at `kb`, for the same byte count.
9
+ import { formatBytes } from '@ultimat3/core';
7
10
  import type { PwaRoute } from './strategies';
8
11
 
9
12
  export interface PrecacheAsset {
@@ -120,12 +123,6 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
120
123
  return { buildId: input.buildId, entries: sorted, totalBytes, warnings };
121
124
  }
122
125
 
123
- export function formatBytes(bytes: number): string {
124
- if (bytes < 1024) return `${bytes}b`;
125
- if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10}kb`;
126
- return `${Math.round((bytes / (1024 * 1024)) * 10) / 10}mb`;
127
- }
128
-
129
126
  /** The manifest as it is embedded in `sw.js`; stable key order for determinism. */
130
127
  export function serializePrecacheManifest(manifest: PrecacheManifest): string {
131
128
  const rows = manifest.entries.map(
@@ -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
- }