@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 +3 -0
- package/README.md +6 -5
- package/package.json +3 -3
- package/src/index.ts +8 -8
- package/src/precache.ts +3 -6
- package/src/version-skew.ts +15 -63
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 |
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
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
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/seo": "
|
|
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(
|
package/src/version-skew.ts
CHANGED
|
@@ -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
|
|
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
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|