@ultimat3/pwa 24.0.0 → 25.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 -5
- package/README.md +7 -6
- package/package.json +3 -3
- package/src/capabilities.ts +1 -1
- package/src/index.ts +2 -8
- package/src/install.ts +2 -2
- package/src/manifest.ts +4 -4
- package/src/offline-fallback.ts +36 -6
- package/src/service-worker.ts +10 -15
- package/src/strategies.ts +28 -11
- package/src/version-skew.ts +7 -9
package/CLAUDE.md
CHANGED
|
@@ -13,24 +13,25 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
13
13
|
| Determinism | no `Date.now()`, no randomness, sort every collection. Same input → same bytes. `subscriptionState` took `now = Date.now()` and was the one exception; it takes a `Clock` `As of 2026-08-23`, so a frozen clock freezes this package too. |
|
|
14
14
|
| Rule ORDER | most specific first, then the path. `ruleFor` in the emitted worker returns the FIRST pattern that matches and has no notion of specificity, so the sort IS the routing decision. Alphabetical, `:` (0x3A) and `*` (0x2A) sort before every letter: `/posts/:id` shadowed `/posts/new`, and one `/*` catch-all shadowed the whole table — every `PRECACHE_MANIFEST` entry downloaded at install and never looked up, and a cacheable route served `network-only`, which offline is the `/offline` document. The order is `@ultimat3/core`'s `routeRank` — the ONE route precedence, also `@ultimat3/render`'s `CompiledPattern.specificity`: the request router's order, segment by segment. Never a local copy, never a sum. |
|
|
15
15
|
| A generated header | names something that regenerates the file. `sw.js` says `regenerate: x build`, and `As of 2026-08` a build really writes it (#390); it named the CALL for as long as none did. |
|
|
16
|
-
| What is WIRED, and what is not | **every generator here has a caller, `As of 2026-08`.** `generateWebManifest`, `renderThemeColorMeta`, `planIcons`, `appleTouchLinks` and `BuiltinImagePipeline` through `pwa-artifacts.ts` / `
|
|
16
|
+
| What is WIRED, and what is not | **every generator here has a caller, `As of 2026-08`.** `generateWebManifest`, `renderThemeColorMeta`, `planIcons`, `appleTouchLinks` and `BuiltinImagePipeline` through `pwa-artifacts.ts` / `runtime-assets.ts` (#362); `generateServiceWorker` — and with it `buildPrecacheManifest`, `offlineFallbackSource`, `backgroundSyncSource` and `pushSource` — through `sw-artifacts.ts` (#390). So `x dev`, the container and the static export emit `manifest.webmanifest`, `sw.js` and `x-sw-register.js` alike. The worker half landed a release later because a bad `sw.js` is sticky: it waited on a real browser check (`packages/cli/e2e/service-worker.e2e.test.ts`), which nothing in the gate could run until #400. **`pwa.push` is the one capability still unwired** — the generator emits a push handler only when a VAPID key comes with it and there is no `pwa.vapid` config key, so `x build` reports it as a `serviceWorkerWarnings` entry rather than dropping it in silence. |
|
|
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. Every row is `network-first` `As of 22.3.2`: every rule is a DOCUMENT, and `static: 'cache-first'` / `isr`,`stream: 'stale-while-revalidate'` answered online navigations with the old deploy's HTML (and so its old hashed assets) until Shift+F5. Precache ≠ cache-first — the precached copy is the offline answer. |
|
|
19
19
|
| Worker update | `self.skipWaiting()` in `install`, `As of 22.3.2` — worker-side, because the browsers that must converge run a 22.3.1 worker and register script that never post `skip-waiting`. Safe for an open tab: the worker routes no chunk. Never a reload — the next navigation is fresh. The `skip-waiting` message stays. |
|
|
20
20
|
| Navigation preload | enabled on `activate`; every emitted strategy reaches the network through `net(req,pre)` (`NETWORK_SOURCE`), never a bare `fetch`, so a navigation is answered by `event.preloadResponse`. The preload carries no build id: `seenBuild` reads skew off the ANSWER. `fromNetwork` is the exported mirror. `service-worker-preload.test.ts` executes it. |
|
|
21
|
-
| Install fill | `PRECACHE_CONCURRENCY` (4) in flight, never `Promise.all` over the manifest: it runs on the first visit against the connection the first click needs. Still one entry per failure. An answer stamped with another build's `BUILD_ID_HEADER` is not stored (an old pod mid-rollout); an unstamped one is. |
|
|
21
|
+
| Install fill | `PRECACHE_CONCURRENCY` (4) in flight, never `Promise.all` over the manifest: it runs on the first visit against the connection the first click needs. Still one entry per failure. An answer stamped with another build's core `BUILD_ID_HEADER` is not stored (an old pod mid-rollout); an unstamped one is. |
|
|
22
22
|
| Rule patterns | built from the DECODED route path, matched against the PERCENT-ENCODED `url.pathname`: a literal segment goes through the WHATWG serializer (`URL.pathname`, never `encodeURI` — it escapes `\|`, `[`, `^`, `%` a browser leaves literal), either hex case. A catch-all is `(?:/.*)?`, so `/docs/*path` owns `/docs`. `route-rules.test.ts`; the e2e drives both in Chrome. |
|
|
23
23
|
| Runtime assets | `ServiceWorkerConfig.runtimeAssets` prefixes (`/islands/`) are `cache-first` runtime rules AHEAD of every page rule (`assetRules`, `route-rules.ts`), and go to the network as the browser asked — no build-id header, because a classic script is a `no-cors` request whose headers a worker may not extend. The precache names only what a precached page boots (the CLI chooses: `sw-precache-plan.ts`); all 43 islands (~1 MB) were precached on a first anonymous visit until 22.3.3. |
|
|
24
24
|
| Locales | the worker matches pathnames and the server strips `/en/` before it matches, so the CALLER spells every route once per routed locale (`@ultimat3/cli`'s `sw-precache-plan.ts`). `localePrefixes` is only for the offline document: a navigation under `/en/` tries `/en` + `OFFLINE_DOC` first. |
|
|
25
|
+
| What is a navigation | to `offlineFallback`: `mode === 'navigate'`, or the client router's soft visit — a `fetch` carrying `CLIENT_NAVIGATION_HEADER: soft` (core's constant, interpolated). Its `prefetch` is a guess: the 503, or the offline page would be cached as a later click's answer. `offline-fallback.test.ts`. |
|
|
25
26
|
| Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
|
|
26
27
|
| 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. |
|
|
27
28
|
| 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. |
|
|
28
|
-
| 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), `
|
|
29
|
+
| 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), `installController`'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. |
|
|
29
30
|
| 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. Each entry is fetched and `put` on its own, `As of 22.3.4`: `addAll`'s all-or-nothing let one 404 (a stylesheet another pod minted) fail the install of every deploy, so every visitor stayed on the first worker that ever installed (notificado.co). A failed entry costs its offline copy only. `service-worker-runtime.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
|
|
30
31
|
| 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. |
|
|
31
32
|
| SW scope | `assertScope` refuses a relative `swPath`. `lastIndexOf('/')` on `sw.js` is `-1`, so the directory was `''` and `scope.startsWith('')` passed for every scope — the check waved through exactly the config most likely to be wrong. |
|
|
32
33
|
| HTML sinks | one escaper: `escapeHtml` from `@ultimat3/core`, the framework's one character table (`bun run flight-copies`, `X_HELPER_COPY`). `appleTouchLinks` and `renderThemeColorMeta` interpolate app config into attributes; both escape. Never a second escaper here. |
|
|
33
|
-
| 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.
|
|
34
|
+
| 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. Imported from core by callers too: 25.0.0 dropped this barrel's re-export. |
|
|
34
35
|
| Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
|
|
35
36
|
| Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
|
|
36
37
|
| 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. |
|
|
@@ -49,7 +50,7 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
49
50
|
| 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. |
|
|
50
51
|
| `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. |
|
|
51
52
|
| Icons | one source image → `BuiltinImagePipeline` → a square PNG per `ICON_MATRIX` entry, rendered by `@ultimat3/core`'s image pipeline. Never a second scaler, never `sharp`, never a vendor CDN. |
|
|
52
|
-
| Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `
|
|
53
|
+
| Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `runtime-assets.ts` reads it at `ICON_SOURCE` (through `icon-assets.ts`) and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. |
|
|
53
54
|
| Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. |
|
|
54
55
|
| Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. |
|
|
55
56
|
| 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. |
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ whole thesis: a hand-written service worker encodes routing decisions a second t
|
|
|
5
5
|
the second copy is the one nobody updates.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
const { source, precache, warnings } = generateServiceWorker(
|
|
8
|
+
const { source, precache, warnings } = generateServiceWorker(describePages(), config, buildId);
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Render mode → runtime strategy
|
|
@@ -41,7 +41,7 @@ and the app dies with a blank screen and no error anyone can act on.
|
|
|
41
41
|
| Mechanism | Rule |
|
|
42
42
|
|---|---|
|
|
43
43
|
| Build id | immutable per deploy, derived from the commit sha; `X_BUILD_ID_MISSING` if absent |
|
|
44
|
-
| Client → server | every SW-proxied request carries `x-ultimate-build` |
|
|
44
|
+
| Client → server | every SW-proxied request carries `x-ultimate-build` — `@ultimat3/core`'s `BUILD_ID_HEADER` |
|
|
45
45
|
| Retention | `retentionPlan(deploys, keep)` keeps the last N deploys' assets alive (default 3) |
|
|
46
46
|
| Stale client | gets `AppUpdateAvailable`, never a 404 |
|
|
47
47
|
| Forced reload | none. This package never navigates a client — the app decides what to do with the message |
|
|
@@ -61,11 +61,12 @@ detectSkew(clientBuildId, message.to); // 'current' | 'stale' | 'unknown'
|
|
|
61
61
|
```
|
|
62
62
|
X_PWA_NO_OFFLINE_FALLBACK: no offline fallback route
|
|
63
63
|
cause: app.config.ts has no `offline` block, so an offline navigation would show the browser's error page
|
|
64
|
-
fix:
|
|
64
|
+
fix: set pwa: { offline: { fallback: '/offline' } } in app.config.ts, then create the route it names: x g route offline --surface site
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
`requireOfflineFallback(config)` runs inside `generateServiceWorker`, so the build fails
|
|
68
|
-
before an un-shippable PWA exists.
|
|
68
|
+
before an un-shippable PWA exists. `pwa.offline.image` and `pwa.offline.font` are the
|
|
69
|
+
placeholders a failed image or font request gets — each served only from the precache.
|
|
69
70
|
|
|
70
71
|
## Capabilities are opt-in, and gate bytes
|
|
71
72
|
|
|
@@ -106,7 +107,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
106
107
|
| Export | Owns |
|
|
107
108
|
|---|---|
|
|
108
109
|
| `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
|
|
109
|
-
| `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only` |
|
|
110
|
+
| `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only`. Each answers what its emitted `sw.js` twin answers: the cache copy goes to `env.wait` and is never awaited (a failed copy costs the copy, not the response), and an exhausted strategy with no fallback rejects — `staleWhileRevalidate` with `X_PWA_STRATEGY_EXHAUSTED` |
|
|
110
111
|
| `routeRules`, `assetRules` | the worker's rule list: routes most specific first, runtime asset prefixes (`/islands/`) ahead of them. `routeRules(routes, personalPages)` — `'last-member'` gives a personal page a `pages` rule; a pattern matches the browser's percent-encoded pathname, and a catch-all its bare prefix |
|
|
111
112
|
| `CLEAR_PAGES_MESSAGE`, `PAGES_CLEARED_MESSAGE` | `{ type: 'clear-pages' }` — post it to the worker on sign-out; it empties every pages cache and answers `{ type: 'pages-cleared' }` |
|
|
112
113
|
| `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
|
|
@@ -117,7 +118,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
117
118
|
| `requireOfflineFallback` | the mandatory offline route |
|
|
118
119
|
| `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
|
|
119
120
|
| `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
|
|
120
|
-
| `
|
|
121
|
+
| `installController`, `iosInstallGuidance` | install prompt, never on first paint |
|
|
121
122
|
| `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
|
|
122
123
|
|
|
123
124
|
## Notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/pwa",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Generated service worker, web manifest, icons, push and version-skew handling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/capabilities.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function resolveCapabilities(flags: CapabilityFlags = {}): ResolvedCapabi
|
|
|
34
34
|
return resolved;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function
|
|
37
|
+
export function hasCapability(capabilities: ResolvedCapabilities, capability: Capability): boolean {
|
|
38
38
|
return capabilities[capability];
|
|
39
39
|
}
|
|
40
40
|
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
* re-exported here; a consumer still needs one import, and now it names the real type.
|
|
7
7
|
*/
|
|
8
8
|
export type { OfflineStrategy, RenderMode } from '@ultimat3/core';
|
|
9
|
-
// Moved to `@ultimat3/core` (one formatter, `b`/`kb`/`mb`/`gb`); still named here because a service
|
|
10
|
-
// worker's size report is what a caller of this package prints.
|
|
11
|
-
export { formatBytes } from '@ultimat3/core';
|
|
12
9
|
export {
|
|
13
10
|
backgroundSyncSource,
|
|
14
11
|
registerBackgroundSyncSource,
|
|
@@ -19,7 +16,7 @@ export {
|
|
|
19
16
|
CAPABILITIES,
|
|
20
17
|
CAPABILITY_SW_MARKERS,
|
|
21
18
|
enabledCapabilities,
|
|
22
|
-
|
|
19
|
+
hasCapability,
|
|
23
20
|
resolveCapabilities,
|
|
24
21
|
} from './capabilities';
|
|
25
22
|
export type { PwaErrorCode } from './errors';
|
|
@@ -65,7 +62,7 @@ export type {
|
|
|
65
62
|
IosGuidance,
|
|
66
63
|
ReadSignal,
|
|
67
64
|
} from './install';
|
|
68
|
-
export {
|
|
65
|
+
export { installController, iosInstallGuidance } from './install';
|
|
69
66
|
export type {
|
|
70
67
|
DisplayMode,
|
|
71
68
|
FileHandler,
|
|
@@ -141,10 +138,7 @@ export type {
|
|
|
141
138
|
SkewState,
|
|
142
139
|
} from './version-skew';
|
|
143
140
|
export {
|
|
144
|
-
APP_UPDATE_AVAILABLE,
|
|
145
141
|
assertBuildId,
|
|
146
|
-
BUILD_ID_HEADER,
|
|
147
|
-
BUILD_ID_META,
|
|
148
142
|
buildId,
|
|
149
143
|
cacheNamespace,
|
|
150
144
|
detectSkew,
|
package/src/install.ts
CHANGED
|
@@ -77,13 +77,13 @@ export interface InstallController {
|
|
|
77
77
|
dispose(): void;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
export function
|
|
80
|
+
export function installController(options: InstallOptions): InstallController {
|
|
81
81
|
const host = options.host;
|
|
82
82
|
// `now() - startedAt < NaN` is false at every instant, so a threshold that arrived non-finite
|
|
83
83
|
// does not shorten the wait — it deletes it, and the prompt fires on first paint. `0` stays
|
|
84
84
|
// legal: "ask as soon as the browser offers" is a decision, and it is a comparison that works.
|
|
85
85
|
const minEngagementMs = finiteCount(
|
|
86
|
-
'
|
|
86
|
+
'installController',
|
|
87
87
|
'minEngagementMs',
|
|
88
88
|
options.minEngagementMs ?? MIN_ENGAGEMENT_MS,
|
|
89
89
|
);
|
package/src/manifest.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { PwaColors } from '@ultimat3/core';
|
|
13
13
|
import { escapeHtml } from '@ultimat3/core';
|
|
14
14
|
import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
|
|
15
|
-
import {
|
|
15
|
+
import { hasCapability, resolveCapabilities } from './capabilities';
|
|
16
16
|
import { PwaManifestInvalidError } from './errors';
|
|
17
17
|
|
|
18
18
|
export type DisplayMode = 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
|
|
@@ -143,13 +143,13 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
143
143
|
if (config.screenshots !== undefined) optional.screenshots = config.screenshots;
|
|
144
144
|
|
|
145
145
|
// A disabled capability contributes no manifest member at all — not an empty one.
|
|
146
|
-
if (
|
|
146
|
+
if (hasCapability(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
|
|
147
147
|
optional.share_target = config.shareTarget;
|
|
148
148
|
}
|
|
149
|
-
if (
|
|
149
|
+
if (hasCapability(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
|
|
150
150
|
optional.file_handlers = config.fileHandlers;
|
|
151
151
|
}
|
|
152
|
-
if (
|
|
152
|
+
if (hasCapability(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
|
|
153
153
|
optional.protocol_handlers = config.protocolHandlers;
|
|
154
154
|
}
|
|
155
155
|
|
package/src/offline-fallback.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* requires it and `requireOfflineFallback` fails the build if it is missing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { CLIENT_NAVIGATION_HEADER, isSameOriginPath } from '@ultimat3/core';
|
|
7
8
|
import { PwaNoOfflineFallbackError } from './errors';
|
|
8
9
|
import type { PersonalPages } from './pages-cache-source';
|
|
9
10
|
|
|
@@ -78,39 +79,68 @@ export function requireOfflineFallback(
|
|
|
78
79
|
`set pwa.offline.fallback to '/${fallback.replace(/^\/+/, '')}' in app.config.ts`,
|
|
79
80
|
);
|
|
80
81
|
}
|
|
82
|
+
// Core's predicate, judged by resolution: every URL here is precached at install and served as
|
|
83
|
+
// the answer to a failed request, so an off-origin one is a third party's bytes in the cache.
|
|
84
|
+
if (!isSameOriginPath(fallback)) throw offOrigin('fallback', fallback, '/offline');
|
|
81
85
|
|
|
82
86
|
return {
|
|
83
87
|
document: fallback,
|
|
84
|
-
image: config.image
|
|
85
|
-
font: config.font
|
|
88
|
+
image: placeholder('image', config.image, '/icons/offline.png'),
|
|
89
|
+
font: placeholder('font', config.font, '/fonts/fallback.woff2'),
|
|
86
90
|
neverCache: config.neverCache ?? [],
|
|
87
91
|
personalPages: config.personalPages === 'last-member' ? 'last-member' : 'never',
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
function offOrigin(key: string, url: string, example: string): PwaNoOfflineFallbackError {
|
|
96
|
+
return new PwaNoOfflineFallbackError(
|
|
97
|
+
`pwa.offline.${key} is ${JSON.stringify(url)}, which is not a path on this origin — it is precached and served as the offline answer`,
|
|
98
|
+
`defineConfig({ pwa: { offline: { ${key}: '${example}' } } }) # pwa.offline.${key} in app.config.ts — a path this app serves`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `pwa.offline.image` / `.font`: absent is `null`; present must be a same-origin path. */
|
|
103
|
+
function placeholder(
|
|
104
|
+
key: 'image' | 'font',
|
|
105
|
+
url: string | undefined,
|
|
106
|
+
example: string,
|
|
107
|
+
): string | null {
|
|
108
|
+
if (url === undefined || url === null) return null;
|
|
109
|
+
if (!isSameOriginPath(url)) throw offOrigin(key, url, example);
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
|
-
* Emitted into `sw.js`: what to serve when a
|
|
93
|
-
*
|
|
94
|
-
*
|
|
114
|
+
* Emitted into `sw.js`: what to serve when a request cannot be answered — the offline document for
|
|
115
|
+
* a navigation, the `image`/`font` placeholders for those destinations (each only if the app
|
|
116
|
+
* precached it), else a 503. `localePrefixes` are the non-default routed locales' URL segments: a
|
|
117
|
+
* navigation under `/en/` tries `/en` + the offline document first, so an English visitor offline
|
|
118
|
+
* is not handed the default locale's page. A navigation is the browser's own (`mode`) or the client
|
|
119
|
+
* router's soft visit — a `fetch` carrying `CLIENT_NAVIGATION_HEADER: soft`, which otherwise got the
|
|
120
|
+
* bare 503 (#627). Its `prefetch` is a guess, not a visit: never answered with the offline page.
|
|
95
121
|
*/
|
|
96
122
|
export function offlineFallbackSource(
|
|
97
123
|
fallback: OfflineFallback,
|
|
98
124
|
localePrefixes: readonly string[] = [],
|
|
99
125
|
): string {
|
|
100
126
|
const image = fallback.image === null ? 'null' : JSON.stringify(fallback.image);
|
|
127
|
+
const font = fallback.font === null ? 'null' : JSON.stringify(fallback.font);
|
|
101
128
|
const locales = [...new Set(localePrefixes)].sort();
|
|
102
129
|
return `
|
|
103
130
|
const OFFLINE_DOC=${JSON.stringify(fallback.document)};
|
|
104
131
|
const OFFLINE_IMAGE=${image};
|
|
132
|
+
const OFFLINE_FONT=${font};
|
|
105
133
|
const OFFLINE_LOCALES=${JSON.stringify(locales)};
|
|
134
|
+
const OFFLINE_NAV_HEADER=${JSON.stringify(CLIENT_NAVIGATION_HEADER)};
|
|
106
135
|
async function offlineFallback(req){
|
|
107
136
|
const c=await caches.open(PRECACHE);
|
|
108
|
-
if(req.mode==='navigate'){
|
|
137
|
+
if(req.mode==='navigate'||req.headers.get(OFFLINE_NAV_HEADER)==='soft'){
|
|
109
138
|
const seg=new URL(req.url).pathname.split('/')[1];
|
|
110
139
|
if(OFFLINE_LOCALES.indexOf(seg)!==-1){const l=await c.match('/'+seg+OFFLINE_DOC);if(l)return l}
|
|
111
140
|
const d=await c.match(OFFLINE_DOC);if(d)return d
|
|
112
141
|
}
|
|
113
142
|
if(OFFLINE_IMAGE&&req.destination==='image'){const i=await c.match(OFFLINE_IMAGE);if(i)return i}
|
|
143
|
+
if(OFFLINE_FONT&&req.destination==='font'){const f=await c.match(OFFLINE_FONT);if(f)return f}
|
|
114
144
|
return new Response('',{status:503,statusText:'Offline'})
|
|
115
145
|
}`.trim();
|
|
116
146
|
}
|
package/src/service-worker.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* update check does not fire on a no-op deploy.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
11
|
+
import { APP_UPDATE_MESSAGE, BUILD_ID_HEADER, CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
12
12
|
import { backgroundSyncSource } from './background-sync';
|
|
13
13
|
import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
|
|
14
|
-
import {
|
|
14
|
+
import { hasCapability, 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';
|
|
@@ -30,12 +30,7 @@ import type { RouteRule } from './route-rules';
|
|
|
30
30
|
import { assetRules, routeRules } from './route-rules';
|
|
31
31
|
import type { PwaRoute } from './strategies';
|
|
32
32
|
import { NETWORK_SOURCE, STRATEGY_FN_NAMES, STRATEGY_SOURCE } from './strategies';
|
|
33
|
-
import {
|
|
34
|
-
APP_UPDATE_AVAILABLE,
|
|
35
|
-
assertBuildId,
|
|
36
|
-
BUILD_ID_HEADER,
|
|
37
|
-
cacheNamespace,
|
|
38
|
-
} from './version-skew';
|
|
33
|
+
import { assertBuildId, cacheNamespace } from './version-skew';
|
|
39
34
|
|
|
40
35
|
export interface ServiceWorkerConfig {
|
|
41
36
|
readonly scope?: string;
|
|
@@ -162,13 +157,13 @@ export function generateServiceWorker(
|
|
|
162
157
|
INSTALL_BLOCK,
|
|
163
158
|
activateBlock(),
|
|
164
159
|
fetchBlock(fallback.personalPages),
|
|
165
|
-
messageBlock(
|
|
160
|
+
messageBlock(hasCapability(capabilities, 'backgroundSync')),
|
|
166
161
|
];
|
|
167
162
|
|
|
168
|
-
if (
|
|
169
|
-
blocks.push(pushSource({ badging:
|
|
163
|
+
if (hasCapability(capabilities, 'push') && config.vapid !== undefined) {
|
|
164
|
+
blocks.push(pushSource({ badging: hasCapability(capabilities, 'badging') }));
|
|
170
165
|
}
|
|
171
|
-
if (
|
|
166
|
+
if (hasCapability(capabilities, 'backgroundSync')) {
|
|
172
167
|
blocks.push(backgroundSyncSource());
|
|
173
168
|
}
|
|
174
169
|
|
|
@@ -300,7 +295,7 @@ self.addEventListener('activate',(event)=>{
|
|
|
300
295
|
// the fetch handler answers from it (net) — start-up is off every navigation's critical path.
|
|
301
296
|
try{if(self.registration&&self.registration.navigationPreload)await self.registration.navigationPreload.enable()}catch(e){}
|
|
302
297
|
const cs=await self.clients.matchAll({type:'window'});
|
|
303
|
-
for(const c of cs)c.postMessage({type:${JSON.stringify(
|
|
298
|
+
for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:BUILD_ID});
|
|
304
299
|
// NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
|
|
305
300
|
// warm-up that copies a streamed page's whole body in here held every request of the tab it
|
|
306
301
|
// just claimed — /feed's own reads included — until that body had ended. Kicked off, never
|
|
@@ -391,7 +386,7 @@ function seenBuild(res){
|
|
|
391
386
|
const server=res.headers.get(BUILD_HEADER);
|
|
392
387
|
if(SKEWED||server===null||server===BUILD_ID)return;
|
|
393
388
|
SKEWED=true;
|
|
394
|
-
self.clients.matchAll({type:'window'}).then((cs)=>{for(const c of cs)c.postMessage({type:${JSON.stringify(
|
|
389
|
+
self.clients.matchAll({type:'window'}).then((cs)=>{for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:server})}).catch(()=>{});
|
|
395
390
|
}
|
|
396
391
|
function withBuild(headers){
|
|
397
392
|
const h=new Headers(headers);
|
|
@@ -410,7 +405,7 @@ async function healSkew(req,res){
|
|
|
410
405
|
// Answering the request for real is what breaks that loop.
|
|
411
406
|
SKEWED=true;
|
|
412
407
|
const cs=await self.clients.matchAll({type:'window'});
|
|
413
|
-
for(const c of cs)c.postMessage({type:${JSON.stringify(
|
|
408
|
+
for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_MESSAGE)},to:server});
|
|
414
409
|
return fetch(new Request(req,{headers:withBuild(req.headers)}));
|
|
415
410
|
}`.trim();
|
|
416
411
|
}
|
package/src/strategies.ts
CHANGED
|
@@ -157,7 +157,7 @@ export async function networkFirst(
|
|
|
157
157
|
const cache = await env.open(options.cacheName);
|
|
158
158
|
try {
|
|
159
159
|
const response = await fromNetwork(request, env, options);
|
|
160
|
-
if (response.ok)
|
|
160
|
+
if (response.ok) later(env, cache.put(request, response.clone()));
|
|
161
161
|
return response;
|
|
162
162
|
} catch (error) {
|
|
163
163
|
const hit = await cache.match(request);
|
|
@@ -176,8 +176,8 @@ export async function staleWhileRevalidate(
|
|
|
176
176
|
const cache = await env.open(options.cacheName);
|
|
177
177
|
const hit = await cache.match(request);
|
|
178
178
|
const refresh = fromNetwork(request, env, options)
|
|
179
|
-
.then(
|
|
180
|
-
if (response.ok)
|
|
179
|
+
.then((response) => {
|
|
180
|
+
if (response.ok) later(env, cache.put(request, response.clone()));
|
|
181
181
|
return response;
|
|
182
182
|
})
|
|
183
183
|
.catch(async () => (hit !== undefined ? hit : fallbackOrThrow(options)));
|
|
@@ -185,8 +185,8 @@ export async function staleWhileRevalidate(
|
|
|
185
185
|
if (hit !== undefined) {
|
|
186
186
|
// The refresh is handed to the event's `waitUntil` NOW, before the cached answer returns:
|
|
187
187
|
// taken after `respondWith` settled, a browser refuses the extension (`InvalidStateError`) and
|
|
188
|
-
// may kill the worker mid-refresh. `later` also swallows its rejection
|
|
189
|
-
env
|
|
188
|
+
// may kill the worker mid-refresh. `later` also swallows its rejection.
|
|
189
|
+
later(env, refresh);
|
|
190
190
|
return hit;
|
|
191
191
|
}
|
|
192
192
|
return refresh;
|
|
@@ -225,7 +225,7 @@ async function fetchAndStore(
|
|
|
225
225
|
): Promise<Response> {
|
|
226
226
|
try {
|
|
227
227
|
const response = await fromNetwork(request, env, options);
|
|
228
|
-
if (response.ok)
|
|
228
|
+
if (response.ok) later(env, cache.put(request, response.clone()));
|
|
229
229
|
return response;
|
|
230
230
|
} catch (error) {
|
|
231
231
|
if (options.fallback !== undefined) return options.fallback();
|
|
@@ -233,16 +233,33 @@ async function fetchAndStore(
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Work behind the answer: the emitted worker's `later`. A cache copy is never awaited — `Cache.put`
|
|
238
|
+
* reads the whole body, so awaiting it held a streamed document away from the tab, and a put that
|
|
239
|
+
* REJECTED (quota) turned a fresh network answer into the stale copy or a throw. The copy goes to
|
|
240
|
+
* the event's `waitUntil`, which keeps the worker alive until it lands; a failed copy costs the
|
|
241
|
+
* copy, never the response.
|
|
242
|
+
*/
|
|
243
|
+
function later(env: StrategyEnv, work: Promise<unknown>): void {
|
|
244
|
+
const settled = work.catch(() => undefined);
|
|
245
|
+
env.wait?.(settled);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Exhausted with nothing to answer: REJECT, as the other three strategies do on both halves. In a
|
|
250
|
+
* worker a rejected `respondWith` is the same network error `Response.error()` would be, and
|
|
251
|
+
* in-process the caller gets a code and a fix instead of a status-0 response it has to recognise.
|
|
252
|
+
*/
|
|
236
253
|
async function fallbackOrThrow(options: StrategyOptions): Promise<Response> {
|
|
237
254
|
if (options.fallback !== undefined) return options.fallback();
|
|
238
255
|
throw new PwaStrategyExhaustedError({ cacheName: options.cacheName });
|
|
239
256
|
}
|
|
240
257
|
|
|
241
258
|
/**
|
|
242
|
-
* The emitted counterpart of the functions above. Kept as source strings because the
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
259
|
+
* The emitted counterpart of the functions above. Kept as source strings because the service
|
|
260
|
+
* worker is a generated artifact with no bundler in the loop — the shapes are identical on purpose
|
|
261
|
+
* and `strategies.test.ts` RUNS each source against the fake its twin gets. They open a cache
|
|
262
|
+
* through the worker's `openCache`, never `caches.open`: the pages cache is a facade that
|
|
246
263
|
* partitions a per-member document by principal (`service-worker.ts`, `pagesCache`). And the cache
|
|
247
264
|
* copy is NEVER awaited before answering: `Cache.put` reads the whole body, so awaiting it held a
|
|
248
265
|
* streamed document away from the tab until it had ended. It goes to `later(wait, …)` instead —
|
|
@@ -262,7 +279,7 @@ export const STRATEGY_SOURCE = Object.freeze<Record<StrategyName, string>>({
|
|
|
262
279
|
'stale-while-revalidate': `async function staleWhileRevalidate(req,cn,fb,wait,pre){
|
|
263
280
|
const c=await openCache(cn);const hit=await c.match(req);
|
|
264
281
|
const refresh=net(req,pre).then((r)=>{if(r.ok)later(wait,c.put(req,r.clone()));return r})
|
|
265
|
-
.catch(()=>hit
|
|
282
|
+
.catch((e)=>{if(hit)return hit;if(fb)return fb();throw e});
|
|
266
283
|
if(hit){later(wait,refresh);return hit}
|
|
267
284
|
return refresh
|
|
268
285
|
}`,
|
package/src/version-skew.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* The app decides what to do with that message. This package never navigates a client.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { APP_UPDATE_MESSAGE
|
|
13
|
+
import type { APP_UPDATE_MESSAGE } from '@ultimat3/core';
|
|
14
|
+
import { finiteCount } from '@ultimat3/core';
|
|
14
15
|
import { BuildIdMissingError } from './errors';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// The header every proxied request carries is core's `BUILD_ID_HEADER`, the document meta core's
|
|
18
|
+
// `CLIENT_BUILD_META` and the worker's message type core's `APP_UPDATE_MESSAGE` — imported where
|
|
19
|
+
// they are used, never re-published under this package's names (25.0.0, `X_HELPER_COPY`).
|
|
19
20
|
|
|
20
21
|
export type DeployChannel = 'production' | 'preview' | 'branch';
|
|
21
22
|
|
|
@@ -125,8 +126,8 @@ export function detectSkew(
|
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
128
|
* The client-side contract, and the whole of it: what the generated worker posts to every page it
|
|
128
|
-
* controls on activation. The page compares `to` against its own `
|
|
129
|
-
* is that comparison — and renders its own "refresh to update" affordance.
|
|
129
|
+
* controls on activation. The page compares `to` against its own `CLIENT_BUILD_META` meta (core's)
|
|
130
|
+
* — `detectSkew` is that comparison — and renders its own "refresh to update" affordance.
|
|
130
131
|
*
|
|
131
132
|
* It declared `from`, `forced` and `deadlineAt` too, for a forced reload after a grace period that
|
|
132
133
|
* NOTHING performed: `updateSignal`/`updatePolicy` computed the three and had no runtime caller,
|
|
@@ -141,6 +142,3 @@ export interface AppUpdateAvailable {
|
|
|
141
142
|
/** The build the worker that posted this was generated for. */
|
|
142
143
|
readonly to: string;
|
|
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;
|