@ultimat3/pwa 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -15,11 +15,15 @@ Tier 4. May import tiers 0–3: `core`, `schema`, `i18n`, `money`, `time`, `cach
15
15
  | Strategy choice | derived from render mode via `MODE_STRATEGY`. Per-route override only. |
16
16
  | Precache revision | content hash. Never the build id — that re-downloads everything per deploy. |
17
17
  | Precache KEY | the bare URL, always. The revision addresses the FETCH (`?v=<hash>`), never the key: every strategy looks an entry up with `caches.match(req)` and `ignoreSearch` defaults to `false`, so an entry left keyed under `?v=` is a permanent miss — offline serves the fallback document instead of the precached page, and online every precached byte is downloaded twice. `addAll` still does the fetching, because its all-or-nothing failure is what stops a half-populated precache from activating; the install block only re-keys what it stored. `service-worker.test.ts` executes the emitted `sw.js` against stub `caches`/`fetch` rather than asserting its text. |
18
+ | 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. |
19
+ | 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. |
20
+ | HTML sinks | one escaper: `escapeAttribute` from `@ultimat3/seo` (tier 1, and the only one reachable — render's `html.ts` is tier 4, sideways). `appleTouchLinks` and `renderThemeColorMeta` interpolate app config into attributes; both escape. Never a second escaper here. |
18
21
  | Cache names | always `cacheNamespace(buildId, kind)`. An unkeyed cache name is a rejected change. |
19
22
  | Offline fallback | `requireOfflineFallback` runs inside `generateServiceWorker`. Never optional. |
20
- | Capabilities | gate the manifest member **and** the SW block. Disabled → zero bytes. |
23
+ | Capabilities | gate the manifest member **and** the SW block, where the capability has one. Disabled → zero bytes. `push`, `backgroundSync` and `badging` emit worker code; `shareTarget`, `fileHandlers` and `protocolHandlers` are **manifest-only** — the OS delivers to a route the app already serves — and their `CAPABILITY_SW_MARKERS` list is empty, which `service-worker.test.ts` checks in both directions: every declared marker is in the worker when its capability is on, none is when they are all off. `shareTarget` named `/_x/share-target` there while no block emitted it. |
21
24
  | Outbox | queue lives in `@ultimat3/realtime`. This package only registers the sync trigger. |
22
25
  | Push strings | i18n keys only, rendered per subscriber locale. Never a literal. |
26
+ | 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. |
23
27
  | Colours | token values passed in via `PwaConfig.tokens`. Never a hex literal in this package — a test fixture asserting the parser is the one exception. |
24
28
  | 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. |
25
29
  | Icon source | a **PNG** (core decodes PNG and JPEG only). `x new` scaffolds one; `@ultimat3/cli`'s `dev-assets.ts` reads it at `ICON_SOURCE` and serves every entry at its `outputPath`. This package renders bytes and mounts nothing. |
package/README.md CHANGED
@@ -67,13 +67,19 @@ before an un-shippable PWA exists.
67
67
  | `push` | — | `push` + `notificationclick` listeners |
68
68
  | `backgroundSync` | — | `sync` listener + outbox flush |
69
69
  | `badging` | — | `navigator.setAppBadge` after a push |
70
- | `shareTarget` | `share_target` | share-target route rule |
70
+ | `shareTarget` | `share_target` | — |
71
71
  | `fileHandlers` | `file_handlers` | — |
72
72
  | `protocolHandlers` | `protocol_handlers` | — |
73
73
 
74
74
  A disabled capability emits neither the manifest member nor the SW code. An unused
75
75
  capability ships zero bytes and asks for zero permissions.
76
76
 
77
+ Three of the six are manifest-only, and the `—` in their SW column is load-bearing: the OS hands a
78
+ share, a file or a protocol URL to a route the app already serves, so there is no worker branch to
79
+ gate. `CAPABILITY_SW_MARKERS` is checked against the emitted `sw.js` in both directions, so a claim
80
+ here that the generator does not honour is a failing test rather than an installed app announcing a
81
+ capability nothing implements.
82
+
77
83
  ## Public API
78
84
 
79
85
  | Export | Owns |
@@ -89,6 +95,7 @@ capability ships zero bytes and asks for zero permissions.
89
95
  | `backgroundSyncSource`, `retryDelayMs` | the Background Sync trigger |
90
96
  | `renderPushPayload`, `pushSource`, `subscribeSource` | Web Push, per-locale bodies |
91
97
  | `createInstallController`, `iosInstallGuidance` | install prompt, never on first paint |
98
+ | `PwaStrategyExhaustedError` and the other `errors.ts` classes | the codes this package throws, catchable by an app |
92
99
 
93
100
  ## Notes
94
101
 
@@ -109,5 +116,13 @@ capability ships zero bytes and asks for zero permissions.
109
116
  PNG, because `type: 'image/png'` is what the manifest declares. A maskable icon's artwork
110
117
  lands exactly inside `maskableSafeZone(size)`; the ring around it is `background`, which is
111
118
  hex or `transparent` (there are no named colours). Same bytes in, same bytes out.
119
+ - **Every HTML sink goes through one escaper.** `appleTouchLinks` and `renderThemeColorMeta`
120
+ interpolate app configuration into attributes, so both run it through `escapeAttribute` from
121
+ `@ultimat3/seo` (tier 1, and the one this package can reach — `@ultimat3/render`'s `html.ts` is
122
+ tier 4, sideways). Never a second escaper here.
123
+ - **A precache URL may already carry a query.** `PrecacheAsset.url` is public API and bundlers emit
124
+ `?v=<hash>` of their own, so the install block picks `?` or `&` per entry. A fixed `?` produced
125
+ `...?locale=en?v=<rev>`, and because `cache.addAll` is all-or-nothing a single non-200 there means
126
+ the worker never installs at all.
112
127
  - **Route data arrives as data.** `@ultimat3/render` and `@ultimat3/pwa` are both tier 4, so
113
128
  `PwaRoute` is a structural view of `RouteDescriptor`, never an import.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/pwa",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Generated service worker, web manifest, icons, push and version-skew handling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "3.0.0"
34
+ "@ultimat3/core": "4.0.0",
35
+ "@ultimat3/seo": "4.0.0"
35
36
  }
36
37
  }
@@ -18,7 +18,6 @@ import { PwaSyncFlushFailedError, PwaSyncIncompleteError } from './errors';
18
18
  import { BUILD_ID_HEADER } from './version-skew';
19
19
 
20
20
  export const SYNC_TAG = 'x-outbox';
21
- export const PERIODIC_SYNC_TAG = 'x-refresh';
22
21
 
23
22
  export interface RetryPolicy {
24
23
  readonly maxAttempts: number;
@@ -50,8 +49,6 @@ export interface BackgroundSyncOptions {
50
49
  /** Endpoint `@ultimat3/realtime` exposes to flush the outbox. */
51
50
  readonly flushEndpoint?: string;
52
51
  readonly retry?: RetryPolicy;
53
- /** Minimum interval for periodic sync, when the platform grants it. */
54
- readonly periodicMinIntervalMs?: number;
55
52
  }
56
53
 
57
54
  export const DEFAULT_FLUSH_ENDPOINT = '/_x/outbox/flush';
@@ -55,15 +55,21 @@ export const CAPABILITY_MANIFEST_KEYS: Readonly<Record<Capability, readonly stri
55
55
 
56
56
  /**
57
57
  * The service-worker code each capability emits — its listener, and anything that listener alone
58
- * needs. Used to assert nothing leaks when disabled: `PwaSyncError` is the background-sync
59
- * handler's own error class, so it ships with the handler and never without it.
58
+ * needs. Checked in BOTH directions (`service-worker.test.ts`): every marker is in the emitted
59
+ * worker when its capability is on, and none of them is when they are all off. `PwaSyncError` is
60
+ * the background-sync handler's own error class, so it ships with the handler and never without it.
61
+ *
62
+ * An EMPTY list is a claim too, and the true one for the three manifest-only capabilities: a share
63
+ * target, a file handler and a protocol handler are all delivered by the OS to a URL the app
64
+ * already serves, so the member is the whole feature and the worker has no branch to add.
65
+ * `shareTarget` named `/_x/share-target` here for two releases and no block ever emitted it.
60
66
  */
61
67
  export const CAPABILITY_SW_MARKERS: Readonly<Record<Capability, readonly string[]>> = Object.freeze(
62
68
  {
63
69
  push: ["addEventListener('push'", "addEventListener('notificationclick'"],
64
70
  backgroundSync: ["addEventListener('sync'", 'class PwaSyncError'],
65
71
  badging: ['navigator.setAppBadge'],
66
- shareTarget: ['/_x/share-target'],
72
+ shareTarget: [],
67
73
  fileHandlers: [],
68
74
  protocolHandlers: [],
69
75
  },
package/src/icons.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { transformImageBytes } from '@ultimat3/core';
8
+ import { escapeAttribute } from '@ultimat3/seo';
8
9
  import { PwaIconMissingError } from './errors';
9
10
  import type { ManifestIcon } from './manifest';
10
11
 
@@ -180,8 +181,11 @@ export function appleTouchLinks(plan: IconPlan): string {
180
181
  return plan.entries
181
182
  .filter((entry) => entry.spec.purpose === 'apple-touch')
182
183
  .map(
184
+ // `outputPath` carries `IconSourceConfig.outDir`, which is app config on its way into an
185
+ // `href` — a quote in it closed the attribute AND the tag, so `<head>` got a live element.
186
+ // seo's escaper is tier 1 and the one this package can reach; render's html.ts is tier 4.
183
187
  (entry) =>
184
- `<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${entry.outputPath}">`,
188
+ `<link rel="apple-touch-icon" sizes="${entry.spec.size}x${entry.spec.size}" href="${escapeAttribute(entry.outputPath)}">`,
185
189
  )
