@timmo001/groundwork-pwa 0.0.0-stage → 0.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/src/worker.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The service worker half of `@timmo001/groundwork-pwa`: keeps the app shell
3
+ * so the app starts offline. Pages come from the network first and fall back
4
+ * to the cached start page; the app's other files come from the cache while
5
+ * a fresh copy is fetched for next time. Requests to other origins, such as
6
+ * the app's backend, are left alone unless one of the app's routes takes them.
7
+ *
8
+ * The app's `sw.ts`, bundled to the config's `offline.worker`:
9
+ *
10
+ * import { serveShell, type ShellWorkerScope } from "@timmo001/groundwork-pwa/worker";
11
+ * import config from "../pwa.config.ts";
12
+ *
13
+ * declare const self: ShellWorkerScope;
14
+ *
15
+ * serveShell(self, config, { version: "1" });
16
+ */
17
+ import { resolveConfig, type PwaConfig } from "./config.ts";
18
+ import { decodePushPayload, type PushPayload } from "./push.ts";
19
+
20
+ /** An install or activate event, which the worker can keep alive. */
21
+ export interface ShellExtendableEvent extends Event {
22
+ readonly waitUntil: (promise: Promise<unknown>) => void;
23
+ }
24
+
25
+ /** A request the worker can answer. */
26
+ export interface ShellFetchEvent extends ShellExtendableEvent {
27
+ readonly request: Request;
28
+ readonly respondWith: (response: Promise<Response>) => void;
29
+ }
30
+
31
+ /** The parts of a service worker's global scope the shell uses. */
32
+ export interface ShellWorkerScope {
33
+ readonly location: { readonly href: string; readonly origin: string };
34
+ readonly caches: CacheStorage;
35
+ readonly clients: { readonly claim: () => Promise<void> };
36
+ readonly skipWaiting: () => Promise<void>;
37
+ readonly addEventListener: {
38
+ (
39
+ type: "install" | "activate",
40
+ listener: (event: ShellExtendableEvent) => void,
41
+ ): void;
42
+ (type: "fetch", listener: (event: ShellFetchEvent) => void): void;
43
+ };
44
+ }
45
+
46
+ /** The shell's cache, for the strategies and the app's own routes. */
47
+ export interface ShellCache {
48
+ readonly caches: CacheStorage;
49
+ /** This build's cache, such as `gw-shell-1`. */
50
+ readonly name: string;
51
+ }
52
+
53
+ /**
54
+ * One of the app's own routes, tried before the shell's. Return a response
55
+ * to answer the request, or `undefined` to leave it to the next route and
56
+ * then the shell.
57
+ */
58
+ export type ShellRoute = (
59
+ event: ShellFetchEvent,
60
+ cache: ShellCache,
61
+ ) => Promise<Response> | undefined;
62
+
63
+ /** What `serveShell` takes beside the config. */
64
+ export interface ShellOptions {
65
+ /**
66
+ * The cache's version. A new one, such as the build's commit, replaces the
67
+ * old cache once the new worker takes over.
68
+ */
69
+ readonly version: string;
70
+ /** The app's routes, tried in order before the shell's. */
71
+ readonly routes?: readonly ShellRoute[];
72
+ }
73
+
74
+ const cachePrefix = "gw-shell-";
75
+
76
+ /** Puts a good same-origin response in the cache, and passes it on. */
77
+ export const keepResponse = async (
78
+ cache: ShellCache,
79
+ request: Request,
80
+ response: Response,
81
+ ): Promise<Response> => {
82
+ if (response.ok && response.type === "basic") {
83
+ const open = await cache.caches.open(cache.name);
84
+
85
+ await open.put(request, response.clone());
86
+ }
87
+
88
+ return response;
89
+ };
90
+
91
+ /**
92
+ * Fetches from the network and keeps the response, falling back to the
93
+ * cached copy, then to `fallback`, such as the start page, when offline.
94
+ */
95
+ export const networkFirst = (
96
+ cache: ShellCache,
97
+ request: Request,
98
+ fallback?: string,
99
+ ): Promise<Response> =>
100
+ fetch(request)
101
+ .then((response) => keepResponse(cache, request, response))
102
+ .catch(async () => {
103
+ const cached =
104
+ (await cache.caches.match(request, { ignoreSearch: true })) ??
105
+ (fallback === undefined
106
+ ? undefined
107
+ : await cache.caches.match(fallback));
108
+
109
+ return cached ?? Response.error();
110
+ });
111
+
112
+ /** Answers from the cache while a fresh copy is fetched for next time. */
113
+ export const staleWhileRevalidate = async (
114
+ cache: ShellCache,
115
+ event: ShellFetchEvent,
116
+ ): Promise<Response> => {
117
+ const { request } = event;
118
+ const cached = await cache.caches.match(request);
119
+
120
+ const fresh = fetch(request).then((response) =>
121
+ keepResponse(cache, request, response),
122
+ );
123
+
124
+ if (cached === undefined) return fresh;
125
+
126
+ event.waitUntil(fresh.catch(() => undefined));
127
+
128
+ return cached;
129
+ };
130
+
131
+ /** Caches the app shell and answers the app's requests from it when offline. */
132
+ export const serveShell = (
133
+ scope: ShellWorkerScope,
134
+ config: PwaConfig,
135
+ { version, routes = [] }: ShellOptions,
136
+ ): void => {
137
+ const pwa = resolveConfig(config);
138
+ const startUrl = new URL(pwa.startUrl, scope.location.href).href;
139
+
140
+ const cache: ShellCache = {
141
+ caches: scope.caches,
142
+ name: `${cachePrefix}${version}`,
143
+ };
144
+
145
+ const shell =
146
+ pwa.offline === false
147
+ ? []
148
+ : pwa.offline.shell.map((url) => new URL(url, scope.location.href).href);
149
+
150
+ scope.addEventListener("install", (event) => {
151
+ event.waitUntil(
152
+ scope.caches
153
+ .open(cache.name)
154
+ .then((open) => open.addAll(shell))
155
+ .then(() => scope.skipWaiting()),
156
+ );
157
+ });
158
+
159
+ scope.addEventListener("activate", (event) => {
160
+ event.waitUntil(
161
+ scope.caches
162
+ .keys()
163
+ .then((names) =>
164
+ Promise.all(
165
+ names
166
+ .filter(
167
+ (name) => name.startsWith(cachePrefix) && name !== cache.name,
168
+ )
169
+ .map((name) => scope.caches.delete(name)),
170
+ ),
171
+ )
172
+ .then(() => scope.clients.claim()),
173
+ );
174
+ });
175
+
176
+ scope.addEventListener("fetch", (event) => {
177
+ for (const route of routes) {
178
+ const response = route(event, cache);
179
+
180
+ if (response !== undefined) {
181
+ event.respondWith(response);
182
+
183
+ return;
184
+ }
185
+ }
186
+
187
+ const { request } = event;
188
+
189
+ if (
190
+ request.method !== "GET" ||
191
+ new URL(request.url).origin !== scope.location.origin
192
+ )
193
+ return;
194
+
195
+ event.respondWith(
196
+ request.mode === "navigate"
197
+ ? networkFirst(cache, request, startUrl)
198
+ : staleWhileRevalidate(cache, event),
199
+ );
200
+ });
201
+ };
202
+
203
+ /** A push arriving at the worker. */
204
+ export interface ShellPushEvent extends ShellExtendableEvent {
205
+ readonly data: { readonly text: () => string } | null;
206
+ }
207
+
208
+ /** A tap on one of the app's notifications. */
209
+ export interface ShellNotificationEvent extends ShellExtendableEvent {
210
+ readonly notification: Notification;
211
+ }
212
+
213
+ /** An open window of the app, which a notification tap can bring forward. */
214
+ export interface ShellWindowClient {
215
+ readonly url: string;
216
+ readonly focus: () => Promise<object>;
217
+ readonly navigate: (url: string) => Promise<object | null>;
218
+ }
219
+
220
+ /** The parts of a service worker's global scope push uses. */
221
+ export interface PushWorkerScope {
222
+ readonly location: { readonly href: string };
223
+ readonly registration: {
224
+ readonly showNotification: (
225
+ title: string,
226
+ options?: NotificationOptions,
227
+ ) => Promise<void>;
228
+ };
229
+ readonly clients: {
230
+ readonly matchAll: (options: {
231
+ readonly type: "window";
232
+ readonly includeUncontrolled: boolean;
233
+ }) => Promise<readonly ShellWindowClient[]>;
234
+ readonly openWindow: (url: string) => Promise<object | null>;
235
+ };
236
+ readonly addEventListener: {
237
+ (type: "push", listener: (event: ShellPushEvent) => void): void;
238
+ (
239
+ type: "notificationclick",
240
+ listener: (event: ShellNotificationEvent) => void,
241
+ ): void;
242
+ };
243
+ }
244
+
245
+ /** What `servePush` takes beside the config. Each replaces a default. */
246
+ export interface PushOptions {
247
+ /** The notification for a payload. Defaults to {@link pushNotification}. */
248
+ readonly show?: (payload: PushPayload) => {
249
+ readonly title: string;
250
+ readonly options: NotificationOptions;
251
+ };
252
+ /** The page a tap opens. Defaults to the payload's `link`, or the start page. */
253
+ readonly open?: (payload: PushPayload) => string | undefined;
254
+ /** Called with a push that isn't a payload, which shows nothing by default. */
255
+ readonly onOther?: (text: string) => Promise<void> | void;
256
+ }
257
+
258
+ /**
259
+ * The default notification for a payload: its title and body, the tag that
260
+ * makes like ones replace each other, quiet for a passive one, and the
261
+ * payload kept on the notification for the tap. Ongoing work shows its status
262
+ * and progress, and stays until it's done.
263
+ */
264
+ export const pushNotification = (payload: PushPayload) => {
265
+ const progress =
266
+ payload.ongoing?.progress === undefined
267
+ ? ""
268
+ : ` (${Math.round((payload.ongoing.progress.value / payload.ongoing.progress.max) * 100)}%)`;
269
+
270
+ const body =
271
+ payload.ongoing === undefined
272
+ ? payload.body
273
+ : `${payload.ongoing.status}${progress}`;
274
+
275
+ const tag = payload.ongoing?.id ?? payload.tag;
276
+
277
+ const options: NotificationOptions = {
278
+ data: payload,
279
+ silent: payload.level === "passive",
280
+ requireInteraction:
281
+ payload.ongoing !== undefined && payload.ongoing.done !== true,
282
+ };
283
+
284
+ if (body !== undefined) options.body = body;
285
+
286
+ if (tag !== undefined) options.tag = tag;
287
+
288
+ return { title: payload.title, options };
289
+ };
290
+
291
+ /**
292
+ * Shows the app's pushes as notifications and opens the app at the page a
293
+ * tap points to, bringing an open window forward rather than opening another.
294
+ */
295
+ export const servePush = (
296
+ scope: PushWorkerScope,
297
+ config: PwaConfig,
298
+ { show = pushNotification, open, onOther }: PushOptions = {},
299
+ ): void => {
300
+ const pwa = resolveConfig(config);
301
+ const base = new URL(pwa.scope, scope.location.href).href;
302
+
303
+ scope.addEventListener("push", (event) => {
304
+ const text = event.data?.text() ?? "";
305
+ const payload = decodePushPayload(text);
306
+
307
+ if (payload === undefined) {
308
+ if (onOther !== undefined)
309
+ event.waitUntil(Promise.resolve(onOther(text)));
310
+
311
+ return;
312
+ }
313
+
314
+ const { title, options } = show(payload);
315
+
316
+ event.waitUntil(scope.registration.showNotification(title, options));
317
+ });
318
+
319
+ scope.addEventListener("notificationclick", (event) => {
320
+ event.notification.close();
321
+
322
+ const payload = decodePushPayload(JSON.stringify(event.notification.data));
323
+
324
+ const target = new URL(
325
+ (payload === undefined ? undefined : open?.(payload)) ??
326
+ payload?.link ??
327
+ pwa.startUrl,
328
+ base,
329
+ ).href;
330
+
331
+ event.waitUntil(
332
+ scope.clients
333
+ .matchAll({ type: "window", includeUncontrolled: true })
334
+ .then(async (windows) => {
335
+ const existing = windows.find(({ url }) => url.startsWith(base));
336
+
337
+ if (existing === undefined) return scope.clients.openWindow(target);
338
+
339
+ await existing.navigate(target);
340
+
341
+ return existing.focus();
342
+ }),
343
+ );
344
+ });
345
+ };