@ultimat3/pwa 20.2.0 → 21.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 +6 -3
- package/README.md +2 -2
- package/package.json +4 -3
- package/src/background-sync.ts +22 -73
- package/src/capabilities.ts +8 -4
- package/src/errors.ts +2 -30
- package/src/index.ts +0 -2
- package/src/pages-cache-source.ts +56 -0
- package/src/service-worker.ts +35 -8
- package/src/strategies.ts +16 -11
- package/src/version-skew.ts +6 -4
package/CLAUDE.md
CHANGED
|
@@ -32,8 +32,11 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
32
32
|
| 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. |
|
|
33
33
|
| `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. |
|
|
34
34
|
| 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. |
|
|
35
|
-
|
|
|
36
|
-
|
|
|
35
|
+
| A cache copy is never awaited before answering | `Cache.put` reads the whole body, so a strategy that awaited it held a streamed (or slow) document away from the tab until it had ENDED. The emitted strategies hand the copy to `later(wait, …)` — the fetch event's `waitUntil` — and return the response at once. `service-worker-runtime.test.ts` streams a gated body and asserts the page reads the first chunk before it ends; its `StubCache.put` reads the whole body, as the real one does, or the test could not fail. |
|
|
36
|
+
| A per-member document | NEVER served from cache while online, and partitioned by principal offline (21.0.0, security). The pages cache was keyed by URL alone, so `/feed` (`stream`, SWR) rendered for kenji answered bruno on a shared browser. The emitted strategies open caches through `openCache`; for `PAGES` that is a facade: a shareable response is stored and read as before, a PRIVATE one (`private`/`no-store`, or carrying `x-ultimate-scope` — `CLIENT_SCOPE_HEADER`, set by `@ultimat3/cli`'s `dev-render.ts` exactly where the scope meta is) is never read from `PAGES` and is kept only in `PAGES~<scope>`, written in the BACKGROUND (a stream's put would otherwise hold the navigation) and read only by `pageOffline`. Storing one principal's page deletes every other partition, so offline answers the most recent member only; private with no scope is not kept at all. `service-worker-runtime.test.ts` runs two members on one URL. |
|
|
37
|
+
| The installing page | cached on `activate`. It loaded before the worker controlled it, so no strategy saw it and an `offline: 'runtime'` route was unavailable offline until a second online visit. After `clients.claim()` each controlled window's URL runs through its OWN route's strategy (`warm`) — only rules with `c: 'pages'`, same origin, not `NEVER_CACHE` — never a second caching rule. Best effort; a failed warm-up never fails activation. It runs AFTER activation, never inside its `waitUntil`: a fetch event waits for the worker to finish activating, so a warm-up copying a streamed page's whole body there held the claimed tab's every request — the `/feed` e2e hang. The offline page read (`pageOffline`) awaits it instead. `service-worker-runtime.test.ts` runs it. |
|
|
38
|
+
| Outbox drain | the worker SENDS NOTHING, `As of 21.0.0`. The outbox is `@ultimat3/realtime`'s and lives in the page (IndexedDB), so the `sync` event — and the `{type:'flush-outbox'}` fallback message, posted by `registerBackgroundSyncSource` wherever `registration.sync` is absent (Safari, Firefox) — both run `drainOutbox`, which posts `OUTBOX_DRAIN_MESSAGE` (`@ultimat3/core`) to EVERY open window client; realtime's listener drains. No client open = the handler resolves and the next load drains; it never fakes work. `/_x/outbox/flush`, `DEFAULT_FLUSH_ENDPOINT`, and `BackgroundSyncOptions` were deleted, and `X_PWA_SYNC_FLUSH_FAILED` / `X_PWA_SYNC_INCOMPLETE` are still registered but thrown by nothing (shipped codes are stable forever): the worker POSTed to a route nothing in the framework ever mounted. The fallback branch lives in the shared message handler, so `CAPABILITY_SW_MARKERS.backgroundSync` carrying `d.type==='flush-outbox'` is what keeps it gated with the `drainOutbox` it calls. `background-sync.test.ts` and `service-worker-runtime.test.ts` run the emitted code and assert the posted messages. |
|
|
39
|
+
| Outbox | queue, replay, retry and idempotency are all `@ultimat3/realtime`'s. This package only registers the sync trigger and schedules **no retry** — `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23. |
|
|
37
40
|
| Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
|
|
38
41
|
| `renotify` | never without a `tag`, at BOTH ends. The Notifications spec makes `showNotification` reject with a `TypeError` for the pair, so the device shows **nothing** — the failure a `renotify: true` with no collapse key produces is the whole notification disappearing. `renderPushPayload` drops the flag and files a `warnings` entry (a knob with no effect is worse than no knob), and the emitted handler recomputes it as `!!d.renotify&&!!d.tag` because a push body is composed by whatever holds the VAPID key. `push.test.ts`'s `showNotification` stub throws the spec's TypeError, so a stub that tolerates the pair cannot go green. |
|
|
39
42
|
| 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. |
|
|
@@ -43,7 +46,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
43
46
|
| Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `dev-assets.ts` reads it at `ICON_SOURCE` and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. |
|
|
44
47
|
| Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. |
|
|
45
48
|
| Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. |
|
|
46
|
-
| Errors in `sw.js` | no bundler there, so no `UltimateError` import
|
|
49
|
+
| Errors in `sw.js` | no bundler there, so no `UltimateError` import. Never emit a bare `throw new Error`: a coded failure in the worker gets a code in `errors.ts` and a local class carrying `code`, `cause`, `fix`, `docs`. `As of 21.0.0` the emitted worker throws nothing of its own. |
|
|
47
50
|
|
|
48
51
|
```
|
|
49
52
|
bun test # from packages/pwa
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ before an un-shippable PWA exists.
|
|
|
66
66
|
| Capability | Manifest member | SW code |
|
|
67
67
|
|---|---|---|
|
|
68
68
|
| `push` | — | `push` + `notificationclick` listeners |
|
|
69
|
-
| `backgroundSync` | — | `sync` listener
|
|
69
|
+
| `backgroundSync` | — | `sync` listener that tells every open tab to drain realtime's outbox (`OUTBOX_DRAIN_MESSAGE`) |
|
|
70
70
|
| `badging` | — | `navigator.setAppBadge` after a push |
|
|
71
71
|
| `shareTarget` | `share_target` | — |
|
|
72
72
|
| `fileHandlers` | `file_handlers` | — |
|
|
@@ -119,7 +119,7 @@ capability nothing implements.
|
|
|
119
119
|
- **Precache revisions are content hashes, never the build id.** Keying on the build id
|
|
120
120
|
re-downloads every asset on every deploy.
|
|
121
121
|
- **The mutation queue lives in `@ultimat3/realtime`, not here** (SRP). This package owns
|
|
122
|
-
only the Background Sync trigger that
|
|
122
|
+
only the Background Sync trigger that tells the open tabs to drain it.
|
|
123
123
|
- **Push bodies are rendered server-side per subscriber locale**, from the locale stored on
|
|
124
124
|
the subscription. A notification in the wrong language is a real bug, and the sending
|
|
125
125
|
server has no request context to infer one from.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/pwa",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.0",
|
|
4
4
|
"description": "Generated service worker, web manifest, icons, push and version-skew handling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/seo": "
|
|
35
|
+
"@ultimat3/core": "21.0.0",
|
|
36
|
+
"@ultimat3/seo": "21.0.0"
|
|
36
37
|
}
|
|
37
38
|
}
|
package/src/background-sync.ts
CHANGED
|
@@ -1,94 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Background Sync
|
|
2
|
+
* Background Sync: the browser-side TRIGGER for draining the offline outbox, and nothing else.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The outbox itself — queued mutations, their idempotency keys, the replay over HTTP — is
|
|
5
|
+
* `@ultimat3/realtime`'s, and it lives in the PAGE (IndexedDB), because only the page holds the
|
|
6
|
+
* store, the principal and `clientTransport`. So when the platform says connectivity is back, the
|
|
7
|
+
* worker does not send anything itself: it posts `OUTBOX_DRAIN_MESSAGE` to every open window client
|
|
8
|
+
* and realtime's listener drains. One outbox, not two — until 21.0.0 the worker POSTed to
|
|
9
|
+
* `/_x/outbox/flush`, a route nothing in the framework ever mounted.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* of its own, so `SYNC_ERROR_CLASS` emits one: a bare `Error` carries a message and nothing a
|
|
13
|
-
* caller can read, while the emitted class exposes the same `code`, `cause`, `fix` and `docs` an
|
|
14
|
-
* `UltimateError` does everywhere else in the framework.
|
|
11
|
+
* With NO client open there is nobody to drain, and the handler resolves rather than faking work:
|
|
12
|
+
* the queue is still in IndexedDB and the next page load replays it. Rejecting instead would only
|
|
13
|
+
* make the platform re-wake a worker that still has nobody to tell.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
|
-
import {
|
|
18
|
-
import { PwaSyncFlushFailedError, PwaSyncIncompleteError } from './errors';
|
|
19
|
-
import { BUILD_ID_HEADER } from './version-skew';
|
|
16
|
+
import { OUTBOX_DRAIN_MESSAGE } from '@ultimat3/core';
|
|
20
17
|
|
|
21
18
|
export const SYNC_TAG = 'x-outbox';
|
|
22
19
|
|
|
23
20
|
/**
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
* rejects, and the PLATFORM decides when to wake it again — `flushOutbox` counts no attempts and
|
|
27
|
-
* has nowhere to apply a delay. Of the policy only `maxAttempts` ever reached the worker, as a
|
|
28
|
-
* `SYNC_MAX_ATTEMPTS` constant nothing read; the two exported functions were called by their own
|
|
29
|
-
* test and by nothing else. Same rule as `PwaConfig.installPrompt` and `JobsConfig.driver`
|
|
30
|
-
* (`packages/core/src/config.ts`): a knob that produces neither a build error nor a runtime effect
|
|
31
|
-
* is worse than no knob, because an author sets it, ships, and nothing changes.
|
|
21
|
+
* Emitted into `sw.js` only when the `backgroundSync` capability is on. `drainOutbox` is also what
|
|
22
|
+
* the shared message handler calls for the page's `flush-outbox` fallback (`service-worker.ts`).
|
|
32
23
|
*/
|
|
33
|
-
export
|
|
34
|
-
/** Endpoint `@ultimat3/realtime` exposes to flush the outbox. */
|
|
35
|
-
readonly flushEndpoint?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Where the emitted class sends a reader — `@ultimat3/core`'s `ERROR_DOCS_URL`, interpolated into
|
|
42
|
-
* the worker source rather than restated, because a URL retyped in a generated string is a second
|
|
43
|
-
* constant that drifts silently. One page for every code, not one per code: `wiki/` is the
|
|
44
|
-
* framework's only public documentation surface and a code lives there in a table row, which has no
|
|
45
|
-
* anchor. `background-sync.test.ts` asserts the emitted value still equals what the registry says.
|
|
46
|
-
*/
|
|
47
|
-
const SYNC_DOCS = ERROR_DOCS_URL;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The generated realm's own coded error, as source. Small on purpose — this ships in `sw.js` — and
|
|
51
|
-
* deliberately not a bare `Error`: `code` is what a reporting hook groups on, `fix` is what the
|
|
52
|
-
* developer in devtools acts on, and neither survives being flattened into a message alone. The
|
|
53
|
-
* message still renders the contract's own line shape, because an uncaught `waitUntil` rejection
|
|
54
|
-
* prints nothing else.
|
|
55
|
-
*/
|
|
56
|
-
const SYNC_ERROR_CLASS = `
|
|
57
|
-
class PwaSyncError extends Error{
|
|
58
|
-
constructor(code,cause,fix){
|
|
59
|
-
const docs=${JSON.stringify(SYNC_DOCS)};
|
|
60
|
-
super(code+': '+cause+'\\n fix: '+fix+'\\n docs: '+docs);
|
|
61
|
-
this.name='PwaSyncError';this.code=code;this.cause=cause;this.fix=fix;this.docs=docs;
|
|
62
|
-
}
|
|
63
|
-
}`.trim();
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Emitted into `sw.js` only when the `backgroundSync` capability is on. The handler posts
|
|
67
|
-
* to realtime's flush endpoint; a non-2xx keeps the sync registration alive so the
|
|
68
|
-
* platform retries with its own scheduling.
|
|
69
|
-
*/
|
|
70
|
-
export function backgroundSyncSource(options: BackgroundSyncOptions = {}): string {
|
|
71
|
-
const endpoint = options.flushEndpoint ?? DEFAULT_FLUSH_ENDPOINT;
|
|
24
|
+
export function backgroundSyncSource(): string {
|
|
72
25
|
return `
|
|
73
26
|
const SYNC_TAG=${JSON.stringify(SYNC_TAG)};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const res=await fetch(FLUSH_ENDPOINT,{method:'POST',headers:{${JSON.stringify(BUILD_ID_HEADER)}:BUILD_ID}});
|
|
78
|
-
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');
|
|
79
|
-
// ||{} rather than a default inside the catch: json() on a 200 body of null RESOLVES with null,
|
|
80
|
-
// so the catch never fires and body.remaining raised inside waitUntil instead of refusing coded.
|
|
81
|
-
const body=(await res.json().catch(()=>null))||{};
|
|
82
|
-
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 # run the role that drains the outbox; the browser reschedules this sync on its own');
|
|
27
|
+
async function drainOutbox(){
|
|
28
|
+
const open=await self.clients.matchAll({type:'window',includeUncontrolled:true});
|
|
29
|
+
for(const c of open)c.postMessage({type:${JSON.stringify(OUTBOX_DRAIN_MESSAGE)}});
|
|
83
30
|
}
|
|
84
31
|
self.addEventListener('sync',(event)=>{
|
|
85
32
|
if(event.tag!==SYNC_TAG)return;
|
|
86
|
-
|
|
87
|
-
event.waitUntil(flushOutbox());
|
|
33
|
+
event.waitUntil(drainOutbox());
|
|
88
34
|
});`.trim();
|
|
89
35
|
}
|
|
90
36
|
|
|
91
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Client-side registration. Where Background Sync is missing (Safari, Firefox) an `online`
|
|
39
|
+
* listener asks the worker instead, which answers every open tab — not only the one that noticed.
|
|
40
|
+
*/
|
|
92
41
|
export function registerBackgroundSyncSource(): string {
|
|
93
42
|
return `
|
|
94
43
|
export async function registerOutboxSync(registration){
|
package/src/capabilities.ts
CHANGED
|
@@ -55,12 +55,12 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
|
|
|
55
55
|
/**
|
|
56
56
|
* The service-worker code each capability emits — its listener, and anything that listener alone
|
|
57
57
|
* needs. Checked in BOTH directions (`service-worker.test.ts`): every marker is in the emitted
|
|
58
|
-
* worker when its capability is on, and none of them is when they are all off. `
|
|
59
|
-
* the background-sync handler's
|
|
58
|
+
* worker when its capability is on, and none of them is when they are all off. `drainOutbox` is
|
|
59
|
+
* the background-sync handler's broadcast, so it ships with the handler and never without it.
|
|
60
60
|
*
|
|
61
61
|
* `flush-outbox` is the same kind of pair the other way round: the branch lives in the shared
|
|
62
62
|
* message handler, not in the capability's own block, so only its presence in this table keeps it
|
|
63
|
-
* gated with the `
|
|
63
|
+
* gated with the `drainOutbox` it calls.
|
|
64
64
|
*
|
|
65
65
|
* An EMPTY list is a claim too, and the true one for the three manifest-only capabilities: a share
|
|
66
66
|
* target, a file handler and a protocol handler are all delivered by the OS to a URL the app
|
|
@@ -69,7 +69,11 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
|
|
|
69
69
|
*/
|
|
70
70
|
export const CAPABILITY_SW_MARKERS = Object.freeze<Record<Capability, readonly string[]>>({
|
|
71
71
|
push: ["addEventListener('push'", "addEventListener('notificationclick'"],
|
|
72
|
-
backgroundSync: [
|
|
72
|
+
backgroundSync: [
|
|
73
|
+
"addEventListener('sync'",
|
|
74
|
+
'async function drainOutbox',
|
|
75
|
+
"d.type==='flush-outbox'",
|
|
76
|
+
],
|
|
73
77
|
badging: ['navigator.setAppBadge'],
|
|
74
78
|
shareTarget: [],
|
|
75
79
|
fileHandlers: [],
|
package/src/errors.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const PWA_OWNED_ERROR_CODES = [
|
|
|
13
13
|
'X_BUILD_ID_MISSING',
|
|
14
14
|
'X_SW_SCOPE_INVALID',
|
|
15
15
|
'X_PWA_STRATEGY_EXHAUSTED',
|
|
16
|
+
// Shipped, so stable forever — and thrown by nothing since 21.0.0, when the worker stopped
|
|
17
|
+
// POSTing to `/_x/outbox/flush` (`background-sync.ts`). Registered so an old log still explains.
|
|
16
18
|
'X_PWA_SYNC_FLUSH_FAILED',
|
|
17
19
|
'X_PWA_SYNC_INCOMPLETE',
|
|
18
20
|
] as const;
|
|
@@ -129,36 +131,6 @@ export class PwaStrategyExhaustedError extends UltimateError {
|
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
/**
|
|
133
|
-
* Titles one of the two failures `backgroundSyncSource()` emits into `sw.js`, and owns the `code`
|
|
134
|
-
* the emitted source throws. The generated code runs in the browser's service-worker realm, which
|
|
135
|
-
* has no bundler and no `@ultimat3/core` to import — so it defines a local `PwaSyncError` carrying
|
|
136
|
-
* this same code, cause, fix and docs rather than constructing this class. This class is what gives
|
|
137
|
-
* the code one title and one wiki row, the same as every other code in this file.
|
|
138
|
-
*/
|
|
139
|
-
export class PwaSyncFlushFailedError extends UltimateError {
|
|
140
|
-
static readonly code = 'X_PWA_SYNC_FLUSH_FAILED' as const;
|
|
141
|
-
constructor(cause: string, fix: string) {
|
|
142
|
-
super({
|
|
143
|
-
code: PwaSyncFlushFailedError.code,
|
|
144
|
-
cause,
|
|
145
|
-
fix,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Documented for the same reason as {@link PwaSyncFlushFailedError} — see its comment. */
|
|
151
|
-
export class PwaSyncIncompleteError extends UltimateError {
|
|
152
|
-
static readonly code = 'X_PWA_SYNC_INCOMPLETE' as const;
|
|
153
|
-
constructor(cause: string, fix: string) {
|
|
154
|
-
super({
|
|
155
|
-
code: PwaSyncIncompleteError.code,
|
|
156
|
-
cause,
|
|
157
|
-
fix,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
134
|
/** A driver whose interface exists and whose backing implementation does not, yet. */
|
|
163
135
|
export class NotImplementedError extends UltimateError {
|
|
164
136
|
static readonly code = 'X_NOT_IMPLEMENTED' as const;
|
package/src/index.ts
CHANGED
|
@@ -9,10 +9,8 @@ export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
|
9
9
|
// Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
|
|
10
10
|
// worker's size report is what a caller of this package prints.
|
|
11
11
|
export { formatBytes } from '@ultimat3/core';
|
|
12
|
-
export type { BackgroundSyncOptions } from './background-sync';
|
|
13
12
|
export {
|
|
14
13
|
backgroundSyncSource,
|
|
15
|
-
DEFAULT_FLUSH_ENDPOINT,
|
|
16
14
|
registerBackgroundSyncSource,
|
|
17
15
|
SYNC_TAG,
|
|
18
16
|
} from './background-sync';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emitted worker's page storage, as source: the facade every strategy opens the pages cache
|
|
3
|
+
* through, the per-principal partitions a private document is kept in, the background copy, and
|
|
4
|
+
* the offline read. Its own module because `service-worker.ts` is the rest of `sw.js`; it is
|
|
5
|
+
* spliced into the fetch block and reads that block's constants (`PAGES`, `SCOPE_HEADER`) and
|
|
6
|
+
* `warming` from the activate block. `service-worker-pages.test.ts` runs it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const PAGES_CACHE_SOURCE = `// The pages cache is a FACADE, because a document rendered for one member is not a page: keyed by
|
|
10
|
+
// URL alone, /feed rendered for kenji answered bruno on the same browser. A shareable document is
|
|
11
|
+
// stored and read as before. A private one (\`private\`/\`no-store\`, or carrying the scope header)
|
|
12
|
+
// is never read from here — so no strategy can answer it from cache while online — and is kept
|
|
13
|
+
// only in its principal's own partition, PAGES~<scope>, which offline alone reads. Storing one
|
|
14
|
+
// principal's page wipes every other partition: offline answers the most recent member only.
|
|
15
|
+
function openCache(cn){return cn===PAGES?pagesCache():caches.open(cn)}
|
|
16
|
+
// A cache copy the strategy does NOT await: handed to the event's waitUntil when there is one, and
|
|
17
|
+
// never allowed to reject into nobody — a failed copy costs the copy, not the response.
|
|
18
|
+
function later(wait,p){const settled=p.catch(()=>{});if(wait)wait(settled);return settled}
|
|
19
|
+
function isPrivate(r){
|
|
20
|
+
if(r.headers.has(SCOPE_HEADER))return true;
|
|
21
|
+
return /(^|,)\\s*(private|no-store)\\s*(,|$)/i.test(r.headers.get('cache-control')||'')
|
|
22
|
+
}
|
|
23
|
+
function pagesCache(){return{
|
|
24
|
+
match:async(req)=>(await caches.open(PAGES)).match(req),
|
|
25
|
+
put:async(req,r)=>{
|
|
26
|
+
if(!isPrivate(r))return (await caches.open(PAGES)).put(req,r);
|
|
27
|
+
const scope=r.headers.get(SCOPE_HEADER);
|
|
28
|
+
// Private with no scope: no principal to file it under, so it is not kept at all.
|
|
29
|
+
if(scope===null)return;
|
|
30
|
+
// NOT awaited by the strategy: a private document is usually a stream, and a strategy awaits
|
|
31
|
+
// its put before answering — the navigation would wait for the whole body to be copied into
|
|
32
|
+
// the cache. The offline read waits for it instead (pageOffline), which is the only reader.
|
|
33
|
+
keeping=keeping.then(()=>keepScoped(req,r,PAGES+'~'+scope)).catch(()=>{});
|
|
34
|
+
}
|
|
35
|
+
}}
|
|
36
|
+
let keeping=Promise.resolve();
|
|
37
|
+
async function keepScoped(req,r,name){
|
|
38
|
+
await (await caches.open(name)).put(req,r);
|
|
39
|
+
const names=await caches.keys();
|
|
40
|
+
await Promise.all(names.filter((n)=>n.startsWith(PAGES+'~')&&n!==name).map((n)=>caches.delete(n)));
|
|
41
|
+
}
|
|
42
|
+
/** Offline only: the most recent member's own copy, then the offline document. */
|
|
43
|
+
async function pageOffline(req){
|
|
44
|
+
await warming;
|
|
45
|
+
await keeping;
|
|
46
|
+
// The shared copy first — a warm-up may have landed it after the strategy's own read missed.
|
|
47
|
+
const shared=await (await caches.open(PAGES)).match(req);
|
|
48
|
+
if(shared)return shared;
|
|
49
|
+
for(const n of await caches.keys()){
|
|
50
|
+
if(!n.startsWith(PAGES+'~'))continue;
|
|
51
|
+
const hit=await (await caches.open(n)).match(req);
|
|
52
|
+
if(hit)return hit;
|
|
53
|
+
}
|
|
54
|
+
return offlineFallback(req)
|
|
55
|
+
}
|
|
56
|
+
function fallbackFor(rule,req){return rule.c==='pages'?()=>pageOffline(req):()=>offlineFallback(req)}`;
|
package/src/service-worker.ts
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
* update check does not fire on a no-op deploy.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
11
|
+
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
12
12
|
import { backgroundSyncSource } from './background-sync';
|
|
13
13
|
import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
|
|
14
14
|
import { isEnabled, resolveCapabilities } from './capabilities';
|
|
15
15
|
import { SwScopeInvalidError } from './errors';
|
|
16
16
|
import type { OfflineConfig } from './offline-fallback';
|
|
17
17
|
import { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
|
|
18
|
+
import { PAGES_CACHE_SOURCE } from './pages-cache-source';
|
|
18
19
|
import type { PrecacheAsset, PrecacheManifest } from './precache';
|
|
19
20
|
import { buildPrecacheManifest, serializePrecacheManifest } from './precache';
|
|
20
21
|
import type { VapidConfig } from './push';
|
|
@@ -53,7 +54,6 @@ export interface ServiceWorkerConfig {
|
|
|
53
54
|
readonly offlineFallbackRevision?: string;
|
|
54
55
|
readonly offlineFallbackBytes?: number;
|
|
55
56
|
readonly vapid?: VapidConfig;
|
|
56
|
-
readonly backgroundSync?: BackgroundSyncOptions;
|
|
57
57
|
/** Build ids whose caches must survive this activation (see `retentionPlan`). */
|
|
58
58
|
readonly retainBuildIds?: readonly string[];
|
|
59
59
|
readonly precacheWarnBytes?: number;
|
|
@@ -236,7 +236,7 @@ export function generateServiceWorker(
|
|
|
236
236
|
blocks.push(pushSource({ badging: isEnabled(capabilities, 'badging') }));
|
|
237
237
|
}
|
|
238
238
|
if (isEnabled(capabilities, 'backgroundSync')) {
|
|
239
|
-
blocks.push(backgroundSyncSource(
|
|
239
|
+
blocks.push(backgroundSyncSource());
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
return {
|
|
@@ -275,6 +275,7 @@ const PAGES=${JSON.stringify(cacheNamespace(buildId, 'pages'))};
|
|
|
275
275
|
const RETAINED=${JSON.stringify(retainedCaches(retained))};
|
|
276
276
|
const NEVER_CACHE=${JSON.stringify(neverCache)};
|
|
277
277
|
const BUILD_HEADER=${JSON.stringify(BUILD_ID_HEADER)};
|
|
278
|
+
const SCOPE_HEADER=${JSON.stringify(CLIENT_SCOPE_HEADER)};
|
|
278
279
|
let SKEWED=false;`.trim();
|
|
279
280
|
}
|
|
280
281
|
|
|
@@ -335,8 +336,32 @@ self.addEventListener('activate',(event)=>{
|
|
|
335
336
|
await self.clients.claim();
|
|
336
337
|
const cs=await self.clients.matchAll({type:'window'});
|
|
337
338
|
for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:BUILD_ID});
|
|
339
|
+
// NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
|
|
340
|
+
// warm-up that copies a streamed page's whole body in here held every request of the tab it
|
|
341
|
+
// just claimed — /feed's own reads included — until that body had ended. Kicked off, never
|
|
342
|
+
// awaited; the one reader that needs it done, the offline page read, waits for it.
|
|
343
|
+
warming=Promise.all(cs.map((c)=>warm(c.url))).catch(()=>{});
|
|
338
344
|
})());
|
|
339
|
-
})
|
|
345
|
+
});
|
|
346
|
+
let warming=Promise.resolve();
|
|
347
|
+
// The page that installed this worker loaded BEFORE the worker controlled it, so no strategy ever
|
|
348
|
+
// saw it and a runtime-cached route was unavailable offline until a second online visit. Now that
|
|
349
|
+
// the worker controls the window, its URL is run through its own route's strategy — the same rule
|
|
350
|
+
// that caches every later visit, never a second one. Best effort: a failure costs the warm-up only.
|
|
351
|
+
async function warm(href){
|
|
352
|
+
if(typeof href!=='string')return;
|
|
353
|
+
const url=new URL(href);
|
|
354
|
+
if(url.origin!==self.location.origin||NEVER_CACHE.some((p)=>url.pathname.startsWith(p)))return;
|
|
355
|
+
const rule=ruleFor(url);
|
|
356
|
+
if(!rule||rule.c!=='pages')return;
|
|
357
|
+
const fn=STRATEGIES[rule.s];
|
|
358
|
+
if(!fn)return;
|
|
359
|
+
const req=new Request(url.href,{headers:withBuild(new Headers())});
|
|
360
|
+
const copies=[];
|
|
361
|
+
// The plain offline document, never pageOffline: that one waits for THIS warm-up to finish.
|
|
362
|
+
try{await fn(req,cacheName(rule.c),()=>offlineFallback(req),(p)=>copies.push(p))}catch(e){}
|
|
363
|
+
await Promise.all(copies);
|
|
364
|
+
}`.trim();
|
|
340
365
|
}
|
|
341
366
|
|
|
342
367
|
/**
|
|
@@ -370,6 +395,7 @@ function ruleFor(url){
|
|
|
370
395
|
return null
|
|
371
396
|
}
|
|
372
397
|
function cacheName(kind){return kind==='precache'?PRECACHE:kind==='pages'?PAGES:RUNTIME}
|
|
398
|
+
${PAGES_CACHE_SOURCE}
|
|
373
399
|
const STRATEGIES={cacheFirst:typeof cacheFirst==='function'?cacheFirst:null,
|
|
374
400
|
networkFirst:typeof networkFirst==='function'?networkFirst:null,
|
|
375
401
|
staleWhileRevalidate:typeof staleWhileRevalidate==='function'?staleWhileRevalidate:null,
|
|
@@ -386,7 +412,7 @@ self.addEventListener('fetch',(event)=>{
|
|
|
386
412
|
if(!fn)return;
|
|
387
413
|
// Every proxied request carries the client's build id so the server can detect skew.
|
|
388
414
|
const tagged=new Request(req,{headers:withBuild(req.headers)});
|
|
389
|
-
event.respondWith(fn(tagged,cacheName(rule.c),()=>
|
|
415
|
+
event.respondWith(fn(tagged,cacheName(rule.c),fallbackFor(rule,req),(p)=>event.waitUntil(p)).then((res)=>healSkew(req,res)));
|
|
390
416
|
});
|
|
391
417
|
function withBuild(headers){
|
|
392
418
|
const h=new Headers(headers);
|
|
@@ -415,15 +441,16 @@ async function healSkew(req,res){
|
|
|
415
441
|
* the page is what sends it: `registerOutboxSync` falls back to an `online` listener posting this
|
|
416
442
|
* message wherever `registration.sync` is absent — Safari and Firefox — and with no branch for it
|
|
417
443
|
* the offline mutation queue was never drained on exactly the browsers the fallback exists for.
|
|
418
|
-
* Silent, too: no rejection, no request, no log.
|
|
444
|
+
* Silent, too: no rejection, no request, no log. The worker answers it by telling EVERY open tab
|
|
445
|
+
* to drain (`drainOutbox`), because the outbox lives in the pages, not here.
|
|
419
446
|
*
|
|
420
|
-
* Gated on the capability, because `
|
|
447
|
+
* Gated on the capability, because `drainOutbox` is only emitted with `backgroundSyncSource`. An
|
|
421
448
|
* unconditional branch would answer the message with a `ReferenceError` inside `waitUntil`, which
|
|
422
449
|
* the page that sent it cannot catch, in every app that leaves `backgroundSync` off.
|
|
423
450
|
*/
|
|
424
451
|
function messageBlock(backgroundSync: boolean): string {
|
|
425
452
|
const flush = backgroundSync
|
|
426
|
-
? "\n if(d.type==='flush-outbox')event.waitUntil(
|
|
453
|
+
? "\n if(d.type==='flush-outbox')event.waitUntil(drainOutbox());"
|
|
427
454
|
: '';
|
|
428
455
|
return `
|
|
429
456
|
self.addEventListener('message',(event)=>{
|
package/src/strategies.ts
CHANGED
|
@@ -194,26 +194,31 @@ async function fallbackOrThrow(options: StrategyOptions): Promise<Response> {
|
|
|
194
194
|
/**
|
|
195
195
|
* The emitted counterpart of the functions above. Kept as source strings because the
|
|
196
196
|
* service worker is a generated artifact with no bundler in the loop — the shapes are
|
|
197
|
-
* identical on purpose and `strategies.test.ts` asserts both halves stay in step.
|
|
197
|
+
* identical on purpose and `strategies.test.ts` asserts both halves stay in step. They open a
|
|
198
|
+
* cache through the worker's `openCache`, never `caches.open`: the pages cache is a facade that
|
|
199
|
+
* partitions a per-member document by principal (`service-worker.ts`, `pagesCache`). And the cache
|
|
200
|
+
* copy is NEVER awaited before answering: `Cache.put` reads the whole body, so awaiting it held a
|
|
201
|
+
* streamed document away from the tab until it had ended. It goes to `later(wait, …)` instead —
|
|
202
|
+
* the fetch event's `waitUntil`, which keeps the worker alive for the copy.
|
|
198
203
|
*/
|
|
199
204
|
export const STRATEGY_SOURCE = Object.freeze<Record<StrategyName, string>>({
|
|
200
|
-
'cache-first': `async function cacheFirst(req,cn,fb){
|
|
201
|
-
const c=await
|
|
202
|
-
try{const r=await fetch(req);if(r.ok)
|
|
205
|
+
'cache-first': `async function cacheFirst(req,cn,fb,wait){
|
|
206
|
+
const c=await openCache(cn);const hit=await c.match(req);if(hit)return hit;
|
|
207
|
+
try{const r=await fetch(req);if(r.ok)later(wait,c.put(req,r.clone()));return r}catch(e){if(fb)return fb();throw e}
|
|
203
208
|
}`,
|
|
204
|
-
'network-first': `async function networkFirst(req,cn,fb){
|
|
205
|
-
const c=await
|
|
206
|
-
try{const r=await fetch(req);if(r.ok)
|
|
209
|
+
'network-first': `async function networkFirst(req,cn,fb,wait){
|
|
210
|
+
const c=await openCache(cn);
|
|
211
|
+
try{const r=await fetch(req);if(r.ok)later(wait,c.put(req,r.clone()));return r}
|
|
207
212
|
catch(e){const hit=await c.match(req);if(hit)return hit;if(fb)return fb();throw e}
|
|
208
213
|
}`,
|
|
209
|
-
'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb){
|
|
210
|
-
const c=await
|
|
211
|
-
const refresh=fetch(req).then(
|
|
214
|
+
'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb,wait){
|
|
215
|
+
const c=await openCache(cn);const hit=await c.match(req);
|
|
216
|
+
const refresh=fetch(req).then((r)=>{if(r.ok)later(wait,c.put(req,r.clone()));return r})
|
|
212
217
|
.catch(()=>hit||(fb?fb():Response.error()));
|
|
213
218
|
if(hit){refresh.catch(()=>{});return hit}
|
|
214
219
|
return refresh
|
|
215
220
|
}`,
|
|
216
|
-
'network-only': `async function networkOnly(req,cn,fb){
|
|
221
|
+
'network-only': `async function networkOnly(req,cn,fb,wait){
|
|
217
222
|
try{return await fetch(req)}catch(e){if(fb)return fb();throw e}
|
|
218
223
|
}`,
|
|
219
224
|
});
|
package/src/version-skew.ts
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
* The app decides what to do with that message. This package never navigates a client.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { finiteCount } from '@ultimat3/core';
|
|
13
|
+
import { APP_UPDATE_MESSAGE, CLIENT_BUILD_META, finiteCount } from '@ultimat3/core';
|
|
14
14
|
import { BuildIdMissingError } from './errors';
|
|
15
15
|
|
|
16
16
|
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
17
|
-
|
|
17
|
+
/** Shipped name for core's `CLIENT_BUILD_META` — the meta render writes, one literal for both. */
|
|
18
|
+
export const BUILD_ID_META = CLIENT_BUILD_META;
|
|
18
19
|
|
|
19
20
|
export type DeployChannel = 'production' | 'preview' | 'branch';
|
|
20
21
|
|
|
@@ -136,9 +137,10 @@ export function detectSkew(
|
|
|
136
137
|
* the literal the worker emits, so the two can no longer differ.
|
|
137
138
|
*/
|
|
138
139
|
export interface AppUpdateAvailable {
|
|
139
|
-
readonly type:
|
|
140
|
+
readonly type: typeof APP_UPDATE_MESSAGE;
|
|
140
141
|
/** The build the worker that posted this was generated for. */
|
|
141
142
|
readonly to: string;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
/** Shipped name for core's `APP_UPDATE_MESSAGE` — one literal for the worker and the page. */
|
|
146
|
+
export const APP_UPDATE_AVAILABLE = APP_UPDATE_MESSAGE;
|