@ultimat3/pwa 26.0.0 → 26.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -4,16 +4,23 @@ Owns: `sw.js` generation, caching strategies, precache manifest, version skew, w
4
4
  manifest, icons, offline fallback, background-sync trigger, push, install prompt.
5
5
 
6
6
  Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cache`, `seo`,
7
- `entity`, `policy`, `http`, `action`, `query`. **Never** `render`, `mcp`, `ai`, `manifest`
8
- (sideways), never `ui`/`cli` (upward).
7
+ `entity`, `policy`, `http`, `action`, `query`. **Never** `render`, `mcp`, `ai`, `manifest`,
8
+ `notify` (sideways), never `ui`/`cli` (upward). Tier 4 is its FLOOR since 26.1.0 — `pushSubscribe`
9
+ builds an `action` — so it has no `FLOOR_ABOVE` row; `render` keeps one and holds the pair level.
10
+
11
+ Three entry points: `.` (server), `./client` (`client.ts` — THE browser entry: install prompt,
12
+ skew, the browser half of push; its graph is `install.ts`, `skew.ts`, `push-client.ts` and
13
+ `@ultimat3/core/page`, held by `client-bundle.test.ts` — never import `@ultimat3/core`'s barrel or
14
+ `errors.ts` from those three) and `./schema` (`push-schema.ts` — the one DDL leaf every
15
+ role's boot loads, `@ultimat3/mcp/schema`'s shape; `serve-graph.test.ts` exempts it by name).
9
16
 
10
17
  | Rule | Detail |
11
18
  |---|---|
12
19
  | `sw.js` | generated, never hand-written, never committed. No file in this package is a SW. |
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. |
20
+ | 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. **Key material is the one exception, and it is injectable**: RFC 8291 needs a fresh salt and one-message ECDH key per push (reusing either reuses a GCM nonce under one key) — `encryptPushMessage`'s `options.salt` / `options.ephemeral` are the seam the Appendix A test pins — and `generateVapidKeys` rolls a pair. Every clock read in push (`vapidAuthorization`'s `exp`, expiry) takes a `Clock`. |
14
21
  | 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
22
  | 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` / `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. |
23
+ | 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 wired too, `As of 26.1.0`** (#710): the boot (`@ultimat3/cli`'s `runtime-push.ts`) resolves the VAPID pair and calls `installWebPush`, the web role hands `installedVapid()` to `generateServiceWorker` (push handler) and the head (`<meta name="x-push-key">`), and a static export — no runtime behind it — is the one build `serviceWorkerWarnings` still reports |
17
24
  | Route input | `PwaRoute` is a **structural** view of render's `RouteDescriptor`. Never import render. |
18
25
  | 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
26
  | 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. |
@@ -44,7 +51,11 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
44
51
  | 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. |
45
52
  | 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. |
46
53
  | 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. |
47
- | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
54
+ | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. The runtime's `translate` defaults to `@ultimat3/i18n`'s `translatorFor(subscription.locale)` — the app's registered catalogs. |
55
+ | Web Push crypto | WebCrypto only, no dependency (`docs/idea/18-build-vs-wrap.md`). `push-encrypt.ts` is RFC 8291 + RFC 8188 (one `aes128gcm` record, delimiter `0x02`, no padding) and is pinned by RFC 8291 Appendix A byte for byte, every intermediate included; it is `scripts/seal-calls.ts`'s `STANDARD_CIPHERS` row, because the key is agreed with the subscriber's browser and is never the app's — `seal()` cannot be what it calls. `vapid.ts` is RFC 8292: `aud` is the endpoint's ORIGIN, `exp` 12 h (≤ 24 h), the signature WebCrypto's raw `r‖s` (= JWS ES256). WebCrypto does not check that `d` belongs to `x`/`y`, so `assertVapidPair` signs and verifies a probe — the boot runs it once. |
56
+ | VAPID keys | env, never config: `ULTIMATE_VAPID_PUBLIC_KEY` + `ULTIMATE_VAPID_PRIVATE_KEY`, printable formats (`web-push`'s). `resolveVapidKeys(env)`: both set → checked pair; neither, locally → `DEV_VAPID_KEYS` (published); neither or the dev key when `devSecretsRefused` (core's rule, fails closed), or half a pair anywhere → `X_PWA_VAPID_KEY_MISSING`. `pwa.vapid` holds the `subject` only. |
57
+ | Push sending | `sendPushMessage` → `delivered` (2xx), `gone` (404/410 — delete, never retry), or a throw: `X_PWA_PUSH_FAILED` (`retry-after`: 429 / 5xx / no connection, `Retry-After` in `meta`), `X_PWA_PUSH_REJECTED` (terminal). `pushToActor` tries EVERY device, deletes the gone, the expired and the 401/403-refused (made with another VAPID key), logs the refused, and rethrows the first retryable failure LAST — so the notifier job retries, and devices that got it get it again; the `tag` is what makes that a replacement. The endpoint must be `https:` on a LISTED push service (`push-hosts.ts`: the built-in four plus `pwa.vapid.pushHosts`, dot-boundary suffix match), checked at subscribe and at send: it came from a request body, and a hostname resolves wherever its owner points it — the list, never the name's shape or its DNS answer, is the screen. `redirect: 'manual'`, a `PUSH_SEND_TIMEOUT_MS` deadline per POST, and a caller's abort rethrown as is — never the retryable class. |
58
+ | Subscriptions | keyed by ENDPOINT (a browser subscribed by a second person is theirs now), stored against `ctx.actor.id` — never an id from the body. `pushSubscribe` / `pushUnsubscribe` are factories over `action` (`PRIMITIVE_FACTORIES` rows); an agent is refused in the policy. `x_push_subscriptions` is DDL applied by the boot (`FRAMEWORK_SCHEMA`), never an `entity()`. |
48
59
  | `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. |
