@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 +4 -2
- package/package.json +3 -3
- package/src/background-sync.ts +11 -6
- package/src/errors.ts +6 -10
- package/src/push.ts +13 -2
- package/src/service-worker.ts +53 -2
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/seo": "
|
|
34
|
+
"@ultimat3/core": "10.0.0",
|
|
35
|
+
"@ultimat3/seo": "10.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/background-sync.ts
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/service-worker.ts
CHANGED
|
@@ -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(
|
|
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}
|
|
229
|
+
// build: ${buildId}
|
|
230
|
+
// regenerate: generateServiceWorker(routes, config, buildId) from @ultimat3/pwa`;
|
|
180
231
|
}
|
|
181
232
|
|
|
182
233
|
function constants(
|