@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 +5 -1
- package/README.md +16 -1
- package/package.json +3 -2
- package/src/background-sync.ts +0 -3
- package/src/capabilities.ts +9 -3
- package/src/icons.ts +5 -1
- package/src/index.ts +4 -1
- package/src/manifest.ts +8 -1
- package/src/precache.ts +0 -2
- package/src/push.ts +4 -1
- package/src/service-worker.ts +17 -1
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` |
|
|
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
|
+
"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": "
|
|
34
|
+
"@ultimat3/core": "4.0.0",
|
|
35
|
+
"@ultimat3/seo": "4.0.0"
|
|
35
36
|
}
|
|
36
37
|
}
|
package/src/background-sync.ts
CHANGED
|
@@ -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';
|
package/src/capabilities.ts
CHANGED
|
@@ -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.
|
|
59
|
-
*
|
|
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: [
|
|
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(
|
|
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
|
-
|
|
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)
|
package/src/service-worker.ts
CHANGED
|
@@ -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
|
-
|
|
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]);
|