186
190
  .join('');
187
191
  }
package/src/index.ts CHANGED
@@ -5,7 +5,6 @@ export {
5
5
  backgroundSyncSource,
6
6
  DEFAULT_FLUSH_ENDPOINT,
7
7
  DEFAULT_RETRY,
8
- PERIODIC_SYNC_TAG,
9
8
  registerBackgroundSyncSource,
10
9
  retryDelayMs,
11
10
  SYNC_TAG,
@@ -29,6 +28,10 @@ export {
29
28
  PwaIconMissingError,
30
29
  PwaManifestInvalidError,
31
30
  PwaNoOfflineFallbackError,
31
+ // `staleWhileRevalidate` is public and throws this, so an app that catches it can name it. The
32
+ // two X_PWA_SYNC_* classes are not here on purpose: the emitted `sw.js` builds its own local
33
+ // class in a realm with no bundler, so no instance of theirs can ever reach an app.
34
+ PwaStrategyExhaustedError,
32
35
  SwScopeInvalidError,
33
36
  } from './errors';
34
37
  export type {
package/src/manifest.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * app gets a light status bar on every launch.
6
6
  */
7
7
 
8
+ import { escapeAttribute } from '@ultimat3/seo';
8
9
  import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
9
10
  import { isEnabled, resolveCapabilities } from './capabilities';
10
11
  import { PwaManifestInvalidError } from './errors';
@@ -211,6 +212,12 @@ export function serializeWebManifest(manifest: WebManifest): string {
211
212
 
212
213
  export function renderThemeColorMeta(metas: readonly ThemeColorMeta[]): string {
213
214
  return metas
214
- .map((meta) => `<meta name="theme-color" content="${meta.content}" media="${meta.media}">`)
215
+ .map(
216
+ // Both values are attribute sinks. `assertValid` only asks that a token is non-empty, so a
217
+ // quote in one emitted a second, live attribute — same class as `appleTouchLinks`, same
218
+ // escaper, one per package rather than one per call site.
219
+ (meta) =>
220
+ `<meta name="theme-color" content="${escapeAttribute(meta.content)}" media="${escapeAttribute(meta.media)}">`,
221
+ )
215
222
  .join('');
216
223
  }
package/src/precache.ts CHANGED
@@ -11,8 +11,6 @@ export interface PrecacheAsset {
11
11
  /** Content hash. Same bytes → same revision → no re-download. */
12
12
  readonly revision: string;
13
13
  readonly bytes: number;
14
- /** Critical assets (the shell CSS, the LCP font) are precached even if large. */
15
- readonly critical?: boolean;
16
14
  }
17
15
 
18
16
  export interface PrecacheEntry {
package/src/push.ts CHANGED
@@ -150,7 +150,10 @@ self.addEventListener('push',(event)=>{
150
150
  });
151
151
  self.addEventListener('notificationclick',(event)=>{
152
152
  event.notification.close();
153
- const url=(event.notification.data&&event.notification.data.url)||'/';
153
+ // PushPayload.url is a PATH and WindowClient.url is absolute, so the comparison below is only
154
+ // ever true after resolving — unresolved it matched no client at all and every tap opened a
155
+ // second window on an app the user already had open. Same resolve as the fetch block's.
156
+ const url=new URL((event.notification.data&&event.notification.data.url)||'/',self.location.origin).href;
154
157
  event.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then((ws)=>{
155
158
  for(const w of ws){if(w.url===url&&'focus'in w)return w.focus()}
156
159
  return clients.openWindow(url)
@@ -64,6 +64,16 @@ export interface ServiceWorkerOutput {
64
64
  * "why is my PWA not working" and it is checkable at build time.
65
65
  */
66
66
  export function assertScope(swPath: string, scope: string): void {
67
+ // A relative path has no directory to compare, and `''` is a prefix of every scope — so the
68
+ // check passed for exactly the config most likely to be wrong. Refused, not normalised: where
69
+ // the browser would resolve `sw.js` from is the registration call, which this cannot see.
70
+ if (!swPath.startsWith('/')) {
71
+ throw new SwScopeInvalidError(
72
+ `sw.js is served from ${swPath}, which is relative, so the directory it can control is ` +
73
+ `unknown at build time; the configured scope is ${scope}`,
74
+ `set pwa.swPath to an absolute path — '${scope}sw.js' serves scope ${scope}`,
75
+ );
76
+ }
67
77
  const directory = swPath.slice(0, swPath.lastIndexOf('/') + 1);
68
78
  if (!scope.startsWith(directory)) {
69
79
  throw new SwScopeInvalidError(
@@ -210,13 +220,19 @@ function serializeRules(rules: readonly RouteRule[]): string {
210
220
  * of the page that was precached, and online every precached byte is downloaded a second time.
211
221
  * `addAll` still does the fetching, because its all-or-nothing failure is what stops a
212
222
  * half-populated precache from activating; the second pass only re-keys what it stored.
223
+ *
224
+ * The separator in front of `v=` is chosen per entry, because `PrecacheAsset.url` is public API
225
+ * and a bundler emits a query of its own: a fixed `?` built `...?locale=en?v=<rev>`, and a single
226
+ * non-200 for it rejects `addAll`, which rejects the install — no precache, no offline document,
227
+ * no version-skew header, and a worker that never activates at all.
213
228
  */
214
229
  const INSTALL_BLOCK = `
215
230
  self.addEventListener('install',(event)=>{
216
231
  event.waitUntil((async()=>{
217
232
  const cache=await caches.open(PRECACHE);
218
233
  // Revision is a content hash: unchanged assets are not re-downloaded across deploys.
219
- const fetched=PRECACHE_MANIFEST.map((e)=>new Request(e.url+'?v='+e.revision,{cache:'reload'}));
234
+ // The separator is picked per entry: a precache URL may already carry a query.
235
+ const fetched=PRECACHE_MANIFEST.map((e)=>new Request(e.url+(e.url.indexOf('?')<0?'?':'&')+'v='+e.revision,{cache:'reload'}));
220
236
  await cache.addAll(fetched);
221
237
  for(let i=0;i<PRECACHE_MANIFEST.length;i++){
222
238
  const stored=await cache.match(fetched[i]);