@ultimat3/pwa 19.1.3 → 19.3.1
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 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/capabilities.ts +5 -1
- package/src/manifest.ts +14 -1
- package/src/offline-fallback.ts +25 -3
- package/src/precache.ts +20 -2
- package/src/push.ts +32 -4
- package/src/service-worker.ts +39 -3
- package/src/strategies.ts +12 -2
package/CLAUDE.md
CHANGED
|
@@ -17,6 +17,8 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
17
17
|
| Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
|
|
18
18
|
| Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. |
|
|
19
19
|
| Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
|
|
20
|
+
| A truncation of author text | CODE POINTS, never `slice`. `short_name` falls back to the first 12 of `name`, and a UTF-16 `slice` cuts inside a surrogate pair — the lone half encodes as U+FFFD, so an app named with an emoji got a home-screen label ending in a replacement character. `Array.from(name).slice(0, n).join('')` is the form. |
|
|
21
|
+
| Declared and never wired | `ServiceWorkerConfig.shellUrl` / `shellRevision` / `shellBytes` and `PwaRoute.dataUrl`. No caller in the framework's build path sets any of them, so `precache.ts`'s `reason: 'shell'` and `reason: 'route-data'` branches are reachable only from a hand-built `generateServiceWorker` call. Each doc comment now says so; the shell trio's said "precached for every `spa` route" while `spa` was already deleted from `RENDER_MODES`. **Kept, not deleted** — a public field is a major — and carried as candidates for the next major's declared-and-never-wired half. `revision` and `bytes` are the pair that IS fed, by the CLI's prerender pass. |
|
|
20
22
|
| Every byte count and every threshold | a whole number of 0 or more, screened with `finiteCount` where it is READ — `buildPrecacheManifest`'s `warnBytes` AND each entry's own `bytes` (shell, route, asset), `createInstallController`'s `minEngagementMs`, `retentionPlan`'s `keep`. `totalBytes > warnBytes` is false when EITHER side is `NaN`, so one asset whose byte count did not arrive as a number took down the install-size warning for every other entry, silently, in the function whose output is otherwise byte-identical per commit; `now() - startedAt < NaN` is false at every instant, so the install prompt fired on first paint; and `Math.max(1, NaN)` is `NaN` with `slice(0, NaN)` `[]`, so a retention plan evicted every deploy including the running one. `Math.max` is not a validator — it propagates. `keep: 0` still means 1 (`version-skew.test.ts` pins it), so the floor is 0 everywhere here. `bun run finite-bounds` is the ratchet. |
|
|
21
23
|
| 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. |
|
|
22
24
|
| 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. |
|
|
@@ -25,11 +27,15 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
25
27
|
| A byte count in a warning | `formatBytes` from `@ultimat3/core`, never a local one. The copy that lived in `precache.ts` stopped at `mb`, so a precache past a gigabyte reported a four-digit `mb` — and `@ultimat3/render`'s copy stopped at `kb`, so the two halves of one build printed different units for the same bytes. Still on this barrel: a size report is what a caller of this package prints. |
|
|
26
28
|
| Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
|
|
27
29
|
| Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
|
|
30
|
+
| Its `fix:` names a route the framework accepts | `x g route offline --surface site # then set pwa.offline.fallback to '/offline' in app.config.ts`. It said `create app/offline.tsx and set offline.fallback`, and BOTH halves were wrong: `<name>.tsx` is not a route file — `registerRoute` refuses it with `X_ROUTE_FILE_INVALID`, because the directory is the URL — and `offline.fallback` names no key `app.config.ts` has. A reader who followed it built a component nothing imports, left `/offline` a URL that does not exist, and met the same refusal on the next build; `wiki/Upgrading.md` had already recorded that path as the wrong one. `--surface site`, not `app`: the document answering a lost network must render with no network, no session and no database, which `app/` (`ssr \| stream`) cannot promise. `@ultimat3/cli`'s `doctor-offline.ts` writes the same line for the same code. |
|
|
31
|
+
| The offline document's revision | `ServiceWorkerConfig.offlineFallbackRevision` / `offlineFallbackBytes`, forwarded to `buildPrecacheManifest`. `ServiceWorkerConfig` had no field for either, so the one page an offline navigation depends on was the single precache entry stamped with the **build id** — re-downloaded on every deploy, which the `Precache revision` row above forbids for everything else — and counted as **0 bytes** against `warnBytes`, under-counting the install by exactly the size of the page that has to survive a lost network. Unlike the shell trio, this pair is meant to be fed: the CLI's prerender pass holds both values. |
|
|
28
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. |
|
|
29
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. |
|
|
30
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. |
|
|
31
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. |
|
|
32
37
|
| Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
|
|
38
|
+
| `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. |
|
|
33
39
|
| 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. |
|
|
34
40
|
| Colours | `@ultimat3/core`'s `PwaColors`, passed in as `WebManifestInput.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. This package declared `ThemeTokens`/`SchemeColors` itself until 2026-08-27: structurally identical to what `app.config.ts` already carried, with no map between them and nothing asserting they agreed, which is the axiom-1 defect the two `PwaConfig`s were. Deleted; tier 0 owns the shape. |
|
|
35
41
|
| `WebManifestInput` | what `generateWebManifest` takes, and **not** the `pwa` block of `app.config.ts` — it was called `PwaConfig` and said it was, while `@ultimat3/core` exported a different type of the same name that really is the block. An app declares `name` and `colors`; every other member is a caller's (`icons` from `planIcons`, `capabilities` from the build). `packages/cli/src/pwa-artifacts.ts` is the one composer of the two, and the only caller of `generateWebManifest` in the tree. |
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ detectSkew(clientBuildId, message.to); // 'current' | 'stale' | 'unknown'
|
|
|
55
55
|
```
|
|
56
56
|
X_PWA_NO_OFFLINE_FALLBACK: no offline fallback route
|
|
57
57
|
cause: app.config.ts has no `offline` block, so an offline navigation would show the browser's error page
|
|
58
|
-
fix:
|
|
58
|
+
fix: x g route offline --surface site # then set pwa.offline.fallback to '/offline' in app.config.ts
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
`requireOfflineFallback(config)` runs inside `generateServiceWorker`, so the build fails
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/pwa",
|
|
3
|
-
"version": "19.1
|
|
3
|
+
"version": "19.3.1",
|
|
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": "19.1
|
|
35
|
-
"@ultimat3/seo": "19.1
|
|
34
|
+
"@ultimat3/core": "19.3.1",
|
|
35
|
+
"@ultimat3/seo": "19.3.1"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/capabilities.ts
CHANGED
|
@@ -58,6 +58,10 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
|
|
|
58
58
|
* worker when its capability is on, and none of them is when they are all off. `PwaSyncError` is
|
|
59
59
|
* the background-sync handler's own error class, so it ships with the handler and never without it.
|
|
60
60
|
*
|
|
61
|
+
* `flush-outbox` is the same kind of pair the other way round: the branch lives in the shared
|
|
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.
|
|
64
|
+
*
|
|
61
65
|
* An EMPTY list is a claim too, and the true one for the three manifest-only capabilities: a share
|
|
62
66
|
* target, a file handler and a protocol handler are all delivered by the OS to a URL the app
|
|
63
67
|
* already serves, so the member is the whole feature and the worker has no branch to add.
|
|
@@ -65,7 +69,7 @@ export const CAPABILITY_MANIFEST_KEYS = Object.freeze<Record<Capability, readonl
|
|
|
65
69
|
*/
|
|
66
70
|
export const CAPABILITY_SW_MARKERS = Object.freeze<Record<Capability, readonly string[]>>({
|
|
67
71
|
push: ["addEventListener('push'", "addEventListener('notificationclick'"],
|
|
68
|
-
backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'],
|
|
72
|
+
backgroundSync: ["addEventListener('sync'", 'class PwaSyncError', "d.type==='flush-outbox'"],
|
|
69
73
|
badging: ['navigator.setAppBadge'],
|
|
70
74
|
shareTarget: [],
|
|
71
75
|
fileHandlers: [],
|
package/src/manifest.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
153
153
|
|
|
154
154
|
const manifest: WebManifest = {
|
|
155
155
|
name: config.name,
|
|
156
|
-
short_name: config.shortName ?? config.name
|
|
156
|
+
short_name: config.shortName ?? shortNameFrom(config.name),
|
|
157
157
|
start_url: config.startUrl ?? scope,
|
|
158
158
|
scope,
|
|
159
159
|
display: config.display ?? 'standalone',
|
|
@@ -179,6 +179,19 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
179
179
|
|
|
180
180
|
type MutableManifest = { -readonly [K in keyof WebManifest]?: WebManifest[K] };
|
|
181
181
|
|
|
182
|
+
/** Home-screen labels are truncated by the OS well before this; the cap only bounds the string. */
|
|
183
|
+
const SHORT_NAME_MAX_CODE_POINTS = 12;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* CODE POINTS, never `slice` — `String.prototype.slice` counts UTF-16 code units, so a name whose
|
|
187
|
+
* 12th unit is the high half of a surrogate pair (any emoji, any astral script) is cut mid
|
|
188
|
+
* character, and the lone surrogate that survives is what every UTF-8 encoder replaces with U+FFFD.
|
|
189
|
+
* The install prompt then offers a label ending in `` on the one surface a `short_name` exists for.
|
|
190
|
+
*/
|
|
191
|
+
function shortNameFrom(name: string): string {
|
|
192
|
+
return Array.from(name).slice(0, SHORT_NAME_MAX_CODE_POINTS).join('');
|
|
193
|
+
}
|
|
194
|
+
|
|
182
195
|
function assertValid(config: WebManifestInput): void {
|
|
183
196
|
if (config.name.trim() === '') {
|
|
184
197
|
throw new PwaManifestInvalidError(
|
package/src/offline-fallback.ts
CHANGED
|
@@ -23,11 +23,33 @@ export interface OfflineFallback {
|
|
|
23
23
|
readonly neverCache: readonly string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The edit, then the command — TWO actions, and neither is optional. It was `create
|
|
28
|
+
* app/offline.tsx and set offline.fallback`, and both halves were wrong: `<name>.tsx` is not a
|
|
29
|
+
* route file at all — `registerRoute` refuses it with `X_ROUTE_FILE_INVALID` because the
|
|
30
|
+
* directory is the URL — and `offline.fallback` names no key `app.config.ts` has. So a reader who
|
|
31
|
+
* followed this line literally created a component nothing imports, left `/offline` a URL that
|
|
32
|
+
* does not exist, and met the same refusal on the next build. `wiki/Upgrading.md` already recorded
|
|
33
|
+
* `apps/web/app/offline.tsx` as the wrong path.
|
|
34
|
+
*
|
|
35
|
+
* The edit comes FIRST, and the command is not the head of the line: with `x g route … # then
|
|
36
|
+
* set …` the second half was a shell COMMENT, so the line read as one paste that does the whole
|
|
37
|
+
* job and did half of it — the route appeared, `pwa.offline.fallback` stayed unset, and the very
|
|
38
|
+
* next build raised this same error. A `fix:` that half-runs is worse than one that names two
|
|
39
|
+
* steps, because the reader has no signal that anything is left. The contract's own accepted
|
|
40
|
+
* shape is an edit naming its file (`set auth.signInPath = '/signin' in app.config.ts`).
|
|
41
|
+
*
|
|
42
|
+
* `--surface site`, and not `app`: the document that answers a lost network must render with no
|
|
43
|
+
* network, no session and no database, which `app/` (`ssr | stream`) cannot promise — and only a
|
|
44
|
+
* `static` route is prerendered, so an `app/` fallback has no document to precache at all. The
|
|
45
|
+
* command writes `apps/web/site/offline/page.tsx`, which is what `x new` scaffolds.
|
|
46
|
+
*/
|
|
47
|
+
const FIX =
|
|
48
|
+
"set pwa: { offline: { fallback: '/offline' } } in app.config.ts, then create the route it names: x g route offline --surface site";
|
|
27
49
|
|
|
28
50
|
/**
|
|
29
51
|
* Build-time gate, called by `generateServiceWorker` and by `x doctor`. The fix line is
|
|
30
|
-
* the
|
|
52
|
+
* the config edit plus the command that creates the route it names, not a doc link.
|
|
31
53
|
*/
|
|
32
54
|
export function requireOfflineFallback(
|
|
33
55
|
config: Partial<OfflineConfig> | undefined | null,
|
|
@@ -49,7 +71,7 @@ export function requireOfflineFallback(
|
|
|
49
71
|
if (!fallback.startsWith('/')) {
|
|
50
72
|
throw new PwaNoOfflineFallbackError(
|
|
51
73
|
`offline.fallback is ${JSON.stringify(fallback)}, which is not an absolute route path`,
|
|
52
|
-
`set offline.fallback to '/${fallback.replace(/^\/+/, '')}'`,
|
|
74
|
+
`set pwa.offline.fallback to '/${fallback.replace(/^\/+/, '')}' in app.config.ts`,
|
|
53
75
|
);
|
|
54
76
|
}
|
|
55
77
|
|
package/src/precache.ts
CHANGED
|
@@ -34,13 +34,31 @@ export interface PrecacheInput {
|
|
|
34
34
|
readonly buildId: string;
|
|
35
35
|
readonly routes: readonly PwaRoute[];
|
|
36
36
|
readonly assets?: readonly PrecacheAsset[];
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* A single document precached for every route, whatever its render mode — an app shell.
|
|
39
|
+
*
|
|
40
|
+
* **Nothing in the framework's build path sets this trio.** It said "precached for every `spa`
|
|
41
|
+
* route" until `As of 2026-09`, and `spa` was deleted from `RENDER_MODES` — so the sentence
|
|
42
|
+
* named a mode that cannot be declared, over a branch no caller reaches. Today the only way to
|
|
43
|
+
* a `reason: 'shell'` entry is a hand-built `generateServiceWorker` call: `x build`'s composer
|
|
44
|
+
* (`packages/cli/src/sw-artifacts.ts`) passes routes and assets and never a shell. Kept because
|
|
45
|
+
* deleting a public field is a major; a candidate for the next one's declared-and-never-wired
|
|
46
|
+
* sweep, with `PwaRoute.dataUrl`.
|
|
47
|
+
*/
|
|
38
48
|
readonly shellUrl?: string;
|
|
49
|
+
/** Defaults to `buildId`, which re-downloads the shell on every deploy. */
|
|
39
50
|
readonly shellRevision?: string;
|
|
40
51
|
readonly shellBytes?: number;
|
|
41
52
|
/** The mandatory offline document. */
|
|
42
53
|
readonly offlineFallbackUrl?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Content hash of the built offline document. Absent, the entry is stamped with `buildId` and
|
|
56
|
+
* every deploy re-downloads the one page an offline navigation depends on — the rule this file's
|
|
57
|
+
* header states for every other entry, which this one had no field to obey.
|
|
58
|
+
*/
|
|
43
59
|
readonly offlineFallbackRevision?: string;
|
|
60
|
+
/** Byte size of the same document. Absent, it counts as 0 against `warnBytes`. */
|
|
61
|
+
readonly offlineFallbackBytes?: number;
|
|
44
62
|
/** Warn past this total. Default 5 MB: past that, install stalls on a bad connection. */
|
|
45
63
|
readonly warnBytes?: number;
|
|
46
64
|
}
|
|
@@ -75,7 +93,7 @@ export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
|
|
|
75
93
|
add({
|
|
76
94
|
url: input.offlineFallbackUrl,
|
|
77
95
|
revision: input.offlineFallbackRevision ?? input.buildId,
|
|
78
|
-
bytes: 0,
|
|
96
|
+
bytes: input.offlineFallbackBytes ?? 0,
|
|
79
97
|
reason: 'fallback',
|
|
80
98
|
});
|
|
81
99
|
}
|
package/src/push.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface PushPayload {
|
|
|
66
66
|
readonly tag?: string;
|
|
67
67
|
readonly icon?: string;
|
|
68
68
|
readonly badge?: string;
|
|
69
|
+
/** Alert again when the `tag` above is replaced. Ignored, with a warning, without one. */
|
|
69
70
|
readonly renotify?: boolean;
|
|
70
71
|
readonly requireInteraction?: boolean;
|
|
71
72
|
readonly actions?: readonly { readonly action: string; readonly titleKey: string }[];
|
|
@@ -82,7 +83,11 @@ export interface RenderedNotification {
|
|
|
82
83
|
readonly requireInteraction: boolean;
|
|
83
84
|
readonly actions: readonly { readonly action: string; readonly title: string }[];
|
|
84
85
|
readonly locale: string;
|
|
85
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* What the composing server must see and the device must not: a missing-key marker (`⟦key⟧`),
|
|
88
|
+
* and a `renotify` dropped for want of a `tag`. Never serialized — `serializePushMessage` picks
|
|
89
|
+
* the wire fields by name.
|
|
90
|
+
*/
|
|
86
91
|
readonly warnings: readonly string[];
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -102,14 +107,30 @@ export function renderPushPayload(
|
|
|
102
107
|
return value;
|
|
103
108
|
};
|
|
104
109
|
|
|
110
|
+
// `renotify` means "replace the notification carrying this tag and alert again", so the spec
|
|
111
|
+
// gives it no meaning without one: `showNotification` REJECTS with a `TypeError` and the device
|
|
112
|
+
// shows nothing at all. Dropped here rather than passed on, and reported rather than dropped in
|
|
113
|
+
// silence — a flag whose only effect is that the notification disappears is the shape this
|
|
114
|
+
// repository keeps re-shipping (`jobs.driver`, `PwaConfig.installPrompt`).
|
|
115
|
+
//
|
|
116
|
+
// The test is EMPTINESS, not presence: `tag` defaults to the empty DOMString in
|
|
117
|
+
// `NotificationOptions`, so `tag: ''` is the very pair the spec rejects and `!== undefined`
|
|
118
|
+
// waved it through — the guard reintroducing the `TypeError` it exists to prevent. `''` is also
|
|
119
|
+
// what a composing server produces from an unset collapse key, which is the common case.
|
|
120
|
+
const tag = payload.tag === undefined || payload.tag === '' ? undefined : payload.tag;
|
|
121
|
+
const renotify = (payload.renotify ?? false) && tag !== undefined;
|
|
122
|
+
if (payload.renotify === true && tag === undefined) {
|
|
123
|
+
warnings.push(`renotify needs a tag to replace — dropped for ${payload.titleKey}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
return {
|
|
106
127
|
title: render(payload.titleKey),
|
|
107
128
|
body: render(payload.bodyKey),
|
|
108
129
|
url: payload.url,
|
|
109
|
-
tag:
|
|
130
|
+
tag: tag ?? null,
|
|
110
131
|
icon: payload.icon ?? null,
|
|
111
132
|
badge: payload.badge ?? null,
|
|
112
|
-
renotify
|
|
133
|
+
renotify,
|
|
113
134
|
requireInteraction: payload.requireInteraction ?? false,
|
|
114
135
|
actions: (payload.actions ?? []).map((action) => ({
|
|
115
136
|
action: action.action,
|
|
@@ -152,8 +173,15 @@ export function pushSource(options: PushSourceOptions = {}): string {
|
|
|
152
173
|
return `
|
|
153
174
|
self.addEventListener('push',(event)=>{
|
|
154
175
|
const d=event.data?event.data.json():{};
|
|
176
|
+
// renotify needs the tag: showNotification rejects with a TypeError when the flag is set and
|
|
177
|
+
// the tag is empty, and the device shows nothing. renderPushPayload refuses the pair too, but a
|
|
178
|
+
// push body is composed by whatever holds the VAPID key, so this is the last guard there is.
|
|
179
|
+
// A NON-EMPTY STRING, never truthiness: WebIDL converts every tag to a DOMString, and [] and {}
|
|
180
|
+
// are truthy in JS while converting to '' and '[object Object]'. !!d.tag therefore enabled
|
|
181
|
+
// renotify for a JSON [] whose converted tag is empty — the rejection this line exists to avoid.
|
|
182
|
+
const tg=typeof d.tag==='string'&&d.tag!==''?d.tag:undefined;
|
|
155
183
|
const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
|
|
156
|
-
tag:
|
|
184
|
+
tag:tg,renotify:!!d.renotify&&tg!==undefined,requireInteraction:!!d.requireInteraction,
|
|
157
185
|
lang:d.lang||undefined,actions:d.actions||[],data:{url:d.url||'/'}};
|
|
158
186
|
event.waitUntil(self.registration.showNotification(d.title||'',opts)${
|
|
159
187
|
badging ? '.then(()=>navigator.setAppBadge&&navigator.setAppBadge())' : ''
|
package/src/service-worker.ts
CHANGED
|
@@ -35,9 +35,23 @@ export interface ServiceWorkerConfig {
|
|
|
35
35
|
readonly offline: Partial<OfflineConfig>;
|
|
36
36
|
readonly capabilities?: CapabilityFlags;
|
|
37
37
|
readonly assets?: readonly PrecacheAsset[];
|
|
38
|
+
/**
|
|
39
|
+
* Forwarded verbatim to `buildPrecacheManifest`, which documents the whole trio: no caller in
|
|
40
|
+
* the framework's build path sets it, so `reason: 'shell'` is reachable only from a hand-built
|
|
41
|
+
* call to this function.
|
|
42
|
+
*/
|
|
38
43
|
readonly shellUrl?: string;
|
|
39
44
|
readonly shellRevision?: string;
|
|
40
45
|
readonly shellBytes?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Content hash and byte size of the built offline document, forwarded to
|
|
48
|
+
* `buildPrecacheManifest`. Absent, the entry is stamped with the build id and counted as 0
|
|
49
|
+
* bytes — so the one page an offline navigation depends on is re-downloaded on every deploy,
|
|
50
|
+
* which is what `Precache revision` forbids for every other entry. Unlike the shell trio above,
|
|
51
|
+
* this pair is meant to be fed: the CLI's prerender pass already holds both values.
|
|
52
|
+
*/
|
|
53
|
+
readonly offlineFallbackRevision?: string;
|
|
54
|
+
readonly offlineFallbackBytes?: number;
|
|
41
55
|
readonly vapid?: VapidConfig;
|
|
42
56
|
readonly backgroundSync?: BackgroundSyncOptions;
|
|
43
57
|
/** Build ids whose caches must survive this activation (see `retentionPlan`). */
|
|
@@ -191,6 +205,14 @@ export function generateServiceWorker(
|
|
|
191
205
|
...(config.shellUrl === undefined ? {} : { shellUrl: config.shellUrl }),
|
|
192
206
|
...(config.shellRevision === undefined ? {} : { shellRevision: config.shellRevision }),
|
|
193
207
|
...(config.shellBytes === undefined ? {} : { shellBytes: config.shellBytes }),
|
|
208
|
+
// Spread, never assigned: `exactOptionalPropertyTypes` makes an explicit `undefined` a
|
|
209
|
+
// different answer from an absent key, and `?? input.buildId` is what reads it.
|
|
210
|
+
...(config.offlineFallbackRevision === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { offlineFallbackRevision: config.offlineFallbackRevision }),
|
|
213
|
+
...(config.offlineFallbackBytes === undefined
|
|
214
|
+
? {}
|
|
215
|
+
: { offlineFallbackBytes: config.offlineFallbackBytes }),
|
|
194
216
|
...(config.precacheWarnBytes === undefined ? {} : { warnBytes: config.precacheWarnBytes }),
|
|
195
217
|
});
|
|
196
218
|
|
|
@@ -207,7 +229,7 @@ export function generateServiceWorker(
|
|
|
207
229
|
INSTALL_BLOCK,
|
|
208
230
|
activateBlock(),
|
|
209
231
|
fetchBlock(),
|
|
210
|
-
messageBlock(),
|
|
232
|
+
messageBlock(isEnabled(capabilities, 'backgroundSync')),
|
|
211
233
|
];
|
|
212
234
|
|
|
213
235
|
if (isEnabled(capabilities, 'push') && config.vapid !== undefined) {
|
|
@@ -388,11 +410,25 @@ async function healSkew(req,res){
|
|
|
388
410
|
}`.trim();
|
|
389
411
|
}
|
|
390
412
|
|
|
391
|
-
|
|
413
|
+
/**
|
|
414
|
+
* The window → worker channel. `flush-outbox` is here and not in `backgroundSyncSource` because
|
|
415
|
+
* the page is what sends it: `registerOutboxSync` falls back to an `online` listener posting this
|
|
416
|
+
* message wherever `registration.sync` is absent — Safari and Firefox — and with no branch for it
|
|
417
|
+
* the offline mutation queue was never drained on exactly the browsers the fallback exists for.
|
|
418
|
+
* Silent, too: no rejection, no request, no log.
|
|
419
|
+
*
|
|
420
|
+
* Gated on the capability, because `flushOutbox` is only emitted with `backgroundSyncSource`. An
|
|
421
|
+
* unconditional branch would answer the message with a `ReferenceError` inside `waitUntil`, which
|
|
422
|
+
* the page that sent it cannot catch, in every app that leaves `backgroundSync` off.
|
|
423
|
+
*/
|
|
424
|
+
function messageBlock(backgroundSync: boolean): string {
|
|
425
|
+
const flush = backgroundSync
|
|
426
|
+
? "\n if(d.type==='flush-outbox')event.waitUntil(flushOutbox());"
|
|
427
|
+
: '';
|
|
392
428
|
return `
|
|
393
429
|
self.addEventListener('message',(event)=>{
|
|
394
430
|
const d=event.data||{};
|
|
395
431
|
if(d.type==='skip-waiting')self.skipWaiting();
|
|
396
|
-
if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID})
|
|
432
|
+
if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});${flush}
|
|
397
433
|
});`.trim();
|
|
398
434
|
}
|
package/src/strategies.ts
CHANGED
|
@@ -32,10 +32,20 @@ export interface PwaRoute {
|
|
|
32
32
|
readonly dynamic?: boolean;
|
|
33
33
|
/** Explicit per-route override; wins over the derived strategy. */
|
|
34
34
|
readonly strategy?: StrategyName;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Content hash of the built HTML — the precache revision. Fed by the CLI's prerender pass; the
|
|
37
|
+
* `buildId` is the fallback, and it re-downloads every precached page on every deploy.
|
|
38
|
+
*/
|
|
36
39
|
readonly revision?: string;
|
|
40
|
+
/** Byte size of the built HTML, from the same prerender pass. Counted against `warnBytes`. */
|
|
37
41
|
readonly bytes?: number;
|
|
38
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Companion data endpoint precached alongside the HTML. **Nothing in the framework's build path
|
|
44
|
+
* sets it** — a hand-built `generateServiceWorker` call is the only route to a
|
|
45
|
+
* `reason: 'route-data'` entry, so the branch in `precache.ts` is unreachable in production.
|
|
46
|
+
* Kept because deleting a public field is a major; a candidate for the next one's
|
|
47
|
+
* declared-and-never-wired sweep, with `ServiceWorkerConfig`'s shell trio.
|
|
48
|
+
*/
|
|
39
49
|
readonly dataUrl?: string;
|
|
40
50
|
}
|
|
41
51
|
|