@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 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
- | Outbox drain | TWO doors, and the second was missing for the framework's whole history: the `sync` event, and a `{type:'flush-outbox'}` message. `registerBackgroundSyncSource`'s fallback posts that message wherever `registration.sync` is absent — **Safari and Firefox** — and `messageBlock` answered only `skip-waiting` and `build-id`, so on exactly the browsers the fallback exists for an offline mutation queue was never drained: no rejection, no request, no log. The branch lives in the shared message handler rather than in `backgroundSyncSource`, so `CAPABILITY_SW_MARKERS.backgroundSync` carrying `d.type==='flush-outbox'` is what keeps it gated with the `flushOutbox` it calls — an ungated branch is a `ReferenceError` inside `waitUntil` in every app with the capability off. `service-worker-runtime.test.ts` posts the message and asserts the POST. |
36
- | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger — and schedules **no retry**: the handler rejects and the PLATFORM decides when to wake it again. `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs`, `shouldRetry` and `BackgroundSyncOptions.retry` were deleted 2026-08-23; of the policy only `maxAttempts` ever reached the worker, as a `SYNC_MAX_ATTEMPTS` constant nothing read, and the emitted `X_PWA_SYNC_INCOMPLETE` fix told the reader to raise `pwa.backgroundSync.retry.maxAttempts`, a key `PwaConfig` has never had. `background-sync.test.ts` asserts that every constant the worker declares is one the worker reads. |
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 — the generated source defines its own class (`SYNC_ERROR_CLASS`) carrying `code`, `cause`, `fix`, `docs`. Never emit a bare `throw new Error`; the code stays owned by `errors.ts`. |
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 + outbox flush |
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 asks realtime to flush.
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": "20.2.0",
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": "20.2.0",
35
- "@ultimat3/seo": "20.2.0"
35
+ "@ultimat3/core": "21.0.0",
36
+ "@ultimat3/seo": "21.0.0"
36
37
  }
37
38
  }
@@ -1,94 +1,43 @@
1
1
  /**
2
- * Background Sync registration for the offline mutation queue.
2
+ * Background Sync: the browser-side TRIGGER for draining the offline outbox, and nothing else.
3
3
  *
4
- * SRP: the queue itself — the outbox, the optimistic local twins, the conflict policy —
5
- * belongs to `@ultimat3/realtime`. This file owns only the browser-side trigger: register
6
- * a sync tag, and when the platform says connectivity is back, ask realtime to flush.
7
- * Nothing here knows what a mutation is, and it must stay that way.
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
- * The two failures below (`X_PWA_SYNC_FLUSH_FAILED`, `X_PWA_SYNC_INCOMPLETE`, documented in
10
- * `./errors.ts`) run inside the string emitted into `sw.js` — the browser's service-worker
11
- * realm, which has no bundler and cannot import `@ultimat3/core`. What it *can* have is a class
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 { ERROR_DOCS_URL } from '@ultimat3/core';
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
- * `retry` was removed 2026-08-23, with `RetryPolicy`, `DEFAULT_RETRY`, `retryDelayMs` and
25
- * `shouldRetry`: **this package schedules no retry and never did.** The one-shot `sync` handler
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 interface BackgroundSyncOptions {
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
- const FLUSH_ENDPOINT=${JSON.stringify(endpoint)};
75
- ${SYNC_ERROR_CLASS}
76
- async function flushOutbox(){
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
- // Rejecting keeps the registration so the platform retries on its own schedule.
87
- event.waitUntil(flushOutbox());
33
+ event.waitUntil(drainOutbox());
88
34
  });`.trim();
89
35
  }
90
36
 
91
- /** Client-side registration. Falls back to an `online` listener where sync is missing. */
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){
@@ -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. `PwaSyncError` is
59
- * the background-sync handler's own error class, so it ships with the handler and never without it.
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 `flushOutbox` it calls.
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: ["addEventListener('sync'", 'class PwaSyncError', "d.type==='flush-outbox'"],
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)}`;
@@ -8,13 +8,14 @@
8
8
  * update check does not fire on a no-op deploy.
9
9
  */
10
10
 
11
- import type { BackgroundSyncOptions } from './background-sync';
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(config.backgroundSync ?? {}));
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
- });`.trim();
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),()=>offlineFallback(req)).then((res)=>healSkew(req,res)));
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 `flushOutbox` is only emitted with `backgroundSyncSource`. An
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(flushOutbox());"
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 caches.open(cn);const hit=await c.match(req);if(hit)return hit;
202
- try{const r=await fetch(req);if(r.ok)await c.put(req,r.clone());return r}catch(e){if(fb)return fb();throw e}
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 caches.open(cn);
206
- try{const r=await fetch(req);if(r.ok)await c.put(req,r.clone());return r}
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 caches.open(cn);const hit=await c.match(req);
211
- const refresh=fetch(req).then(async(r)=>{if(r.ok)await c.put(req,r.clone());return r})
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
  });
@@ -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
- export const BUILD_ID_META = 'x-ultimate-build';
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: 'AppUpdateAvailable';
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
- export const APP_UPDATE_AVAILABLE = 'AppUpdateAvailable' as const;
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;