@ultimat3/pwa 23.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 +11 -9
- package/README.md +12 -12
- package/package.json +3 -4
- package/src/capabilities.ts +1 -1
- package/src/errors.ts +3 -14
- package/src/icons.ts +2 -3
- package/src/index.ts +2 -9
- package/src/install.ts +2 -2
- package/src/manifest.ts +24 -8
- package/src/offline-fallback.ts +36 -6
- package/src/push.ts +33 -15
- package/src/route-rules.ts +52 -47
- package/src/service-worker.ts +19 -23
- package/src/strategies.ts +28 -11
- package/src/version-skew.ts +7 -9
package/CLAUDE.md
CHANGED
|
@@ -11,25 +11,27 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
11
11
|
|---|---|
|
|
12
12
|
| `sw.js` | generated, never hand-written, never committed. No file in this package is a SW. |
|
|
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
|
-
| 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.
|
|
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. |
|
|
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
|
+
| 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. |
|
|
22
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. |
|
|
23
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`. |
|
|
24
26
|
| Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
|
|
25
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. |
|
|
26
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. |
|
|
27
|
-
| 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. |
|
|
28
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. |
|
|
29
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. |
|
|
30
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. |
|
|
31
|
-
| HTML sinks | one escaper: `
|
|
32
|
-
| 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.
|
|
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. |
|
|
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. |
|
|
33
35
|
| Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
|
|
34
36
|
| Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
|
|
35
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. |
|
|
@@ -38,17 +40,17 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
|
|
|
38
40
|
| `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. |
|
|
39
41
|
| 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. |
|
|
40
42
|
| 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. |
|
|
41
|
-
| A per-member document | NEVER stored, in any cache, under any rule (22.3.3, security). `openCache` guards every cache a strategy opens: a response that is `private`/`no-store` or carries `x-ultimate-scope` (`CLIENT_SCOPE_HEADER`) is answered and dropped. 21.0.0 kept one in a per-principal partition (`PAGES~<scope>`) for offline, and after sign-out an offline navigation on a shared device showed the previous member's cases (notificado.co). A PERSONAL route (`PwaRoute.personal`: a policy, a `stream`, a `no-store`/`private` cache) is also `network-only` in `strategyFor` and skipped by `buildPrecacheManifest`. `{type:'clear-pages'}` (`CLEAR_PAGES_MESSAGE`) deletes every `x-pages-*` cache — old partitions included — and answers `pages-cleared`. `offline.personalPages: 'last-member'` (an app's explicit opt-in) restores 21.0.0's partition — `pagesCacheSource(mode)` emits one of the two. `service-worker-pages.test.ts` runs both modes. |
|
|
43
|
+
| A per-member document | NEVER stored, in any cache, under any rule (22.3.3, security). `openCache` guards every cache a strategy opens: a response that is `private`/`no-store` or carries `x-ultimate-scope` (`CLIENT_SCOPE_HEADER`) is answered and dropped. 21.0.0 kept one in a per-principal partition (`PAGES~<scope>`) for offline, and after sign-out an offline navigation on a shared device showed the previous member's cases (notificado.co). A PERSONAL route (`PwaRoute.personal`: a policy, a `stream`, a `no-store`/`private` cache) is also `network-only` in `strategyFor` and skipped by `buildPrecacheManifest`. `{type:'clear-pages'}` (`CLEAR_PAGES_MESSAGE`) deletes every `x-pages-*` cache — old partitions included — and answers `pages-cleared`. `offline.personalPages: 'last-member'` (an app's explicit opt-in) restores 21.0.0's partition — `pagesCacheSource(mode)` emits one of the two, and `routeRules(routes, mode)` gives a personal page a `pages` rule whatever its `offline` (a `precache` rule was never filled, so it was never kept). `service-worker-pages.test.ts` runs both modes. |
|
|
42
44
|
| 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. |
|
|
43
45
|
| 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. |
|
|
44
46
|
| 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. |
|
|
45
47
|
| Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
|
|
46
48
|
| `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. |
|
|
47
|
-
| 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. |
|
|
49
|
+
| 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. A URL that does not resolve to `self.location.origin` (another host, a scheme, malformed, a non-string) opens the root instead (`tapTarget`). The `push` body is parsed INSIDE `waitUntil` (`pushData`): plain text is the body, JSON that is no object is `{}`. `push-handler.test.ts`. |
|
|
48
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. |
|
|
49
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. |
|
|
50
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. |
|
|
51
|
-
| 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. |
|
|
52
54
|
| Icon background | `IconSourceConfig.background`, hex or `transparent` — core's grammar has no named colours. Default transparent. |
|
|
53
55
|
| Errors | `errors.ts` subclasses only. `X_NOT_IMPLEMENTED` must carry a real `fix:`. |
|
|
54
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,17 +61,18 @@ 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
|
|
|
72
73
|
| Capability | Manifest member | SW code |
|
|
73
74
|
|---|---|---|
|
|
74
|
-
| `push` | — | `push` + `notificationclick` listeners |
|
|
75
|
+
| `push` | — | `push` + `notificationclick` listeners; a tap opens a same-origin URL only |
|
|
75
76
|
| `backgroundSync` | — | `sync` listener that tells every open tab to drain realtime's outbox (`OUTBOX_DRAIN_MESSAGE`) |
|
|
76
77
|
| `badging` | — | `navigator.setAppBadge` after a push |
|
|
77
78
|
| `shareTarget` | `share_target` | — |
|
|
@@ -95,7 +96,6 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
95
96
|
| Class | Code | Declared in |
|
|
96
97
|
|---|---|---|
|
|
97
98
|
| `BuildIdMissingError` | `X_BUILD_ID_MISSING` | `src/errors.ts` |
|
|
98
|
-
| `NotImplementedError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
99
99
|
| `PwaIconMissingError` | `X_PWA_ICON_MISSING` | `src/errors.ts` |
|
|
100
100
|
| `PwaManifestInvalidError` | `X_PWA_MANIFEST_INVALID` | `src/errors.ts` |
|
|
101
101
|
| `PwaNoOfflineFallbackError` | `X_PWA_NO_OFFLINE_FALLBACK` | `src/errors.ts` |
|
|
@@ -107,8 +107,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
107
107
|
| Export | Owns |
|
|
108
108
|
|---|---|
|
|
109
109
|
| `generateServiceWorker` | `sw.js` from the route table; deterministic for identical input |
|
|
110
|
-
| `strategyFor`, `MODE_STRATEGY`, `cacheFirst`, … | the four strategies + the mapping table; a `personal` route is `network-only` |
|
|
111
|
-
| `routeRules`, `assetRules` | the worker's rule list: routes most specific first, runtime asset prefixes (`/islands/`) ahead of them |
|
|
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` |
|
|
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 |
|
|
112
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' }` |
|
|
113
113
|
| `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
|
|
114
114
|
| `buildId`, `detectSkew`, `retentionPlan` | version skew |
|
|
@@ -118,7 +118,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
118
118
|
| `requireOfflineFallback` | the mandatory offline route |
|
|
119
119
|
| `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
|
|
120
120
|
| `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
|
|
121
|
-
| `
|
|
121
|
+
| `installController`, `iosInstallGuidance` | install prompt, never on first paint |
|
|
122
122
|
| `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
|
|
123
123
|
|
|
124
124
|
## Notes
|
|
@@ -153,9 +153,9 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
153
153
|
lands exactly inside `maskableSafeZone(size)`; the ring around it is `background`, which is
|
|
154
154
|
hex or `transparent` (there are no named colours). Same bytes in, same bytes out.
|
|
155
155
|
- **Every HTML sink goes through one escaper.** `appleTouchLinks` and `renderThemeColorMeta`
|
|
156
|
-
interpolate app configuration into attributes, so both run it through `
|
|
157
|
-
`@ultimat3/
|
|
158
|
-
tier 4, sideways). Never a second escaper here.
|
|
156
|
+
interpolate app configuration into attributes, so both run it through `escapeHtml` from
|
|
157
|
+
`@ultimat3/core` — the framework's one HTML character table (`@ultimat3/render`'s `html.ts` is
|
|
158
|
+
tier 4, sideways, and re-exports the same function). Never a second escaper here.
|
|
159
159
|
- **A precache URL may already carry a query.** `PrecacheAsset.url` is public API and bundlers emit
|
|
160
160
|
`?v=<hash>` of their own, so the install block picks `?` or `&` per entry. A fixed `?` produced
|
|
161
161
|
`...?locale=en?v=<rev>`, and because `cache.addAll` is all-or-nothing a single non-200 there means
|
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,14 +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": "
|
|
36
|
-
"@ultimat3/seo": "23.0.0"
|
|
35
|
+
"@ultimat3/core": "25.0.0"
|
|
37
36
|
}
|
|
38
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/errors.ts
CHANGED
|
@@ -20,8 +20,9 @@ export const PWA_OWNED_ERROR_CODES = [
|
|
|
20
20
|
] as const;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s
|
|
24
|
-
*
|
|
23
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — constructed only there, by core's own
|
|
24
|
+
* `NotImplementedError`, and titled only there: the copy this file used to keep was a second title
|
|
25
|
+
* that could drift from core's with nothing to catch it.
|
|
25
26
|
*/
|
|
26
27
|
export const PWA_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
27
28
|
|
|
@@ -130,15 +131,3 @@ export class PwaStrategyExhaustedError extends UltimateError {
|
|
|
130
131
|
});
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
|
|
134
|
-
/** A driver whose interface exists and whose backing implementation does not, yet. */
|
|
135
|
-
export class NotImplementedError extends UltimateError {
|
|
136
|
-
static readonly code = 'X_NOT_IMPLEMENTED' as const;
|
|
137
|
-
constructor(cause: string, fix: string) {
|
|
138
|
-
super({
|
|
139
|
-
code: NotImplementedError.code,
|
|
140
|
-
cause,
|
|
141
|
-
fix,
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
}
|
package/src/icons.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* icons end up with clipped logos.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { transformImageBytes } from '@ultimat3/core';
|
|
8
|
-
import { escapeAttribute } from '@ultimat3/seo';
|
|
7
|
+
import { escapeHtml, transformImageBytes } from '@ultimat3/core';
|
|
9
8
|
import { PwaIconMissingError } from './errors';
|
|
10
9
|
import type { ManifestIcon } from './manifest';
|
|
11
10
|
|
|
@@ -186,7 +185,7 @@ export function appleTouchLinks(plan: IconPlan): string {
|
|
|
186
185
|
// `href` — a quote in it closed the attribute AND the tag, so `<head>` got a live element.
|
|
187
186
|
// seo's escaper is tier 1 and the one this package can reach; render's html.ts is tier 4.
|
|
188
187
|
(entry) =>
|
|
189
|
-
`<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${
|
|
188
|
+
`<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${escapeHtml(entry.outputPath)}">`,
|
|
190
189
|
)
|
|
191
190
|
.join('');
|
|
192
191
|
}
|
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,13 +16,12 @@ 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';
|
|
26
23
|
export {
|
|
27
24
|
BuildIdMissingError,
|
|
28
|
-
NotImplementedError,
|
|
29
25
|
PWA_ERROR_CODES,
|
|
30
26
|
PWA_ERROR_TITLES,
|
|
31
27
|
PwaIconMissingError,
|
|
@@ -66,7 +62,7 @@ export type {
|
|
|
66
62
|
IosGuidance,
|
|
67
63
|
ReadSignal,
|
|
68
64
|
} from './install';
|
|
69
|
-
export {
|
|
65
|
+
export { installController, iosInstallGuidance } from './install';
|
|
70
66
|
export type {
|
|
71
67
|
DisplayMode,
|
|
72
68
|
FileHandler,
|
|
@@ -142,10 +138,7 @@ export type {
|
|
|
142
138
|
SkewState,
|
|
143
139
|
} from './version-skew';
|
|
144
140
|
export {
|
|
145
|
-
APP_UPDATE_AVAILABLE,
|
|
146
141
|
assertBuildId,
|
|
147
|
-
BUILD_ID_HEADER,
|
|
148
|
-
BUILD_ID_META,
|
|
149
142
|
buildId,
|
|
150
143
|
cacheNamespace,
|
|
151
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
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
// with no map between them and nothing asserting they agreed, which is the same axiom-1 defect the
|
|
11
11
|
// two `PwaConfig`s were. An app writes them once, in the one config file.
|
|
12
12
|
import type { PwaColors } from '@ultimat3/core';
|
|
13
|
-
import {
|
|
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';
|
|
@@ -132,6 +132,8 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
132
132
|
assertValid(config);
|
|
133
133
|
const capabilities = resolveCapabilities(config.capabilities);
|
|
134
134
|
const scope = config.scope ?? '/';
|
|
135
|
+
const display = config.display ?? 'standalone';
|
|
136
|
+
const override = displayOverrideFor(display);
|
|
135
137
|
|
|
136
138
|
const optional: MutableManifest = {};
|
|
137
139
|
if (config.description !== undefined) optional.description = config.description;
|
|
@@ -141,13 +143,13 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
141
143
|
if (config.screenshots !== undefined) optional.screenshots = config.screenshots;
|
|
142
144
|
|
|
143
145
|
// A disabled capability contributes no manifest member at all — not an empty one.
|
|
144
|
-
if (
|
|
146
|
+
if (hasCapability(capabilities, 'shareTarget') && config.shareTarget !== undefined) {
|
|
145
147
|
optional.share_target = config.shareTarget;
|
|
146
148
|
}
|
|
147
|
-
if (
|
|
149
|
+
if (hasCapability(capabilities, 'fileHandlers') && config.fileHandlers !== undefined) {
|
|
148
150
|
optional.file_handlers = config.fileHandlers;
|
|
149
151
|
}
|
|
150
|
-
if (
|
|
152
|
+
if (hasCapability(capabilities, 'protocolHandlers') && config.protocolHandlers !== undefined) {
|
|
151
153
|
optional.protocol_handlers = config.protocolHandlers;
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -156,8 +158,9 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
156
158
|
short_name: config.shortName ?? shortNameFrom(config.name),
|
|
157
159
|
start_url: config.startUrl ?? scope,
|
|
158
160
|
scope,
|
|
159
|
-
display
|
|
160
|
-
|
|
161
|
+
display,
|
|
162
|
+
// Right after `display`, where it always was: an unchanged config emits unchanged bytes.
|
|
163
|
+
...(override.length > 0 ? { display_override: override } : {}),
|
|
161
164
|
orientation: config.orientation ?? 'any',
|
|
162
165
|
lang: config.lang ?? 'en',
|
|
163
166
|
dir: config.dir ?? 'ltr',
|
|
@@ -177,6 +180,19 @@ export function generateWebManifest(config: WebManifestInput): WebManifestResult
|
|
|
177
180
|
};
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
/** The spec's own fallback chain for `display`, app windows only — `browser` is no window. */
|
|
184
|
+
const DISPLAY_CHAIN: readonly DisplayMode[] = ['fullscreen', 'standalone', 'minimal-ui'];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `display_override` is read BEFORE `display`, so it must start where `display` does: a fixed
|
|
188
|
+
* `['standalone','minimal-ui']` opened a `browser` app in its own window and gave a `fullscreen`
|
|
189
|
+
* one browser UI. `browser` gets none — any override would be a window it did not ask for.
|
|
190
|
+
*/
|
|
191
|
+
function displayOverrideFor(display: DisplayMode): readonly DisplayMode[] {
|
|
192
|
+
const from = DISPLAY_CHAIN.indexOf(display);
|
|
193
|
+
return from < 0 ? [] : DISPLAY_CHAIN.slice(from);
|
|
194
|
+
}
|
|
195
|
+
|
|
180
196
|
type MutableManifest = { -readonly [K in keyof WebManifest]?: WebManifest[K] };
|
|
181
197
|
|
|
182
198
|
/** Home-screen labels are truncated by the OS well before this; the cap only bounds the string. */
|
|
@@ -231,7 +247,7 @@ export function renderThemeColorMeta(metas: readonly ThemeColorMeta[]): string {
|
|
|
231
247
|
// quote in one emitted a second, live attribute — same class as `appleTouchLinks`, same
|
|
232
248
|
// escaper, one per package rather than one per call site.
|
|
233
249
|
(meta) =>
|
|
234
|
-
`<meta name="theme-color" content="${
|
|
250
|
+
`<meta name="theme-color" content="${escapeHtml(meta.content)}" media="${escapeHtml(meta.media)}">`,
|
|
235
251
|
)
|
|
236
252
|
.join('');
|
|
237
253
|
}
|
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/push.ts
CHANGED
|
@@ -171,28 +171,46 @@ export function pushSource(options: PushSourceOptions = {}): string {
|
|
|
171
171
|
const badging = options.badging === true;
|
|
172
172
|
|
|
173
173
|
return `
|
|
174
|
+
// Parsed INSIDE waitUntil: json() on a body that is not JSON threw out of the listener before it
|
|
175
|
+
// was extended, and nothing was shown. Plain text is the body; JSON that is no object reads as {}.
|
|
176
|
+
function pushData(event){
|
|
177
|
+
if(!event.data)return {};
|
|
178
|
+
let v;
|
|
179
|
+
try{v=event.data.json()}catch(e){return {body:event.data.text()}}
|
|
180
|
+
return v!==null&&typeof v==='object'&&!Array.isArray(v)?v:{}
|
|
181
|
+
}
|
|
174
182
|
self.addEventListener('push',(event)=>{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
event.waitUntil((async()=>{
|
|
184
|
+
const d=pushData(event);
|
|
185
|
+
// renotify needs the tag: showNotification rejects with a TypeError when the flag is set and
|
|
186
|
+
// the tag is empty, and the device shows nothing. renderPushPayload refuses the pair too, but a
|
|
187
|
+
// push body is composed by whatever holds the VAPID key, so this is the last guard there is.
|
|
188
|
+
// A NON-EMPTY STRING, never truthiness: WebIDL converts every tag to a DOMString, and [] and {}
|
|
189
|
+
// are truthy in JS while converting to '' and '[object Object]'. !!d.tag therefore enabled
|
|
190
|
+
// renotify for a JSON [] whose converted tag is empty — the rejection this line exists to avoid.
|
|
191
|
+
const tg=typeof d.tag==='string'&&d.tag!==''?d.tag:undefined;
|
|
192
|
+
const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
|
|
193
|
+
tag:tg,renotify:!!d.renotify&&tg!==undefined,requireInteraction:!!d.requireInteraction,
|
|
194
|
+
lang:d.lang||undefined,actions:Array.isArray(d.actions)?d.actions:[],data:{url:d.url||'/'}};
|
|
195
|
+
await self.registration.showNotification(d.title||'',opts);${
|
|
196
|
+
badging ? '\n if(navigator.setAppBadge)await navigator.setAppBadge();' : ''
|
|
197
|
+
}
|
|
198
|
+
})());
|
|
189
199
|
});
|
|
200
|
+
// A tap opens THIS app only: the URL comes from the push body, whoever composed it. Another host, a
|
|
201
|
+
// non-http scheme, a malformed URL or a non-string opens the app's root instead.
|
|
202
|
+
function tapTarget(raw){
|
|
203
|
+
const root=new URL('/',self.location.origin).href;
|
|
204
|
+
if(typeof raw!=='string')return root;
|
|
205
|
+
try{const u=new URL(raw,self.location.origin);return u.origin===self.location.origin?u.href:root}
|
|
206
|
+
catch(e){return root}
|
|
207
|
+
}
|
|
190
208
|
self.addEventListener('notificationclick',(event)=>{
|
|
191
209
|
event.notification.close();
|
|
192
210
|
// PushPayload.url is a PATH and WindowClient.url is absolute, so the comparison below is only
|
|
193
211
|
// ever true after resolving — unresolved it matched no client at all and every tap opened a
|
|
194
212
|
// second window on an app the user already had open. Same resolve as the fetch block's.
|
|
195
|
-
const url=
|
|
213
|
+
const url=tapTarget(event.notification.data&&event.notification.data.url);
|
|
196
214
|
event.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then((ws)=>{
|
|
197
215
|
for(const w of ws){if(w.url===url&&'focus'in w)return w.focus()}
|
|
198
216
|
return clients.openWindow(url)
|
package/src/route-rules.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* that are no route: content-addressed chunks, cached the first time a page asks for one.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { routeRank } from '@ultimat3/core';
|
|
7
8
|
import { SwScopeInvalidError } from './errors';
|
|
9
|
+
import type { PersonalPages } from './pages-cache-source';
|
|
8
10
|
import type { PwaRoute, StrategyName } from './strategies';
|
|
9
11
|
import { strategyFor } from './strategies';
|
|
10
12
|
|
|
@@ -23,35 +25,8 @@ const segmentsOf = (path: string): readonly string[] =>
|
|
|
23
25
|
path.split('/').filter((segment) => segment.length > 0);
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* worker and the server rank the same pathname the same way.
|
|
29
|
-
*
|
|
30
|
-
* DUPLICATED, not imported: `render` and `pwa` are both tier 4 and a sideways import is a build
|
|
31
|
-
* error. The shared home is `@ultimat3/core`'s `route-vocabulary.ts` — tier 0, already the owner of
|
|
32
|
-
* `RENDER_MODES` / `OFFLINE_STRATEGIES` / `HYDRATE_STRATEGIES` for exactly this reason — and moving
|
|
33
|
-
* it there is the follow-up this comment exists to name.
|
|
34
|
-
*/
|
|
35
|
-
function specificityOf(path: string): number {
|
|
36
|
-
return segmentsOf(path).reduce((total, segment) => {
|
|
37
|
-
if (segment.startsWith('*')) return total + 1;
|
|
38
|
-
if (segment.startsWith(':')) return total + 10;
|
|
39
|
-
return total + 100;
|
|
40
|
-
}, 0);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A catch-all is a FALLBACK, and it sorts behind every rule that is not one — a second key, because
|
|
45
|
-
* a sum over segments cannot say it. `/` has no segments and so scores 0, while `/*rest` scores 1:
|
|
46
|
-
* on specificity alone a single root catch-all outranks the home page, and with it every precached
|
|
47
|
-
* entry in the table. The rule this expresses is the one a reader already assumes — a pattern that
|
|
48
|
-
* matches everything answers only what nothing else claimed.
|
|
49
|
-
*/
|
|
50
|
-
const hasWildcard = (path: string): boolean =>
|
|
51
|
-
segmentsOf(path).some((segment) => segment.startsWith('*'));
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Ordered MOST SPECIFIC FIRST, because the emitted `ruleFor` returns the first pattern that
|
|
28
|
+
* Ordered MOST SPECIFIC FIRST — `@ultimat3/core`'s `routeRank`, the one rule `@ultimat3/render` and
|
|
29
|
+
* the request router share — because the emitted `ruleFor` returns the first pattern that
|
|
55
30
|
* matches and has no notion of specificity of its own. Sorted alphabetically it did not: `:` (0x3A)
|
|
56
31
|
* and `*` (0x2A) both sort before every letter, so `/posts/:id` shadowed `/posts/new` and a single
|
|
57
32
|
* `/*` catch-all shadowed the entire table — every entry in `PRECACHE_MANIFEST` downloaded at
|
|
@@ -66,17 +41,21 @@ const hasWildcard = (path: string): boolean =>
|
|
|
66
41
|
*/
|
|
67
42
|
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
68
43
|
|
|
69
|
-
|
|
44
|
+
/**
|
|
45
|
+
* `personalPages: 'last-member'` routes a personal page by its render mode again — the pages facade
|
|
46
|
+
* partitions it per member. `'never'` (the default) leaves it `network-only`.
|
|
47
|
+
*/
|
|
48
|
+
export function routeRules(
|
|
49
|
+
routes: readonly PwaRoute[],
|
|
50
|
+
personalPages: PersonalPages = 'never',
|
|
51
|
+
): readonly RouteRule[] {
|
|
70
52
|
return [...routes]
|
|
71
53
|
.filter((route) => route.surface !== 'api')
|
|
72
|
-
.sort(
|
|
73
|
-
(a, b) =>
|
|
74
|
-
Number(hasWildcard(a.path)) - Number(hasWildcard(b.path)) ||
|
|
75
|
-
specificityOf(b.path) - specificityOf(a.path) ||
|
|
76
|
-
byCodeUnit(a.path, b.path),
|
|
77
|
-
)
|
|
54
|
+
.sort((a, b) => routeRank(b.path) - routeRank(a.path) || byCodeUnit(a.path, b.path))
|
|
78
55
|
.map((route) => {
|
|
79
|
-
const strategy = strategyFor(
|
|
56
|
+
const strategy = strategyFor(
|
|
57
|
+
personalPages === 'last-member' ? { ...route, personal: false } : route,
|
|
58
|
+
);
|
|
80
59
|
return {
|
|
81
60
|
pattern: toPattern(route.path),
|
|
82
61
|
strategy,
|
|
@@ -85,27 +64,53 @@ export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
|
|
|
85
64
|
});
|
|
86
65
|
}
|
|
87
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A personal page that reaches a cache at all goes to `pages`, never `precache`: it is never in the
|
|
69
|
+
* precache manifest (fetched anonymously at install it is a sign-in redirect), so a `precache` rule
|
|
70
|
+
* was a cache that always missed — and only the pages facade files a member's page by member.
|
|
71
|
+
*/
|
|
88
72
|
function cacheFor(route: PwaRoute, strategy: StrategyName): 'precache' | 'runtime' | 'pages' {
|
|
89
73
|
if (strategy === 'network-only') return 'runtime';
|
|
74
|
+
if (route.personal === true) return 'pages';
|
|
90
75
|
if (route.offline === 'precache' && route.dynamic !== true) return 'precache';
|
|
91
76
|
return 'pages';
|
|
92
77
|
}
|
|
93
78
|
|
|
79
|
+
const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A literal segment as the browser's `url.pathname` spells it — the WHATWG serializer itself, so a
|
|
83
|
+
* route path (the DECODED directory name) matches the PERCENT-ENCODED pathname `ruleFor` tests:
|
|
84
|
+
* `/precios-españa` and `/a b` were never handled. Not `encodeURI`, which also escapes `|`, `[`,
|
|
85
|
+
* `^` and `%` where a browser leaves them literal. A `%XX` escape matches either hex case, as a
|
|
86
|
+
* hand-typed link may carry one.
|
|
87
|
+
*/
|
|
88
|
+
function literal(segment: string): string {
|
|
89
|
+
const url = new URL('https://x.invalid/');
|
|
90
|
+
url.pathname = `/${segment}`;
|
|
91
|
+
return escapeRegex(url.pathname.slice(1)).replace(
|
|
92
|
+
/%([0-9A-Fa-f]{2})/g,
|
|
93
|
+
(_, hex: string) =>
|
|
94
|
+
`%${[...hex].map((c) => (/[A-Fa-f]/.test(c) ? `[${c.toUpperCase()}${c.toLowerCase()}]` : c)).join('')}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
94
98
|
/**
|
|
95
99
|
* A trailing slash is dropped before the pattern is built, because the pattern allows one anyway:
|
|
96
|
-
* a locale's home is spelled `/en/`, and `^/en//?$` matched `/en/` and missed `/en`.
|
|
100
|
+
* a locale's home is spelled `/en/`, and `^/en//?$` matched `/en/` and missed `/en`. A catch-all
|
|
101
|
+
* takes its leading slash with it, `(?:/.*)?`, so `/docs/*path` matches the bare `/docs` that
|
|
102
|
+
* `@ultimat3/render`'s static export writes for an empty `path`.
|
|
97
103
|
*/
|
|
98
104
|
function toPattern(path: string): string {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
const body =
|
|
102
|
-
.split('/')
|
|
105
|
+
const segments = segmentsOf(path);
|
|
106
|
+
if (segments.length === 0) return '^/$';
|
|
107
|
+
const body = segments
|
|
103
108
|
.map((segment) => {
|
|
104
|
-
if (segment.startsWith(':')) return '[^/]+';
|
|
105
|
-
if (segment.startsWith('*')) return '
|
|
106
|
-
return
|
|
109
|
+
if (segment.startsWith(':')) return '/[^/]+';
|
|
110
|
+
if (segment.startsWith('*')) return '(?:/.*)?';
|
|
111
|
+
return `/${literal(segment)}`;
|
|
107
112
|
})
|
|
108
|
-
.join('
|
|
113
|
+
.join('');
|
|
109
114
|
return `^${body}/?$`;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -125,7 +130,7 @@ export function assetRules(prefixes: readonly string[], scope: string): readonly
|
|
|
125
130
|
);
|
|
126
131
|
}
|
|
127
132
|
return {
|
|
128
|
-
pattern: `^${prefix
|
|
133
|
+
pattern: `^${escapeRegex(prefix)}`,
|
|
129
134
|
strategy: 'cache-first',
|
|
130
135
|
cache: 'runtime',
|
|
131
136
|
asset: true,
|
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;
|
|
@@ -123,13 +118,12 @@ export function generateServiceWorker(
|
|
|
123
118
|
|
|
124
119
|
const fallback = requireOfflineFallback(config.offline);
|
|
125
120
|
const capabilities = resolveCapabilities(config.capabilities);
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const rules = [...assetRules(config.runtimeAssets ?? [], scope), ...routeRules(routed)];
|
|
121
|
+
// The precache below skips a personal page in both modes: fetched anonymously at install, it is a
|
|
122
|
+
// sign-in redirect. `routeRules` decides where `last-member` keeps it instead — the pages facade.
|
|
123
|
+
const rules = [
|
|
124
|
+
...assetRules(config.runtimeAssets ?? [], scope),
|
|
125
|
+
...routeRules(routes, fallback.personalPages),
|
|
126
|
+
];
|
|
133
127
|
|
|
134
128
|
const precache = buildPrecacheManifest({
|
|
135
129
|
buildId,
|
|
@@ -163,13 +157,13 @@ export function generateServiceWorker(
|
|
|
163
157
|
INSTALL_BLOCK,
|
|
164
158
|
activateBlock(),
|
|
165
159
|
fetchBlock(fallback.personalPages),
|
|
166
|
-
messageBlock(
|
|
160
|
+
messageBlock(hasCapability(capabilities, 'backgroundSync')),
|
|
167
161
|
];
|
|
168
162
|
|
|
169
|
-
if (
|
|
170
|
-
blocks.push(pushSource({ badging:
|
|
163
|
+
if (hasCapability(capabilities, 'push') && config.vapid !== undefined) {
|
|
164
|
+
blocks.push(pushSource({ badging: hasCapability(capabilities, 'badging') }));
|
|
171
165
|
}
|
|
172
|
-
if (
|
|
166
|
+
if (hasCapability(capabilities, 'backgroundSync')) {
|
|
173
167
|
blocks.push(backgroundSyncSource());
|
|
174
168
|
}
|
|
175
169
|
|
|
@@ -278,7 +272,9 @@ self.addEventListener('install',(event)=>{
|
|
|
278
272
|
const e=PRECACHE_MANIFEST[i++];
|
|
279
273
|
try{
|
|
280
274
|
const r=await fetch(new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
|
|
281
|
-
|
|
275
|
+
// Mid-rollout an OLD pod may answer: its document is not this build's offline copy.
|
|
276
|
+
const b=r.headers.get(BUILD_HEADER);
|
|
277
|
+
if(r.ok&&(b===null||b===BUILD_ID))await cache.put(new Request(e.url),r);
|
|
282
278
|
}catch(err){}
|
|
283
279
|
}
|
|
284
280
|
};
|
|
@@ -299,7 +295,7 @@ self.addEventListener('activate',(event)=>{
|
|
|
299
295
|
// the fetch handler answers from it (net) — start-up is off every navigation's critical path.
|
|
300
296
|
try{if(self.registration&&self.registration.navigationPreload)await self.registration.navigationPreload.enable()}catch(e){}
|
|
301
297
|
const cs=await self.clients.matchAll({type:'window'});
|
|
302
|
-
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});
|
|
303
299
|
// NOT part of this waitUntil: a fetch event waits for the worker to finish ACTIVATING, so a
|
|
304
300
|
// warm-up that copies a streamed page's whole body in here held every request of the tab it
|
|
305
301
|
// just claimed — /feed's own reads included — until that body had ended. Kicked off, never
|
|
@@ -390,7 +386,7 @@ function seenBuild(res){
|
|
|
390
386
|
const server=res.headers.get(BUILD_HEADER);
|
|
391
387
|
if(SKEWED||server===null||server===BUILD_ID)return;
|
|
392
388
|
SKEWED=true;
|
|
393
|
-
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(()=>{});
|
|
394
390
|
}
|
|
395
391
|
function withBuild(headers){
|
|
396
392
|
const h=new Headers(headers);
|
|
@@ -409,7 +405,7 @@ async function healSkew(req,res){
|
|
|
409
405
|
// Answering the request for real is what breaks that loop.
|
|
410
406
|
SKEWED=true;
|
|
411
407
|
const cs=await self.clients.matchAll({type:'window'});
|
|
412
|
-
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});
|
|
413
409
|
return fetch(new Request(req,{headers:withBuild(req.headers)}));
|
|
414
410
|
}`.trim();
|
|
415
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;
|