@ultimat3/pwa 1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/package.json +35 -0
- package/src/background-sync.ts +117 -0
- package/src/capabilities.ts +70 -0
- package/src/errors.ts +176 -0
- package/src/icons.ts +187 -0
- package/src/index.ts +155 -0
- package/src/install.ts +137 -0
- package/src/manifest.ts +216 -0
- package/src/offline-fallback.ts +76 -0
- package/src/precache.ts +137 -0
- package/src/push.ts +171 -0
- package/src/service-worker.ts +267 -0
- package/src/strategies.ts +207 -0
- package/src/version-skew.ts +187 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline fallback is mandatory. A PWA whose first offline navigation shows the
|
|
3
|
+
* browser's dinosaur is not offline-capable — it is a website with a manifest. The type
|
|
4
|
+
* requires it and `requireOfflineFallback` fails the build if it is missing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PwaNoOfflineFallbackError } from './errors';
|
|
8
|
+
|
|
9
|
+
export interface OfflineConfig {
|
|
10
|
+
/** Route path of the offline document, e.g. `/offline`. Required. */
|
|
11
|
+
readonly fallback: string;
|
|
12
|
+
/** Optional per-content-type fallbacks. */
|
|
13
|
+
readonly image?: string;
|
|
14
|
+
readonly font?: string;
|
|
15
|
+
/** Requests that must never be answered from a cache (auth, payments). */
|
|
16
|
+
readonly neverCache?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OfflineFallback {
|
|
20
|
+
readonly document: string;
|
|
21
|
+
readonly image: string | null;
|
|
22
|
+
readonly font: string | null;
|
|
23
|
+
readonly neverCache: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const FIX = 'create app/offline.tsx and set offline.fallback';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build-time gate, called by `generateServiceWorker` and by `x doctor`. The fix line is
|
|
30
|
+
* the literal two-step edit, not a doc link.
|
|
31
|
+
*/
|
|
32
|
+
export function requireOfflineFallback(
|
|
33
|
+
config: Partial<OfflineConfig> | undefined | null,
|
|
34
|
+
): OfflineFallback {
|
|
35
|
+
if (config === undefined || config === null) {
|
|
36
|
+
throw new PwaNoOfflineFallbackError(
|
|
37
|
+
'app.config.ts has no `offline` block, so an offline navigation would show the ' +
|
|
38
|
+
"browser's error page",
|
|
39
|
+
FIX,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const fallback = config.fallback;
|
|
43
|
+
if (fallback === undefined || fallback.trim() === '') {
|
|
44
|
+
throw new PwaNoOfflineFallbackError(
|
|
45
|
+
'app.config.ts has an `offline` block with no `fallback` route',
|
|
46
|
+
FIX,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!fallback.startsWith('/')) {
|
|
50
|
+
throw new PwaNoOfflineFallbackError(
|
|
51
|
+
`offline.fallback is ${JSON.stringify(fallback)}, which is not an absolute route path`,
|
|
52
|
+
`set offline.fallback to '/${fallback.replace(/^\/+/, '')}'`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
document: fallback,
|
|
58
|
+
image: config.image ?? null,
|
|
59
|
+
font: config.font ?? null,
|
|
60
|
+
neverCache: config.neverCache ?? [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Emitted into `sw.js`: what to serve when a navigation cannot be answered. */
|
|
65
|
+
export function offlineFallbackSource(fallback: OfflineFallback): string {
|
|
66
|
+
const image = fallback.image === null ? 'null' : JSON.stringify(fallback.image);
|
|
67
|
+
return `
|
|
68
|
+
const OFFLINE_DOC=${JSON.stringify(fallback.document)};
|
|
69
|
+
const OFFLINE_IMAGE=${image};
|
|
70
|
+
async function offlineFallback(req){
|
|
71
|
+
const c=await caches.open(PRECACHE);
|
|
72
|
+
if(req.mode==='navigate'){const d=await c.match(OFFLINE_DOC);if(d)return d}
|
|
73
|
+
if(OFFLINE_IMAGE&&req.destination==='image'){const i=await c.match(OFFLINE_IMAGE);if(i)return i}
|
|
74
|
+
return new Response('',{status:503,statusText:'Offline'})
|
|
75
|
+
}`.trim();
|
|
76
|
+
}
|
package/src/precache.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The precache manifest, built from the route table. Entries are URL + revision, so a
|
|
3
|
+
* byte-identical asset across deploys is not re-downloaded; the revision is the content
|
|
4
|
+
* hash, never the build id, or every deploy would re-fetch everything.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PwaRoute } from './strategies';
|
|
8
|
+
|
|
9
|
+
export interface PrecacheAsset {
|
|
10
|
+
readonly url: string;
|
|
11
|
+
/** Content hash. Same bytes → same revision → no re-download. */
|
|
12
|
+
readonly revision: string;
|
|
13
|
+
readonly bytes: number;
|
|
14
|
+
/** Critical assets (the shell CSS, the LCP font) are precached even if large. */
|
|
15
|
+
readonly critical?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrecacheEntry {
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly revision: string;
|
|
21
|
+
readonly bytes: number;
|
|
22
|
+
readonly reason: 'shell' | 'route' | 'route-data' | 'asset' | 'fallback';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PrecacheManifest {
|
|
26
|
+
readonly buildId: string;
|
|
27
|
+
readonly entries: readonly PrecacheEntry[];
|
|
28
|
+
readonly totalBytes: number;
|
|
29
|
+
readonly warnings: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PrecacheInput {
|
|
33
|
+
readonly buildId: string;
|
|
34
|
+
readonly routes: readonly PwaRoute[];
|
|
35
|
+
readonly assets?: readonly PrecacheAsset[];
|
|
36
|
+
/** The app shell URL, precached for every `spa` route. */
|
|
37
|
+
readonly shellUrl?: string;
|
|
38
|
+
readonly shellRevision?: string;
|
|
39
|
+
readonly shellBytes?: number;
|
|
40
|
+
/** The mandatory offline document. */
|
|
41
|
+
readonly offlineFallbackUrl?: string;
|
|
42
|
+
readonly offlineFallbackRevision?: string;
|
|
43
|
+
/** Warn past this total. Default 5 MB: past that, install stalls on a bad connection. */
|
|
44
|
+
readonly warnBytes?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_PRECACHE_WARN_BYTES = 5 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deterministic: entries are sorted by URL and every field is derived from the input, so
|
|
51
|
+
* two builds of the same commit emit byte-identical manifests.
|
|
52
|
+
*/
|
|
53
|
+
export function buildPrecacheManifest(input: PrecacheInput): PrecacheManifest {
|
|
54
|
+
const entries = new Map<string, PrecacheEntry>();
|
|
55
|
+
const add = (entry: PrecacheEntry): void => {
|
|
56
|
+
if (!entries.has(entry.url)) entries.set(entry.url, entry);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (input.shellUrl !== undefined) {
|
|
60
|
+
add({
|
|
61
|
+
url: input.shellUrl,
|
|
62
|
+
revision: input.shellRevision ?? input.buildId,
|
|
63
|
+
bytes: input.shellBytes ?? 0,
|
|
64
|
+
reason: 'shell',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (input.offlineFallbackUrl !== undefined) {
|
|
69
|
+
add({
|
|
70
|
+
url: input.offlineFallbackUrl,
|
|
71
|
+
revision: input.offlineFallbackRevision ?? input.buildId,
|
|
72
|
+
bytes: 0,
|
|
73
|
+
reason: 'fallback',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const route of input.routes) {
|
|
78
|
+
if (route.offline !== 'precache') continue;
|
|
79
|
+
// A dynamic route has no single URL to precache; its instances are runtime-cached.
|
|
80
|
+
if (route.dynamic === true) continue;
|
|
81
|
+
add({
|
|
82
|
+
url: route.path,
|
|
83
|
+
revision: route.revision ?? input.buildId,
|
|
84
|
+
bytes: route.bytes ?? 0,
|
|
85
|
+
reason: 'route',
|
|
86
|
+
});
|
|
87
|
+
if (route.dataUrl !== undefined) {
|
|
88
|
+
add({
|
|
89
|
+
url: route.dataUrl,
|
|
90
|
+
revision: route.revision ?? input.buildId,
|
|
91
|
+
bytes: 0,
|
|
92
|
+
reason: 'route-data',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const asset of input.assets ?? []) {
|
|
98
|
+
add({ url: asset.url, revision: asset.revision, bytes: asset.bytes, reason: 'asset' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sorted = [...entries.values()].sort((a, b) => a.url.localeCompare(b.url));
|
|
102
|
+
const totalBytes = sorted.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
103
|
+
const warnBytes = input.warnBytes ?? DEFAULT_PRECACHE_WARN_BYTES;
|
|
104
|
+
|
|
105
|
+
const warnings: string[] = [];
|
|
106
|
+
if (totalBytes > warnBytes) {
|
|
107
|
+
warnings.push(
|
|
108
|
+
`precache is ${formatBytes(totalBytes)} (over ${formatBytes(warnBytes)}): install ` +
|
|
109
|
+
"will stall on a slow connection — move rarely-visited routes to offline: 'runtime'",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const dynamicPrecache = input.routes.filter(
|
|
113
|
+
(r) => r.offline === 'precache' && r.dynamic === true,
|
|
114
|
+
);
|
|
115
|
+
for (const route of dynamicPrecache) {
|
|
116
|
+
warnings.push(
|
|
117
|
+
`${route.path} is dynamic and cannot be precached as one URL; its instances are ` +
|
|
118
|
+
'runtime-cached instead',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { buildId: input.buildId, entries: sorted, totalBytes, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatBytes(bytes: number): string {
|
|
126
|
+
if (bytes < 1024) return `${bytes}b`;
|
|
127
|
+
if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10}kb`;
|
|
128
|
+
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10}mb`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The manifest as it is embedded in `sw.js`; stable key order for determinism. */
|
|
132
|
+
export function serializePrecacheManifest(manifest: PrecacheManifest): string {
|
|
133
|
+
const rows = manifest.entries.map(
|
|
134
|
+
(entry) => `{"url":${JSON.stringify(entry.url)},"revision":${JSON.stringify(entry.revision)}}`,
|
|
135
|
+
);
|
|
136
|
+
return `[${rows.join(',')}]`;
|
|
137
|
+
}
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push: subscription lifecycle, VAPID config, typed payloads, and a per-locale body.
|
|
3
|
+
*
|
|
4
|
+
* The locale is stored ON the subscription, not read from the sending request: a push is
|
|
5
|
+
* composed on a server with no request context, and a notification in the wrong language
|
|
6
|
+
* is a real bug that users report as "the app is broken".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface VapidConfig {
|
|
10
|
+
readonly publicKey: string;
|
|
11
|
+
/** `mailto:` or an https URL — required by the spec, checked by every push service. */
|
|
12
|
+
readonly subject: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PushSubscriptionKeys {
|
|
16
|
+
readonly p256dh: string;
|
|
17
|
+
readonly auth: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PushSubscriptionRecord {
|
|
21
|
+
readonly endpoint: string;
|
|
22
|
+
readonly keys: PushSubscriptionKeys;
|
|
23
|
+
/** BCP-47, captured at subscribe time. */
|
|
24
|
+
readonly locale: string;
|
|
25
|
+
readonly timeZone: string;
|
|
26
|
+
readonly actorId: string | null;
|
|
27
|
+
readonly createdAt: number;
|
|
28
|
+
readonly expirationTime: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SubscriptionState = 'active' | 'expired' | 'gone';
|
|
32
|
+
|
|
33
|
+
/** A 404/410 from the push service means the subscription is dead — delete it, don't retry. */
|
|
34
|
+
export function subscriptionState(
|
|
35
|
+
record: PushSubscriptionRecord,
|
|
36
|
+
lastStatus: number | null,
|
|
37
|
+
now = Date.now(),
|
|
38
|
+
): SubscriptionState {
|
|
39
|
+
if (lastStatus === 404 || lastStatus === 410) return 'gone';
|
|
40
|
+
if (record.expirationTime !== null && record.expirationTime <= now) return 'expired';
|
|
41
|
+
return 'active';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Translation function from `@ultimat3/i18n`, bound to the subscriber's locale. */
|
|
45
|
+
export type Translate = (key: string, params?: Readonly<Record<string, string | number>>) => string;
|
|
46
|
+
|
|
47
|
+
export interface PushPayload {
|
|
48
|
+
/** i18n catalog keys, never literal strings. */
|
|
49
|
+
readonly titleKey: string;
|
|
50
|
+
readonly bodyKey: string;
|
|
51
|
+
readonly params?: Readonly<Record<string, string | number>>;
|
|
52
|
+
/** Deep link opened by `notificationclick`. */
|
|
53
|
+
readonly url: string;
|
|
54
|
+
/** Collapse key: a newer notification with the same tag replaces the older one. */
|
|
55
|
+
readonly tag?: string;
|
|
56
|
+
readonly icon?: string;
|
|
57
|
+
readonly badge?: string;
|
|
58
|
+
readonly renotify?: boolean;
|
|
59
|
+
readonly requireInteraction?: boolean;
|
|
60
|
+
readonly actions?: readonly { readonly action: string; readonly titleKey: string }[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RenderedNotification {
|
|
64
|
+
readonly title: string;
|
|
65
|
+
readonly body: string;
|
|
66
|
+
readonly url: string;
|
|
67
|
+
readonly tag: string | null;
|
|
68
|
+
readonly icon: string | null;
|
|
69
|
+
readonly badge: string | null;
|
|
70
|
+
readonly renotify: boolean;
|
|
71
|
+
readonly requireInteraction: boolean;
|
|
72
|
+
readonly actions: readonly { readonly action: string; readonly title: string }[];
|
|
73
|
+
readonly locale: string;
|
|
74
|
+
/** Missing-key markers (`⟦key⟧`) surfaced instead of being shipped to a user. */
|
|
75
|
+
readonly warnings: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MISSING_MARKER = '⟦';
|
|
79
|
+
|
|
80
|
+
export function renderPushPayload(
|
|
81
|
+
payload: PushPayload,
|
|
82
|
+
locale: string,
|
|
83
|
+
translate: Translate,
|
|
84
|
+
): RenderedNotification {
|
|
85
|
+
const warnings: string[] = [];
|
|
86
|
+
const render = (key: string): string => {
|
|
87
|
+
const value = translate(key, payload.params);
|
|
88
|
+
if (value.startsWith(MISSING_MARKER)) {
|
|
89
|
+
warnings.push(`missing ${locale} translation for ${key}`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
title: render(payload.titleKey),
|
|
96
|
+
body: render(payload.bodyKey),
|
|
97
|
+
url: payload.url,
|
|
98
|
+
tag: payload.tag ?? null,
|
|
99
|
+
icon: payload.icon ?? null,
|
|
100
|
+
badge: payload.badge ?? null,
|
|
101
|
+
renotify: payload.renotify ?? false,
|
|
102
|
+
requireInteraction: payload.requireInteraction ?? false,
|
|
103
|
+
actions: (payload.actions ?? []).map((action) => ({
|
|
104
|
+
action: action.action,
|
|
105
|
+
title: render(action.titleKey),
|
|
106
|
+
})),
|
|
107
|
+
locale,
|
|
108
|
+
warnings,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The wire body the SW receives. Rendered server-side so the SW ships no catalog. */
|
|
113
|
+
export function serializePushMessage(notification: RenderedNotification): string {
|
|
114
|
+
return JSON.stringify({
|
|
115
|
+
title: notification.title,
|
|
116
|
+
body: notification.body,
|
|
117
|
+
url: notification.url,
|
|
118
|
+
tag: notification.tag,
|
|
119
|
+
icon: notification.icon,
|
|
120
|
+
badge: notification.badge,
|
|
121
|
+
renotify: notification.renotify,
|
|
122
|
+
requireInteraction: notification.requireInteraction,
|
|
123
|
+
actions: notification.actions,
|
|
124
|
+
lang: notification.locale,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PushSourceOptions {
|
|
129
|
+
readonly defaultIcon?: string;
|
|
130
|
+
readonly defaultBadge?: string;
|
|
131
|
+
/** Emit `navigator.setAppBadge` calls — gated by the `badging` capability. */
|
|
132
|
+
readonly badging?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Emitted into `sw.js` only when the `push` capability is on. */
|
|
136
|
+
export function pushSource(options: PushSourceOptions = {}): string {
|
|
137
|
+
const icon = JSON.stringify(options.defaultIcon ?? '/icons/icon-192.png');
|
|
138
|
+
const badge = JSON.stringify(options.defaultBadge ?? '/icons/icon-mono-512.png');
|
|
139
|
+
const badging = options.badging === true;
|
|
140
|
+
|
|
141
|
+
return `
|
|
142
|
+
self.addEventListener('push',(event)=>{
|
|
143
|
+
const d=event.data?event.data.json():{};
|
|
144
|
+
const opts={body:d.body||'',icon:d.icon||${icon},badge:d.badge||${badge},
|
|
145
|
+
tag:d.tag||undefined,renotify:!!d.renotify,requireInteraction:!!d.requireInteraction,
|
|
146
|
+
lang:d.lang||undefined,actions:d.actions||[],data:{url:d.url||'/'}};
|
|
147
|
+
event.waitUntil(self.registration.showNotification(d.title||'',opts)${
|
|
148
|
+
badging ? '.then(()=>navigator.setAppBadge&&navigator.setAppBadge())' : ''
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
self.addEventListener('notificationclick',(event)=>{
|
|
152
|
+
event.notification.close();
|
|
153
|
+
const url=(event.notification.data&&event.notification.data.url)||'/';
|
|
154
|
+
event.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then((ws)=>{
|
|
155
|
+
for(const w of ws){if(w.url===url&&'focus'in w)return w.focus()}
|
|
156
|
+
return clients.openWindow(url)
|
|
157
|
+
}));
|
|
158
|
+
});`.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Client-side subscribe. The locale travels with the subscription, by design. */
|
|
162
|
+
export function subscribeSource(vapid: VapidConfig): string {
|
|
163
|
+
return `
|
|
164
|
+
export async function subscribePush(registration,locale,timeZone){
|
|
165
|
+
const existing=await registration.pushManager.getSubscription();
|
|
166
|
+
if(existing)return {subscription:existing.toJSON(),locale,timeZone};
|
|
167
|
+
const sub=await registration.pushManager.subscribe({userVisibleOnly:true,
|
|
168
|
+
applicationServerKey:${JSON.stringify(vapid.publicKey)}});
|
|
169
|
+
return {subscription:sub.toJSON(),locale,timeZone}
|
|
170
|
+
}`.trim();
|
|
171
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sw.js` is emitted from the route table. You never open it — that is this package's
|
|
3
|
+
* whole thesis. Hand-written service workers rot because they encode routing decisions a
|
|
4
|
+
* second time, and the second copy is the one nobody updates.
|
|
5
|
+
*
|
|
6
|
+
* Output is deterministic for identical input: no timestamps, no randomness, sorted
|
|
7
|
+
* everything, so two builds of the same commit produce byte-identical bytes and the SW
|
|
8
|
+
* update check does not fire on a no-op deploy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BackgroundSyncOptions } from './background-sync';
|
|
12
|
+
import { backgroundSyncSource } from './background-sync';
|
|
13
|
+
import type { CapabilityFlags, ResolvedCapabilities } from './capabilities';
|
|
14
|
+
import { isEnabled, resolveCapabilities } from './capabilities';
|
|
15
|
+
import { SwScopeInvalidError } from './errors';
|
|
16
|
+
import type { OfflineConfig } from './offline-fallback';
|
|
17
|
+
import { offlineFallbackSource, requireOfflineFallback } from './offline-fallback';
|
|
18
|
+
import type { PrecacheAsset, PrecacheManifest } from './precache';
|
|
19
|
+
import { buildPrecacheManifest, serializePrecacheManifest } from './precache';
|
|
20
|
+
import type { VapidConfig } from './push';
|
|
21
|
+
import { pushSource } from './push';
|
|
22
|
+
import type { PwaRoute, StrategyName } from './strategies';
|
|
23
|
+
import { STRATEGY_FN_NAMES, STRATEGY_SOURCE, strategyFor } from './strategies';
|
|
24
|
+
import {
|
|
25
|
+
APP_UPDATE_AVAILABLE,
|
|
26
|
+
assertBuildId,
|
|
27
|
+
BUILD_ID_HEADER,
|
|
28
|
+
cacheNamespace,
|
|
29
|
+
} from './version-skew';
|
|
30
|
+
|
|
31
|
+
export interface ServiceWorkerConfig {
|
|
32
|
+
readonly scope?: string;
|
|
33
|
+
/** Where `sw.js` is served from. Must be at or above `scope`. */
|
|
34
|
+
readonly swPath?: string;
|
|
35
|
+
readonly offline: Partial<OfflineConfig>;
|
|
36
|
+
readonly capabilities?: CapabilityFlags;
|
|
37
|
+
readonly assets?: readonly PrecacheAsset[];
|
|
38
|
+
readonly shellUrl?: string;
|
|
39
|
+
readonly shellRevision?: string;
|
|
40
|
+
readonly shellBytes?: number;
|
|
41
|
+
readonly vapid?: VapidConfig;
|
|
42
|
+
readonly backgroundSync?: BackgroundSyncOptions;
|
|
43
|
+
/** Build ids whose caches must survive this activation (see `retentionPlan`). */
|
|
44
|
+
readonly retainBuildIds?: readonly string[];
|
|
45
|
+
readonly precacheWarnBytes?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RouteRule {
|
|
49
|
+
readonly pattern: string;
|
|
50
|
+
readonly strategy: StrategyName;
|
|
51
|
+
readonly cache: 'precache' | 'runtime' | 'pages';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ServiceWorkerOutput {
|
|
55
|
+
readonly source: string;
|
|
56
|
+
readonly precache: PrecacheManifest;
|
|
57
|
+
readonly rules: readonly RouteRule[];
|
|
58
|
+
readonly capabilities: ResolvedCapabilities;
|
|
59
|
+
readonly warnings: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A SW served from `/js/sw.js` can only control `/js/`. This is the single most common
|
|
64
|
+
* "why is my PWA not working" and it is checkable at build time.
|
|
65
|
+
*/
|
|
66
|
+
export function assertScope(swPath: string, scope: string): void {
|
|
67
|
+
const directory = swPath.slice(0, swPath.lastIndexOf('/') + 1);
|
|
68
|
+
if (!scope.startsWith(directory)) {
|
|
69
|
+
throw new SwScopeInvalidError(
|
|
70
|
+
`sw.js is served from ${swPath}, which can only control ${directory}, but the ` +
|
|
71
|
+
`configured scope is ${scope}`,
|
|
72
|
+
`serve the service worker from ${scope}sw.js, or set pwa.scope to '${directory}'`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function routeRules(routes: readonly PwaRoute[]): readonly RouteRule[] {
|
|
78
|
+
return [...routes]
|
|
79
|
+
.filter((route) => route.surface !== 'api')
|
|
80
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
81
|
+
.map((route) => {
|
|
82
|
+
const strategy = strategyFor(route);
|
|
83
|
+
return {
|
|
84
|
+
pattern: toPattern(route.path),
|
|
85
|
+
strategy,
|
|
86
|
+
cache: cacheFor(route, strategy),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cacheFor(route: PwaRoute, strategy: StrategyName): 'precache' | 'runtime' | 'pages' {
|
|
92
|
+
if (strategy === 'network-only') return 'runtime';
|
|
93
|
+
if (route.offline === 'precache' && route.dynamic !== true) return 'precache';
|
|
94
|
+
return 'pages';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toPattern(path: string): string {
|
|
98
|
+
if (path === '/') return '^/$';
|
|
99
|
+
const body = path
|
|
100
|
+
.split('/')
|
|
101
|
+
.map((segment) => {
|
|
102
|
+
if (segment.startsWith(':')) return '[^/]+';
|
|
103
|
+
if (segment.startsWith('*')) return '.*';
|
|
104
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
105
|
+
})
|
|
106
|
+
.join('/');
|
|
107
|
+
return `^${body}/?$`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Emit `sw.js`. `routes` are `@ultimat3/render` descriptors, passed as data. */
|
|
111
|
+
export function generateServiceWorker(
|
|
112
|
+
routes: readonly PwaRoute[],
|
|
113
|
+
config: ServiceWorkerConfig,
|
|
114
|
+
buildId: string,
|
|
115
|
+
): ServiceWorkerOutput {
|
|
116
|
+
assertBuildId(buildId);
|
|
117
|
+
const scope = config.scope ?? '/';
|
|
118
|
+
assertScope(config.swPath ?? `${scope}sw.js`, scope);
|
|
119
|
+
|
|
120
|
+
const fallback = requireOfflineFallback(config.offline);
|
|
121
|
+
const capabilities = resolveCapabilities(config.capabilities);
|
|
122
|
+
const rules = routeRules(routes);
|
|
123
|
+
|
|
124
|
+
const precache = buildPrecacheManifest({
|
|
125
|
+
buildId,
|
|
126
|
+
routes,
|
|
127
|
+
offlineFallbackUrl: fallback.document,
|
|
128
|
+
...(config.assets === undefined ? {} : { assets: config.assets }),
|
|
129
|
+
...(config.shellUrl === undefined ? {} : { shellUrl: config.shellUrl }),
|
|
130
|
+
...(config.shellRevision === undefined ? {} : { shellRevision: config.shellRevision }),
|
|
131
|
+
...(config.shellBytes === undefined ? {} : { shellBytes: config.shellBytes }),
|
|
132
|
+
...(config.precacheWarnBytes === undefined ? {} : { warnBytes: config.precacheWarnBytes }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const usedStrategies = [...new Set(rules.map((rule) => rule.strategy))].sort();
|
|
136
|
+
const retained = [...new Set([buildId, ...(config.retainBuildIds ?? [])])].sort();
|
|
137
|
+
|
|
138
|
+
const blocks: string[] = [
|
|
139
|
+
header(buildId),
|
|
140
|
+
constants(buildId, scope, retained, fallback.neverCache),
|
|
141
|
+
`const PRECACHE_MANIFEST=${serializePrecacheManifest(precache)};`,
|
|
142
|
+
`const ROUTE_RULES=${serializeRules(rules)};`,
|
|
143
|
+
usedStrategies.map((strategy) => STRATEGY_SOURCE[strategy]).join('\n'),
|
|
144
|
+
offlineFallbackSource(fallback),
|
|
145
|
+
INSTALL_BLOCK,
|
|
146
|
+
activateBlock(),
|
|
147
|
+
fetchBlock(),
|
|
148
|
+
messageBlock(),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
if (isEnabled(capabilities, 'push') && config.vapid !== undefined) {
|
|
152
|
+
blocks.push(pushSource({ badging: isEnabled(capabilities, 'badging') }));
|
|
153
|
+
}
|
|
154
|
+
if (isEnabled(capabilities, 'backgroundSync')) {
|
|
155
|
+
blocks.push(backgroundSyncSource(config.backgroundSync ?? {}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
source: `${blocks.join('\n\n')}\n`,
|
|
160
|
+
precache,
|
|
161
|
+
rules,
|
|
162
|
+
capabilities,
|
|
163
|
+
warnings: precache.warnings,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function header(buildId: string): string {
|
|
168
|
+
return `// GENERATED by @ultimat3/pwa from the route table — do not edit.
|
|
169
|
+
// build: ${buildId} regenerate: x build`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function constants(
|
|
173
|
+
buildId: string,
|
|
174
|
+
scope: string,
|
|
175
|
+
retained: readonly string[],
|
|
176
|
+
neverCache: readonly string[],
|
|
177
|
+
): string {
|
|
178
|
+
return `
|
|
179
|
+
const BUILD_ID=${JSON.stringify(buildId)};
|
|
180
|
+
const SCOPE=${JSON.stringify(scope)};
|
|
181
|
+
const PRECACHE=${JSON.stringify(cacheNamespace(buildId, 'precache'))};
|
|
182
|
+
const RUNTIME=${JSON.stringify(cacheNamespace(buildId, 'runtime'))};
|
|
183
|
+
const PAGES=${JSON.stringify(cacheNamespace(buildId, 'pages'))};
|
|
184
|
+
const RETAINED=${JSON.stringify(retainedCaches(retained))};
|
|
185
|
+
const NEVER_CACHE=${JSON.stringify(neverCache)};
|
|
186
|
+
const BUILD_HEADER=${JSON.stringify(BUILD_ID_HEADER)};`.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function retainedCaches(buildIds: readonly string[]): readonly string[] {
|
|
190
|
+
return buildIds.flatMap((id) => [
|
|
191
|
+
cacheNamespace(id, 'precache'),
|
|
192
|
+
cacheNamespace(id, 'runtime'),
|
|
193
|
+
cacheNamespace(id, 'pages'),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function serializeRules(rules: readonly RouteRule[]): string {
|
|
198
|
+
const rows = rules.map(
|
|
199
|
+
(rule) =>
|
|
200
|
+
`{"p":${JSON.stringify(rule.pattern)},"s":${JSON.stringify(STRATEGY_FN_NAMES[rule.strategy])},` +
|
|
201
|
+
`"c":${JSON.stringify(rule.cache)}}`,
|
|
202
|
+
);
|
|
203
|
+
return `[${rows.join(',')}]`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const INSTALL_BLOCK = `
|
|
207
|
+
self.addEventListener('install',(event)=>{
|
|
208
|
+
event.waitUntil((async()=>{
|
|
209
|
+
const cache=await caches.open(PRECACHE);
|
|
210
|
+
// Revision is a content hash: unchanged assets are not re-downloaded across deploys.
|
|
211
|
+
await cache.addAll(PRECACHE_MANIFEST.map((e)=>new Request(e.url+'?v='+e.revision,{cache:'reload'})));
|
|
212
|
+
})());
|
|
213
|
+
});`.trim();
|
|
214
|
+
|
|
215
|
+
function activateBlock(): string {
|
|
216
|
+
return `
|
|
217
|
+
self.addEventListener('activate',(event)=>{
|
|
218
|
+
event.waitUntil((async()=>{
|
|
219
|
+
// Retain the last N deploys so a tab open across a release still resolves its chunks.
|
|
220
|
+
const names=await caches.keys();
|
|
221
|
+
await Promise.all(names.filter((n)=>n.startsWith('x-')&&RETAINED.indexOf(n)===-1)
|
|
222
|
+
.map((n)=>caches.delete(n)));
|
|
223
|
+
await self.clients.claim();
|
|
224
|
+
const cs=await self.clients.matchAll({type:'window'});
|
|
225
|
+
for(const c of cs)c.postMessage({type:${JSON.stringify(APP_UPDATE_AVAILABLE)},to:BUILD_ID});
|
|
226
|
+
})());
|
|
227
|
+
});`.trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function fetchBlock(): string {
|
|
231
|
+
return `
|
|
232
|
+
function ruleFor(url){
|
|
233
|
+
for(const r of ROUTE_RULES){if(new RegExp(r.p).test(url.pathname))return r}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
function cacheName(kind){return kind==='precache'?PRECACHE:kind==='pages'?PAGES:RUNTIME}
|
|
237
|
+
const STRATEGIES={cacheFirst:typeof cacheFirst==='function'?cacheFirst:null,
|
|
238
|
+
networkFirst:typeof networkFirst==='function'?networkFirst:null,
|
|
239
|
+
staleWhileRevalidate:typeof staleWhileRevalidate==='function'?staleWhileRevalidate:null,
|
|
240
|
+
networkOnly:typeof networkOnly==='function'?networkOnly:null};
|
|
241
|
+
self.addEventListener('fetch',(event)=>{
|
|
242
|
+
const req=event.request;
|
|
243
|
+
if(req.method!=='GET')return;
|
|
244
|
+
const url=new URL(req.url);
|
|
245
|
+
if(url.origin!==self.location.origin)return;
|
|
246
|
+
if(NEVER_CACHE.some((p)=>url.pathname.startsWith(p)))return;
|
|
247
|
+
const rule=ruleFor(url);
|
|
248
|
+
if(!rule)return;
|
|
249
|
+
const fn=STRATEGIES[rule.s];
|
|
250
|
+
if(!fn)return;
|
|
251
|
+
// Every proxied request carries the client's build id so the server can detect skew.
|
|
252
|
+
const tagged=new Request(req,{headers:withBuild(req.headers)});
|
|
253
|
+
event.respondWith(fn(tagged,cacheName(rule.c),()=>offlineFallback(req)));
|
|
254
|
+
});
|
|
255
|
+
function withBuild(headers){
|
|
256
|
+
const h=new Headers(headers);h.set(BUILD_HEADER,BUILD_ID);return h
|
|
257
|
+
}`.trim();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function messageBlock(): string {
|
|
261
|
+
return `
|
|
262
|
+
self.addEventListener('message',(event)=>{
|
|
263
|
+
const d=event.data||{};
|
|
264
|
+
if(d.type==='skip-waiting')self.skipWaiting();
|
|
265
|
+
if(d.type==='build-id')event.source&&event.source.postMessage({type:'build-id',buildId:BUILD_ID});
|
|
266
|
+
});`.trim();
|
|
267
|
+
}
|