49
60
  | 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`. |
50
61
  | 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. |
package/README.md CHANGED
@@ -8,6 +8,32 @@ the second copy is the one nobody updates.
8
8
  const { source, precache, warnings } = generateServiceWorker(describePages(), config, buildId);
9
9
  ```
10
10
 
11
+ ## Browser entry: `@ultimat3/pwa/client`
12
+
13
+ `As of 26.1.0`. Everything a page or an island calls comes from **`@ultimat3/pwa/client`**, never
14
+ from `@ultimat3/pwa`: the barrel reaches the icon pipeline (core's image transform, Node-only), the
15
+ service-worker generator and the push sender, and an island importing it does not build.
16
+
17
+ ```ts
18
+ import {
19
+ detectSkew,
20
+ installController,
21
+ MIN_ENGAGEMENT_MS,
22
+ subscribeToPush,
23
+ } from '@ultimat3/pwa/client';
24
+ import { APP_UPDATE_MESSAGE, CLIENT_BUILD_META } from '@ultimat3/core/page';
25
+ ```
26
+
27
+ | Need | From `@ultimat3/pwa/client` |
28
+ |---|---|
29
+ | the install prompt, never on first paint | `installController` (+ `InstallOptions`, `InstallHost`, `InstallController`, `InstallOutcome`, `BeforeInstallPromptEventLike`, `ReadSignal`), `iosInstallGuidance` (+ `IosGuidance`), `MIN_ENGAGEMENT_MS` (30 000 — the default `minEngagementMs`) |
30
+ | "a new version is live" | `AppUpdateAvailable` (the worker's message), `detectSkew` (+ `SkewState`). The message's `type` value is core's `APP_UPDATE_MESSAGE`, from `@ultimat3/core/page` — one name, one home |
31
+ | Web Push | `subscribeToPush`, `unsubscribeFromPush`, `pushPermission`, `browserPushHost`, `PUSH_KEY_META` (+ their types) |
32
+
33
+ Its graph is three modules of this package plus `@ultimat3/core/page`; `client-bundle.test.ts`
34
+ bundles it for the browser and refuses a fourth, an image module or a `node:` import. The server
35
+ barrel keeps exporting the same names for server code and tests.
36
+
11
37
  ## Render mode → runtime strategy
12
38
 
13
39
  | Render mode | Strategy | Why |
@@ -72,7 +98,7 @@ placeholders a failed image or font request gets — each served only from the p
72
98
 
73
99
  | Capability | Manifest member | SW code |
74
100
  |---|---|---|
75
- | `push` | — | `push` + `notificationclick` listeners; a tap opens a same-origin URL only |
101
+ | `push` | — | `push` + `notificationclick` listeners; a tap opens a same-origin URL only. Emitted only when a `vapid` key comes with it — the web role passes `installedVapid()` |
76
102
  | `backgroundSync` | — | `sync` listener that tells every open tab to drain realtime's outbox (`OUTBOX_DRAIN_MESSAGE`) |
77
103
  | `badging` | — | `navigator.setAppBadge` after a push |
78
104
  | `shareTarget` | `share_target` | — |
@@ -88,6 +114,59 @@ gate. `CAPABILITY_SW_MARKERS` is checked against the emitted `sw.js` in both dir
88
114
  here that the generator does not honour is a failing test rather than an installed app announcing a
89
115
  capability nothing implements.
90
116
 
117
+ ## Web Push
118
+
119
+ `pwa: { push: true, vapid: { subject: 'mailto:ops@example.com' } }` in `app.config.ts`, and the rest
120
+ is wiring the framework does: the boot resolves the VAPID pair from the environment and installs the
121
+ runtime, the worker carries the handlers, every page carries `<meta name="x-push-key">`. RFC 8291
122
+ encryption and RFC 8292 (ES256) signing, on WebCrypto — no dependency.
123
+
124
+ ```ts
125
+ // apps/web/app/push/actions.ts — two real actions: route, OpenAPI, typed client, contract tests.
126
+ import { pushSubscribe, pushUnsubscribe } from '@ultimat3/pwa';
127
+
128
+ export const subscribePush = pushSubscribe({ permission: 'push:subscribe' });
129
+ export const unsubscribePush = pushUnsubscribe({ permission: 'push:subscribe' });
130
+ ```
131
+
132
+ ```ts
133
+ // an island, on a click — the browser entry, `@ultimat3/pwa/client`.
134
+ import { subscribeToPush } from '@ultimat3/pwa/client';
135
+
136
+ declare const client: { subscribePush(input: unknown): Promise<unknown> };
137
+
138
+ const outcome = await subscribeToPush({ save: client.subscribePush });
139
+ // { status: 'subscribed', endpoint } | { status: 'denied' } | { status: 'unsupported' } | { status: 'unconfigured' }
140
+ ```
141
+
142
+ ```ts
143
+ // a notifier — every device of the recipient, each in the locale it subscribed with.
144
+ import { pushChannel } from '@ultimat3/notify';
145
+ import { webPush } from '@ultimat3/pwa';
146
+
147
+ export const channel = pushChannel<{ postId: string }>({
148
+ pusher: webPush(),
149
+ message: ({ event }) => ({
150
+ titleKey: 'push.comment.title',
151
+ bodyKey: 'push.comment.body',
152
+ url: `/posts/${event.params.postId}`,
153
+ tag: `post:${event.params.postId}`,
154
+ }),
155
+ });
156
+ ```
157
+
158
+ | Piece | Rule |
159
+ |---|---|
160
+ | Keys | `ULTIMATE_VAPID_PUBLIC_KEY` + `ULTIMATE_VAPID_PRIVATE_KEY`, env only. `x vapid create` seals both in one write; locally, neither set signs with `DEV_VAPID_KEYS`, which a deployed boot refuses (`X_PWA_VAPID_KEY_MISSING`). Two halves that do not sign for each other: `X_PWA_VAPID_KEY_INVALID`, at boot |
161
+ | Subscriptions | `x_push_subscriptions`, applied by the boot; keyed by endpoint, stored against `ctx.actor.id`, with the locale and zone they subscribed in. An agent never subscribes |
162
+ | Strings | catalog keys (`titleKey`, `bodyKey`, `actions[].titleKey`), rendered per subscription locale through `@ultimat3/i18n`'s `translatorFor` |
163
+ | Size | one 4096-byte record: 3993 bytes of notification (`X_PWA_PUSH_PAYLOAD_TOO_LARGE`). Send a path, never the content |
164
+ | Travel | `ttlSeconds` (default 86 400), `urgency`, `topic` — RFC 8030 headers; the topic is any string, sent as 32 URL-safe characters of its SHA-256 |
165
+ | 201 / 404, 410 / 429, 5xx / 400, 401, 403, 413 | delivered / subscription deleted / `X_PWA_PUSH_FAILED`, retried by the job after every other device was tried / `X_PWA_PUSH_REJECTED`, logged, never retried — and on 401/403 the subscription is deleted: it was made with a key this server no longer signs with |
166
+ | Key rotation | `subscribeToPush` compares a browser's existing subscription with the page's `x-push-key`; one made with another key is unsubscribed and replaced, never re-saved |
167
+ | Endpoints | dialled only on a push service: `https:`, and a host that is one of `PUSH_SERVICE_HOSTS` (FCM, Mozilla, Apple, WNS) or `pwa.vapid.pushHosts`, or a subdomain of one on a dot boundary — checked when stored and before every send (`assertPushEndpoint`), never by where the name resolves, since the endpoint came from a request body (`X_PWA_PUSH_HOST_UNLISTED`; a stored one is deleted unsent). One POST waits at most `PUSH_SEND_TIMEOUT_MS` (30 s); a caller's abort is rethrown as is, never the retryable class |
168
+ | `renotify` | dropped, with a warning, without a `tag` — at both ends |
169
+
91
170
  ## Error classes
92
171
 
93
172
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
@@ -99,7 +178,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
99
178
  | `PwaIconMissingError` | `X_PWA_ICON_MISSING` | `src/errors.ts` |
100
179
  | `PwaManifestInvalidError` | `X_PWA_MANIFEST_INVALID` | `src/errors.ts` |
101
180
  | `PwaNoOfflineFallbackError` | `X_PWA_NO_OFFLINE_FALLBACK` | `src/errors.ts` |
181
+ | `PwaPushFailedError` | `X_PWA_PUSH_FAILED` | `src/errors.ts` |
182
+ | `PwaPushPayloadTooLargeError` | `X_PWA_PUSH_PAYLOAD_TOO_LARGE` | `src/errors.ts` |
183
+ | `PwaPushRejectedError` | `X_PWA_PUSH_REJECTED` | `src/errors.ts` |
184
+ | `PwaPushSubscriptionInvalidError` | `X_PWA_PUSH_SUBSCRIPTION_INVALID` | `src/errors.ts` |
185
+ | `PwaPushUnconfiguredError` | `X_PWA_PUSH_UNCONFIGURED` | `src/errors.ts` |
102
186
  | `PwaStrategyExhaustedError` | `X_PWA_STRATEGY_EXHAUSTED` | `src/errors.ts` |
187
+ | `PwaVapidKeyInvalidError` | `X_PWA_VAPID_KEY_INVALID` | `src/errors.ts` |
188
+ | `PwaVapidKeyMissingError` | `X_PWA_VAPID_KEY_MISSING` | `src/errors.ts` |
103
189
  | `SwScopeInvalidError` | `X_SW_SCOPE_INVALID` | `src/errors.ts` |
104
190
 
105
191
  ## Public API
@@ -111,14 +197,21 @@ a job boundary the class is gone and the `code` is what survives — match on th
111
197
  | `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
198
  | `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
199
  | `buildPrecacheManifest` | precache entries (url + content-hash revision), size warnings |
114
- | `buildId`, `detectSkew`, `retentionPlan` | version skew |
200
+ | `buildId`, `detectSkew`, `retentionPlan` | version skew (`detectSkew` also on `./client`) |
115
201
  | `generateWebManifest` | the manifest + `theme-color` metas for both schemes, from a `WebManifestInput`. Called by `@ultimat3/cli` (`pwa-artifacts.ts`) `As of 2026-08-27`, so `x dev`, the container and the static export all emit `manifest.webmanifest` |
116
202
  | `planIcons`, `requireSourceIcon`, `maskableSafeZone` | icons and splashes from one source |
117
203
  | `BuiltinImagePipeline` | renders that plan: one square PNG per entry, deterministic |
118
204
  | `requireOfflineFallback` | the mandatory offline route |
119
205
  | `backgroundSyncSource`, `registerBackgroundSyncSource` | the Background Sync trigger. No retry policy: the handler rejects and the PLATFORM reschedules it |
120
206
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
121
- | `installController`, `iosInstallGuidance` | install prompt, never on first paint |
207
+ | `pushSubscribe`, `pushUnsubscribe` | factories over `action`: store / forget this browser's subscription for `ctx.actor` |
208
+ | `webPush`, `pushToActor` | one notification to every device of one person — `webPush()` is notify's `Pusher` |
209
+ | `sendPushMessage`, `encryptPushMessage`, `vapidAuthorization` | one message to one subscription; RFC 8291; RFC 8292 |
210
+ | `installWebPush`, `installedVapid`, `webPushRuntime`, `resetWebPush` | the runtime the boot installs (store, signer, transport, translator) |
211
+ | `memoryPushSubscriptionStore`, `postgresPushSubscriptionStore` | where subscriptions live; `x_push_subscriptions` DDL is `@ultimat3/pwa/schema` |
212
+ | `resolveVapidKeys`, `generateVapidKeys`, `assertVapidPair`, `DEV_VAPID_KEYS` | the key pair: from env, minted, checked, and the published development one |
213
+ | `@ultimat3/pwa/client` | THE browser entry — [Browser entry](#browser-entry-ultimat3pwaclient) |
214
+ | `installController`, `iosInstallGuidance`, `MIN_ENGAGEMENT_MS` | install prompt, never on first paint (also on `./client`) |
122
215
  | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
123
216
 
124
217
  ## Notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "26.0.0",
3
+ "version": "26.1.1",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,9 @@
14
14
  "provenance": true
15
15
  },
16
16
  "exports": {
17
- ".": "./src/index.ts"
17
+ ".": "./src/index.ts",
18
+ "./client": "./src/client.ts",
19
+ "./schema": "./src/push-schema.ts"
18
20
  },
19
21
  "files": [
20
22
  "src",
@@ -32,6 +34,10 @@
32
34
  "test": "bun test"
33
35
  },
34
36
  "dependencies": {
35
- "@ultimat3/core": "26.0.0"
37
+ "@ultimat3/action": "26.1.1",
38
+ "@ultimat3/core": "26.1.1",
39
+ "@ultimat3/i18n": "26.1.1",
40
+ "@ultimat3/policy": "26.1.1",
41
+ "@ultimat3/schema": "26.1.1"
36
42
  }
37
43
  }
package/src/client.ts ADDED
@@ -0,0 +1,37 @@
1
+ // `@ultimat3/pwa/client` — THE browser entry of this package: everything an island or a page script
2
+ // may call, and nothing that drags the server half along. The `.` barrel reaches the icon pipeline
3
+ // (core's image transform, Node-only), the service-worker generator and the push sender; this
4
+ // entry's graph is these four files and `@ultimat3/core/page`, held there by `client-bundle.test.ts`.
5
+ //
6
+ // import { detectSkew, installController, subscribeToPush } from '@ultimat3/pwa/client';
7
+ //
8
+ // The worker's message TYPE (`'AppUpdateAvailable'`) is core's `APP_UPDATE_MESSAGE`, imported from
9
+ // `@ultimat3/core/page` — one name, one home (`X_HELPER_COPY`); `AppUpdateAvailable` types it here.
10
+
11
+ export type {
12
+ BeforeInstallPromptEventLike,
13
+ InstallController,
14
+ InstallHost,
15
+ InstallOptions,
16
+ InstallOutcome,
17
+ IosGuidance,
18
+ ReadSignal,
19
+ } from './install';
20
+ export { installController, iosInstallGuidance, MIN_ENGAGEMENT_MS } from './install';
21
+ export type {
22
+ PushClientHost,
23
+ PushPermission,
24
+ PushRegistrationLike,
25
+ PushSubscribeOutcome,
26
+ PushSubscriptionInput,
27
+ PushSubscriptionLike,
28
+ } from './push-client';
29
+ export {
30
+ browserPushHost,
31
+ PUSH_KEY_META,
32
+ pushPermission,
33
+ subscribeToPush,
34
+ unsubscribeFromPush,
35
+ } from './push-client';
36
+ export type { AppUpdateAvailable, SkewState } from './skew';
37
+ export { detectSkew } from './skew';
package/src/errors.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * fallback, a missing build id, a bad scope — fails here at build time instead.
4
4
  */
5
5
 
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
7
7
 
8
8
  /** Codes this package declares and owns. */
9
9
  export const PWA_OWNED_ERROR_CODES = [
@@ -17,6 +17,14 @@ export const PWA_OWNED_ERROR_CODES = [
17
17
  // POSTing to `/_x/outbox/flush` (`background-sync.ts`). Registered so an old log still explains.
18
18
  'X_PWA_SYNC_FLUSH_FAILED',
19
19
  'X_PWA_SYNC_INCOMPLETE',
20
+ 'X_PWA_VAPID_KEY_MISSING',
21
+ 'X_PWA_VAPID_KEY_INVALID',
22
+ 'X_PWA_PUSH_UNCONFIGURED',
23
+ 'X_PWA_PUSH_FAILED',
24
+ 'X_PWA_PUSH_REJECTED',
25
+ 'X_PWA_PUSH_PAYLOAD_TOO_LARGE',
26
+ 'X_PWA_PUSH_SUBSCRIPTION_INVALID',
27
+ 'X_PWA_PUSH_HOST_UNLISTED',
20
28
  ] as const;
21
29
 
22
30
  /**
@@ -41,6 +49,14 @@ export const PWA_ERROR_TITLES: Readonly<Record<PwaOwnedErrorCode, string>> = {
41
49
  X_PWA_STRATEGY_EXHAUSTED: 'a caching strategy had no cache, no network and no fallback',
42
50
  X_PWA_SYNC_FLUSH_FAILED: 'the background-sync outbox flush was rejected',
43
51
  X_PWA_SYNC_INCOMPLETE: 'the background-sync outbox flush left mutations queued',
52
+ X_PWA_VAPID_KEY_MISSING: 'no VAPID key pair for push',
53
+ X_PWA_VAPID_KEY_INVALID: 'the VAPID key pair is malformed or not a pair',
54
+ X_PWA_PUSH_UNCONFIGURED: 'push is not configured in this process',
55
+ X_PWA_PUSH_FAILED: 'the push service did not accept the message, and may later',
56
+ X_PWA_PUSH_REJECTED: 'the push service refused the message and will refuse it again',
57
+ X_PWA_PUSH_PAYLOAD_TOO_LARGE: 'the push message does not fit one 4096-byte record',
58
+ X_PWA_PUSH_SUBSCRIPTION_INVALID: 'the push subscription is not one a browser produced',
59
+ X_PWA_PUSH_HOST_UNLISTED: 'the push endpoint is not on a push service this app sends to',
44
60
  };
45
61
 
46
62
  // One unconditional call, so a second package claiming one of pwa's codes throws
@@ -131,3 +147,142 @@ export class PwaStrategyExhaustedError extends UltimateError {
131
147
  });
132
148
  }
133
149
  }
150
+
151
+ // Web Push. `X_PWA_PUSH_FAILED` is the one a retry can cure — the same message landing later —
152
+ // so it is `retry-after` (a 429's `Retry-After` is honoured through `meta.retryAfterSeconds`); the
153
+ // rest describe the key, the subscription or the body, and every attempt meets them unchanged.
154
+ registerErrorRetry({
155
+ X_PWA_PUSH_FAILED: 'retry-after',
156
+ X_PWA_PUSH_REJECTED: 'terminal',
157
+ X_PWA_PUSH_PAYLOAD_TOO_LARGE: 'terminal',
158
+ X_PWA_PUSH_SUBSCRIPTION_INVALID: 'terminal',
159
+ X_PWA_PUSH_HOST_UNLISTED: 'terminal',
160
+ X_PWA_VAPID_KEY_MISSING: 'terminal',
161
+ X_PWA_VAPID_KEY_INVALID: 'terminal',
162
+ X_PWA_PUSH_UNCONFIGURED: 'terminal',
163
+ });
164
+
165
+ /** The boot refuses push with no key pair in a deployed environment. */
166
+ export class PwaVapidKeyMissingError extends UltimateError {
167
+ static readonly code = 'X_PWA_VAPID_KEY_MISSING' as const;
168
+ constructor(input: { missing: readonly string[]; deployed: boolean }) {
169
+ const unset = `${input.missing.join(' and ')} ${input.missing.length === 1 ? 'is' : 'are'} unset`;
170
+ super({
171
+ code: PwaVapidKeyMissingError.code,
172
+ cause: input.deployed
173
+ ? `pwa.push is true and ${unset} (or holds the published development key), and a deployed environment never signs with the development pair`
174
+ : `pwa.push is true and ${unset} while its other half is set — half a pair is never completed with the development one`,
175
+ fix: "x vapid create # seals a fresh pair into secrets.enc.json; or set ULTIMATE_VAPID_PUBLIC_KEY and ULTIMATE_VAPID_PRIVATE_KEY in the deployment's environment",
176
+ meta: { missing: [...input.missing] },
177
+ });
178
+ }
179
+ }
180
+
181
+ /** A key that is not a P-256 key, or a private key that does not sign for its public half. */
182
+ export class PwaVapidKeyInvalidError extends UltimateError {
183
+ static readonly code = 'X_PWA_VAPID_KEY_INVALID' as const;
184
+ constructor(input: { key: string; reason: string }) {
185
+ super({
186
+ code: PwaVapidKeyInvalidError.code,
187
+ cause: `${input.key} ${input.reason}`,
188
+ fix: 'x vapid create # one command writes both halves, so they cannot disagree',
189
+ meta: { key: input.key },
190
+ });
191
+ }
192
+ }
193
+
194
+ /** Push used in a process whose boot installed no Web Push runtime. */
195
+ export class PwaPushUnconfiguredError extends UltimateError {
196
+ static readonly code = 'X_PWA_PUSH_UNCONFIGURED' as const;
197
+ constructor(input: { operation: string }) {
198
+ super({
199
+ code: PwaPushUnconfiguredError.code,
200
+ cause: `${input.operation} ran in a process whose boot installed no Web Push runtime: pwa.push is false, or pwa.enabled is`,
201
+ fix: "pwa: { enabled: true, push: true, vapid: { subject: 'mailto:ops@example.com' } } # in app.config.ts; a test calls installWebPush({ store, keys, subject }) first",
202
+ });
203
+ }
204
+ }
205
+
206
+ /** The push service may accept the same message later: a 429, a 5xx, or no connection at all. */
207
+ export class PwaPushFailedError extends UltimateError {
208
+ static readonly code = 'X_PWA_PUSH_FAILED' as const;
209
+ constructor(input: {
210
+ origin: string;
211
+ status: number | null;
212
+ detail: string;
213
+ retryAfterSeconds?: number | undefined;
214
+ }) {
215
+ super({
216
+ code: PwaPushFailedError.code,
217
+ cause: `the push service at ${input.origin} answered ${input.status === null ? input.detail : `status ${input.status}`}`,
218
+ fix: 'x jobs list --json # the job retries on its policy; a stated Retry-After rides in meta.retryAfterSeconds',
219
+ meta: {
220
+ origin: input.origin,
221
+ status: input.status,
222
+ ...(input.retryAfterSeconds === undefined
223
+ ? {}
224
+ : { retryAfterSeconds: input.retryAfterSeconds }),
225
+ },
226
+ });
227
+ }
228
+ }
229
+
230
+ /** The push service refused the message, and will refuse it again. */
231
+ export class PwaPushRejectedError extends UltimateError {
232
+ static readonly code = 'X_PWA_PUSH_REJECTED' as const;
233
+ constructor(input: { origin: string; status: number; body: string }) {
234
+ super({
235
+ code: PwaPushRejectedError.code,
236
+ cause: `the push service at ${input.origin} refused the message with status ${input.status}${input.body === '' ? '' : `: ${input.body}`}`,
237
+ fix:
238
+ input.status === 403 || input.status === 401
239
+ ? 'x vapid show # the subscription was made with another public key: a key change means every subscriber subscribes again'
240
+ : "vapid: { subject: 'mailto:ops@example.com' } # pwa.vapid in app.config.ts — the push service's answer in the cause names the field it refused",
241
+ meta: { origin: input.origin, status: input.status },
242
+ });
243
+ }
244
+ }
245
+
246
+ /** One record is 4096 bytes; RFC 8291 leaves 3993 of them for the notification. */
247
+ export class PwaPushPayloadTooLargeError extends UltimateError {
248
+ static readonly code = 'X_PWA_PUSH_PAYLOAD_TOO_LARGE' as const;
249
+ constructor(input: { bytes: number; limit: number }) {
250
+ super({
251
+ code: PwaPushPayloadTooLargeError.code,
252
+ cause: `the rendered notification is ${input.bytes} bytes and one Web Push record carries ${input.limit}`,
253
+ fix: "url: '/posts/1' # send a path to the content and shorten the catalog strings — never the content itself",
254
+ meta: { bytes: input.bytes, limit: input.limit },
255
+ });
256
+ }
257
+ }
258
+
259
+ /** A subscription no browser produced: a non-https endpoint, or keys of the wrong size. */
260
+ export class PwaPushSubscriptionInvalidError extends UltimateError {
261
+ static readonly code = 'X_PWA_PUSH_SUBSCRIPTION_INVALID' as const;
262
+ constructor(input: { field: string; reason: string }) {
263
+ super({
264
+ code: PwaPushSubscriptionInvalidError.code,
265
+ cause: `${input.field} ${input.reason}`,
266
+ fix: "import { subscribeToPush } from '@ultimat3/pwa/client'; // it sends PushSubscription.toJSON() unchanged",
267
+ meta: { field: input.field },
268
+ });
269
+ }
270
+ }
271
+
272
+ /**
273
+ * An endpoint whose host is not a push service this app sends to. The endpoint arrives in a request
274
+ * body, and a hostname can resolve anywhere — a private address, the metadata service — so the one
275
+ * screen that holds is a list of the services themselves (`push-hosts.ts`), not a check of where a
276
+ * name points today.
277
+ */
278
+ export class PwaPushHostUnlistedError extends UltimateError {
279
+ static readonly code = 'X_PWA_PUSH_HOST_UNLISTED' as const;
280
+ constructor(input: { host: string }) {
281
+ super({
282
+ code: PwaPushHostUnlistedError.code,
283
+ cause: `the endpoint's host ${input.host} is neither a built-in push service (PUSH_SERVICE_HOSTS: FCM, Mozilla, Apple, WNS, or a subdomain of one) nor listed in pwa.vapid.pushHosts`,
284
+ fix: "vapid: { subject: 'mailto:ops@example.com', pushHosts: ['push.example.com'] } # pwa.vapid.pushHosts in app.config.ts, for a push service the built-in list does not name",
285
+ meta: { host: input.host },
286
+ });
287
+ }
288
+ }
package/src/index.ts CHANGED
@@ -30,7 +30,15 @@ export {
30
30
  // `staleWhileRevalidate` is public and throws this, so an app that catches it can name it. The
31
31
  // two X_PWA_SYNC_* classes are not here on purpose: the emitted `sw.js` builds its own local
32
32
  // class in a realm with no bundler, so no instance of theirs can ever reach an app.
33
+ PwaPushFailedError,
34
+ PwaPushHostUnlistedError,
35
+ PwaPushPayloadTooLargeError,
36
+ PwaPushRejectedError,
37
+ PwaPushSubscriptionInvalidError,
38
+ PwaPushUnconfiguredError,
33
39
  PwaStrategyExhaustedError,
40
+ PwaVapidKeyInvalidError,
41
+ PwaVapidKeyMissingError,
34
42
  SwScopeInvalidError,
35
43
  } from './errors';
36
44
  export type {
@@ -62,7 +70,7 @@ export type {
62
70
  IosGuidance,
63
71
  ReadSignal,
64
72
  } from './install';
65
- export { installController, iosInstallGuidance } from './install';
73
+ export { installController, iosInstallGuidance, MIN_ENGAGEMENT_MS } from './install';
66
74
  export type {
67
75
  DisplayMode,
68
76
  FileHandler,
@@ -103,10 +111,50 @@ export {
103
111
  subscribeSource,
104
112
  subscriptionState,
105
113
  } from './push';
114
+ export type { PushSubscriptionActionOptions } from './push-actions';
115
+ export { pushSubscribe, pushUnsubscribe } from './push-actions';
116
+ // The browser entry is `@ultimat3/pwa/client` (`client.ts`), never this barrel: an island
117
+ // importing it would pull the service-worker generator into a page bundle. Its meta name is
118
+ // re-exported here because the SERVER writes the tag the client reads.
119
+ export { PUSH_KEY_META } from './push-client';
120
+ export type { PushReceiverKeys } from './push-decrypt';
121
+ export { decryptPushMessage } from './push-decrypt';
122
+ export type { PushEncryptionKeys, PushEncryptOptions } from './push-encrypt';
123
+ export { encryptPushMessage, PUSH_RECORD_SIZE, pushMessageCapacity } from './push-encrypt';
124
+ export { PUSH_SERVICE_HOSTS, pushHostAllowed } from './push-hosts';
125
+ export type { WebPushOptions, WebPushRuntime } from './push-runtime';
126
+ export { installedVapid, installWebPush, resetWebPush, webPushRuntime } from './push-runtime';
127
+ export type {
128
+ PushDelivery,
129
+ PushSendInput,
130
+ PushSendOutcome,
131
+ PushTarget,
132
+ PushUrgency,
133
+ VapidSigner,
134
+ } from './push-send';
135
+ export {
136
+ assertPushEndpoint,
137
+ DEFAULT_PUSH_TTL_SECONDS,
138
+ PUSH_SEND_TIMEOUT_MS,
139
+ PUSH_URGENCIES,
140
+ pushEndpointProblem,
141
+ sendPushMessage,
142
+ } from './push-send';
143
+ export type { PushSubscriptionStore } from './push-store';
144
+ export { memoryPushSubscriptionStore } from './push-store';
145
+ export type { PostgresPushSubscriptionStoreOptions } from './push-store-pg';
146
+ export { postgresPushSubscriptionStore } from './push-store-pg';
106
147
  export type { RouteRule } from './route-rules';
107
148
  export { assetRules, routeRules } from './route-rules';
108
149
  export type { ServiceWorkerConfig, ServiceWorkerOutput } from './service-worker';
109
150
  export { assertScope, generateServiceWorker, PRECACHE_CONCURRENCY } from './service-worker';
151
+ // The forced-reload half of this module is gone as of 9.0.0 — `updateSignal`, `updatePolicy`,
152
+ // `DEFAULT_GRACE_MS`, `ForceReason`, `UpdatePolicy`, `UpdatePolicyInput`, `UpdateSignalInput`.
153
+ // It computed `forced`/`deadlineAt` for a client-side reload no code in the framework performed,
154
+ // from a caller that never existed. What remains is what runs: an id, a comparison, a retention
155
+ // plan, and the message the generated worker really posts.
156
+ export type { AppUpdateAvailable, SkewState } from './skew';
157
+ export { detectSkew } from './skew';
110
158
  export type {
111
159
  PwaRoute,
112
160
  StrategyCache,
@@ -124,23 +172,19 @@ export {
124
172
  staleWhileRevalidate,
125
173
  strategyFor,
126
174
  } from './strategies';
127
- // The forced-reload half of this module is gone as of 9.0.0 — `updateSignal`, `updatePolicy`,
128
- // `DEFAULT_GRACE_MS`, `ForceReason`, `UpdatePolicy`, `UpdatePolicyInput`, `UpdateSignalInput`.
129
- // It computed `forced`/`deadlineAt` for a client-side reload no code in the framework performed,
130
- // from a caller that never existed. What remains is what runs: an id, a comparison, a retention
131
- // plan, and the message the generated worker really posts.
132
- export type {
133
- AppUpdateAvailable,
134
- BuildIdInput,
135
- Deploy,
136
- DeployChannel,
137
- RetentionPlan,
138
- SkewState,
139
- } from './version-skew';
175
+ export type { VapidKeyPair, VapidTokenInput } from './vapid';
140
176
  export {
141
- assertBuildId,
142
- buildId,
143
- cacheNamespace,
144
- detectSkew,
145
- retentionPlan,
146
- } from './version-skew';
177
+ assertVapidPair,
178
+ generateVapidKeys,
179
+ importVapidKeys,
180
+ VAPID_PRIVATE_KEY_ENV,
181
+ VAPID_PUBLIC_KEY_ENV,
182
+ VAPID_TOKEN_TTL_SECONDS,
183
+ vapidAuthorization,
184
+ } from './vapid';
185
+ export type { ResolvedVapidKeys } from './vapid-keys';
186
+ export { DEV_VAPID_KEYS, resolveVapidKeys, usesDevVapidKeys } from './vapid-keys';
187
+ export type { BuildIdInput, Deploy, DeployChannel, RetentionPlan } from './version-skew';
188
+ export { assertBuildId, buildId, cacheNamespace, retentionPlan } from './version-skew';
189
+ export type { PushNotification, PushReport, WebPusher } from './web-push';
190
+ export { pushToActor, webPush } from './web-push';
package/src/install.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * permanently on some platforms.
6
6
  */
7
7
 
8
- import { finiteCount } from '@ultimat3/core';
8
+ // `/page`: the browser-safe subpath — this file is in `@ultimat3/pwa/client`'s graph.
9
+ import { finiteCount } from '@ultimat3/core/page';
9
10
 
10
11
  /** A minimal signal so this file needs no framework runtime. */
11
12
  export interface ReadSignal<T> {