@pylonsync/functions 0.22.47 → 0.22.58

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.
@@ -0,0 +1,393 @@
1
+ // The offline service worker. Every test runs the generated `/pylon-sw.js`
2
+ // source (renderServiceWorker), not the TypeScript function, in a fresh VM
3
+ // context against a fake ServiceWorkerGlobalScope: what passes here is what
4
+ // the browser runs, and a worker body that leaned on module scope would fail.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { runInNewContext } from "node:vm";
8
+ import {
9
+ classifyRequest,
10
+ renderServiceWorker,
11
+ type ServiceWorkerConfig,
12
+ } from "./offline-sw";
13
+
14
+ const ORIGIN = "https://app.example";
15
+
16
+ const CONFIG: ServiceWorkerConfig = {
17
+ version: "v1",
18
+ buildPrefix: "/_pylon/build/",
19
+ precache: [
20
+ "/_pylon/build/manifest.json",
21
+ "/_pylon/build/client-entry-app__leads__page-abc12345.js",
22
+ "/_pylon/build/chunks/chunk-def67890.js",
23
+ "/_pylon/build/styles-0000abcd.css",
24
+ "/manifest.webmanifest",
25
+ "/icons/192.png",
26
+ ],
27
+ shells: ["/leads", "/map"],
28
+ routes: ["/leads", "/leads/[id]", "/map"],
29
+ startPath: "/leads",
30
+ navigationTimeoutMs: 50,
31
+ };
32
+
33
+ const req = (url: string, mode = "cors", method = "GET") => ({
34
+ url: url.startsWith("http") ? url : ORIGIN + url,
35
+ method,
36
+ mode,
37
+ });
38
+
39
+ describe("classifyRequest", () => {
40
+ test("page loads are navigations", () => {
41
+ expect(classifyRequest(req("/leads", "navigate"), CONFIG, ORIGIN)).toBe("navigate");
42
+ expect(classifyRequest(req("/anything/else", "navigate"), CONFIG, ORIGIN)).toBe("navigate");
43
+ });
44
+
45
+ test("build files are cache-first, same-origin or on the CDN", () => {
46
+ expect(classifyRequest(req("/_pylon/build/chunks/a-123.js"), CONFIG, ORIGIN)).toBe("build");
47
+ expect(classifyRequest(req("/_pylon/build/manifest.json"), CONFIG, ORIGIN)).toBe("build");
48
+ const cdn = { ...CONFIG, buildPrefix: "https://assets.example/b1/" };
49
+ expect(classifyRequest(req("https://assets.example/b1/x-123.js"), cdn, ORIGIN)).toBe("build");
50
+ expect(classifyRequest(req("https://assets.example/other.js"), cdn, ORIGIN)).toBe("network");
51
+ });
52
+
53
+ test("the web app manifest and icons are served from the cache", () => {
54
+ expect(classifyRequest(req("/manifest.webmanifest"), CONFIG, ORIGIN)).toBe("precached");
55
+ expect(classifyRequest(req("/icons/192.png"), CONFIG, ORIGIN)).toBe("precached");
56
+ });
57
+
58
+ test("API, auth, sync, dev and the worker itself are never intercepted", () => {
59
+ for (const path of [
60
+ "/api/entities/Lead",
61
+ "/api/auth/session",
62
+ "/api/sync/pull",
63
+ "/api/fn/listLeads",
64
+ "/auth/callback",
65
+ "/_pylon/dev/live",
66
+ "/events",
67
+ "/studio",
68
+ "/health",
69
+ "/pylon-sw.js",
70
+ "/_pylon/version",
71
+ "/.well-known/openid-configuration",
72
+ ]) {
73
+ expect(classifyRequest(req(path), CONFIG, ORIGIN)).toBe("network");
74
+ expect(classifyRequest(req(path, "navigate"), CONFIG, ORIGIN)).toBe("network");
75
+ }
76
+ });
77
+
78
+ test("writes and other origins are not intercepted", () => {
79
+ expect(classifyRequest(req("/_pylon/build/x.js", "cors", "POST"), CONFIG, ORIGIN)).toBe(
80
+ "network",
81
+ );
82
+ expect(classifyRequest(req("https://maps.example/tile.png"), CONFIG, ORIGIN)).toBe("network");
83
+ expect(classifyRequest(req("/favicon.ico"), CONFIG, ORIGIN)).toBe("network");
84
+ });
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // A fake ServiceWorkerGlobalScope.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ type Handler = (event: any) => void;
92
+
93
+ class FakeCache {
94
+ entries = new Map<string, Response>();
95
+ constructor(private scope: any) {}
96
+ key(r: any): string {
97
+ const url = typeof r === "string" ? r : r.url;
98
+ return new URL(url, ORIGIN).href;
99
+ }
100
+ async match(r: any) {
101
+ const hit = this.entries.get(this.key(r));
102
+ return hit ? hit.clone() : undefined;
103
+ }
104
+ async put(r: any, res: Response) {
105
+ this.entries.set(this.key(r), res);
106
+ }
107
+ async delete(r: any) {
108
+ return this.entries.delete(this.key(r));
109
+ }
110
+ async addAll(urls: string[]) {
111
+ for (const u of urls) {
112
+ const res = await this.scope.fetch(u);
113
+ if (!res.ok) throw new Error(`addAll: ${u} ${res.status}`);
114
+ await this.put(u, res);
115
+ }
116
+ }
117
+ }
118
+
119
+ function makeScope(fetchImpl: (url: string, init?: any) => Promise<Response>) {
120
+ const handlers: Record<string, Handler[]> = {};
121
+ const caches = new Map<string, FakeCache>();
122
+ const fetchLog: Array<{ url: string; headers: Record<string, string> }> = [];
123
+ const s: any = {
124
+ location: { origin: ORIGIN },
125
+ console: { warn: () => {} },
126
+ Response,
127
+ setTimeout,
128
+ clearTimeout,
129
+ claimed: false,
130
+ skippedWaiting: 0,
131
+ skipWaiting: async () => {
132
+ s.skippedWaiting++;
133
+ },
134
+ clients: {
135
+ claim: async () => {
136
+ s.claimed = true;
137
+ },
138
+ },
139
+ caches: {
140
+ open: async (name: string) => {
141
+ if (!caches.has(name)) caches.set(name, new FakeCache(s));
142
+ return caches.get(name)!;
143
+ },
144
+ keys: async () => Array.from(caches.keys()),
145
+ delete: async (name: string) => caches.delete(name),
146
+ },
147
+ fetch: async (input: any, init?: any) => {
148
+ const url = new URL(typeof input === "string" ? input : input.url, ORIGIN).href;
149
+ fetchLog.push({ url, headers: { ...(init?.headers ?? {}) } });
150
+ return fetchImpl(url, init);
151
+ },
152
+ addEventListener: (type: string, h: Handler) => {
153
+ (handlers[type] ||= []).push(h);
154
+ },
155
+ };
156
+ runInNewContext(renderServiceWorker(CONFIG), { self: s, URL });
157
+ async function dispatch(type: string, extra: Record<string, unknown> = {}) {
158
+ let responded: Promise<Response> | null = null;
159
+ const waits: Promise<unknown>[] = [];
160
+ const event = {
161
+ ...extra,
162
+ waitUntil: (p: Promise<unknown>) => waits.push(p),
163
+ respondWith: (p: Promise<Response>) => {
164
+ responded = p;
165
+ },
166
+ };
167
+ for (const h of handlers[type] ?? []) h(event);
168
+ await Promise.all(waits);
169
+ return responded as Promise<Response> | null;
170
+ }
171
+ return { s, caches, fetchLog, dispatch };
172
+ }
173
+
174
+ function html(body: string, headers: Record<string, string> = {}) {
175
+ return new Response(body, {
176
+ status: 200,
177
+ headers: { "Content-Type": "text/html", ...headers },
178
+ });
179
+ }
180
+
181
+ /** A server that answers every URL and marks shell requests with `verdict`. */
182
+ function onlineServer(verdict = "1") {
183
+ return async (url: string, init?: any) => {
184
+ const path = new URL(url).pathname;
185
+ if (init?.headers?.["X-Pylon-Offline-Shell"] === "1") {
186
+ return html(`shell ${path}`, { "Pylon-Offline-Shell": verdict });
187
+ }
188
+ return new Response(`file ${path}`, { status: 200 });
189
+ };
190
+ }
191
+
192
+ const offline = async (): Promise<Response> => {
193
+ throw new TypeError("Failed to fetch");
194
+ };
195
+
196
+ const text = async (p: Promise<Response | null>) => (await p)!.text();
197
+
198
+ describe("install", () => {
199
+ test("precaches the build files and keeps shells the runtime marked", async () => {
200
+ const env = makeScope(onlineServer());
201
+ await env.dispatch("install");
202
+ const assets = env.caches.get("pylon-v1-assets")!;
203
+ for (const url of CONFIG.precache) {
204
+ expect(await assets.match(url)).toBeDefined();
205
+ }
206
+ const shells = env.caches.get("pylon-v1-shells")!;
207
+ expect(await (await shells.match("/leads"))!.text()).toBe("shell /leads");
208
+ expect(await (await shells.match("/map"))!.text()).toBe("shell /map");
209
+ expect(
210
+ env.fetchLog
211
+ .filter((f) => f.headers["X-Pylon-Offline-Shell"] === "1")
212
+ .map((f) => f.url),
213
+ ).toEqual([`${ORIGIN}/leads`, `${ORIGIN}/map`]);
214
+ });
215
+
216
+ test("a page the runtime refused is not kept", async () => {
217
+ const env = makeScope(onlineServer("refused; the page read props.auth"));
218
+ await env.dispatch("install");
219
+ const shells = env.caches.get("pylon-v1-shells")!;
220
+ expect(await shells.match("/leads")).toBeUndefined();
221
+ });
222
+
223
+ test("a build file that fails to download fails the install", async () => {
224
+ const env = makeScope(async (url) =>
225
+ url.endsWith(".css") ? new Response("", { status: 404 }) : new Response("ok"),
226
+ );
227
+ await expect(env.dispatch("install")).rejects.toThrow();
228
+ });
229
+ });
230
+
231
+ describe("navigation", () => {
232
+ test("online: the network answers", async () => {
233
+ const env = makeScope(onlineServer());
234
+ await env.dispatch("install");
235
+ expect(await text(env.dispatch("fetch", { request: req("/leads", "navigate") }))).toBe(
236
+ "file /leads",
237
+ );
238
+ });
239
+
240
+ test("offline: the route's own shell, else the start URL's", async () => {
241
+ let online = true;
242
+ const env = makeScope(async (url, init) => (online ? onlineServer()(url, init) : offline()));
243
+ await env.dispatch("install");
244
+ online = false;
245
+ const nav = (path: string) => text(env.dispatch("fetch", { request: req(path, "navigate") }));
246
+ expect(await nav("/map")).toBe("shell /map");
247
+ expect(await nav("/map/")).toBe("shell /map");
248
+ // A dynamic offline route has no shell of its own.
249
+ expect(await nav("/leads/l_42")).toBe("shell /leads");
250
+ // Not an offline route: the start page beats the browser's offline page.
251
+ expect(await nav("/settings")).toBe("shell /leads");
252
+ });
253
+
254
+ test("a slow network on an offline route gives way to the shell", async () => {
255
+ let slow = false;
256
+ const env = makeScope(async (url, init) => {
257
+ if (slow) return new Promise<Response>((r) => setTimeout(() => r(html("late")), 500));
258
+ return onlineServer()(url, init);
259
+ });
260
+ await env.dispatch("install");
261
+ slow = true;
262
+ expect(await text(env.dispatch("fetch", { request: req("/leads", "navigate") }))).toBe(
263
+ "shell /leads",
264
+ );
265
+ });
266
+
267
+ test("a slow network on another route is waited for", async () => {
268
+ let slow = false;
269
+ const env = makeScope(async (url, init) => {
270
+ if (slow) return new Promise<Response>((r) => setTimeout(() => r(html("late")), 120));
271
+ return onlineServer()(url, init);
272
+ });
273
+ await env.dispatch("install");
274
+ slow = true;
275
+ expect(await text(env.dispatch("fetch", { request: req("/settings", "navigate") }))).toBe(
276
+ "late",
277
+ );
278
+ });
279
+
280
+ test("offline with nothing kept: the network error goes through", async () => {
281
+ const env = makeScope(offline);
282
+ // dispatch() adopts the respondWith promise, so its rejection surfaces here.
283
+ await expect(env.dispatch("fetch", { request: req("/leads", "navigate") })).rejects.toThrow(
284
+ "Failed to fetch",
285
+ );
286
+ });
287
+ });
288
+
289
+ describe("build files and other requests", () => {
290
+ test("cache first; a miss is fetched once and kept", async () => {
291
+ let online = true;
292
+ const env = makeScope(async (url, init) => (online ? onlineServer()(url, init) : offline()));
293
+ await env.dispatch("install");
294
+ online = false;
295
+ expect(
296
+ await text(env.dispatch("fetch", { request: req("/_pylon/build/chunks/chunk-def67890.js") })),
297
+ ).toBe("file /_pylon/build/chunks/chunk-def67890.js");
298
+
299
+ online = true;
300
+ const lazy = "/_pylon/build/chunks/lazy-11112222.js";
301
+ await text(env.dispatch("fetch", { request: req(lazy) }));
302
+ online = false;
303
+ expect(await text(env.dispatch("fetch", { request: req(lazy) }))).toBe(`file ${lazy}`);
304
+ });
305
+
306
+ test("API and sync requests are left to the network", async () => {
307
+ const env = makeScope(onlineServer());
308
+ for (const path of ["/api/sync/pull", "/api/auth/session", "/api/entities/Lead"]) {
309
+ expect(await env.dispatch("fetch", { request: req(path) })).toBeNull();
310
+ }
311
+ expect(
312
+ await env.dispatch("fetch", { request: req("/api/entities/Lead", "cors", "POST") }),
313
+ ).toBeNull();
314
+ });
315
+ });
316
+
317
+ describe("activate and session changes", () => {
318
+ test("activate deletes other builds' caches, keeps the app's own, and claims", async () => {
319
+ const env = makeScope(onlineServer());
320
+ await env.s.caches.open("pylon-old-assets");
321
+ await env.s.caches.open("pylon-old-shells");
322
+ await env.s.caches.open("app-images");
323
+ await env.dispatch("install");
324
+ await env.dispatch("activate");
325
+ expect((await env.s.caches.keys()).sort()).toEqual([
326
+ "app-images",
327
+ "pylon-v1-assets",
328
+ "pylon-v1-shells",
329
+ ]);
330
+ expect(env.s.claimed).toBe(true);
331
+ });
332
+
333
+ test("a change in signed-in state refetches the shells; no change does not", async () => {
334
+ const env = makeScope(onlineServer());
335
+ await env.dispatch("install");
336
+ const shellFetches = () =>
337
+ env.fetchLog.filter((f) => f.headers["X-Pylon-Offline-Shell"] === "1").length;
338
+ expect(shellFetches()).toBe(2);
339
+ await env.dispatch("message", { data: { type: "pylon:session", signedIn: true } });
340
+ expect(shellFetches()).toBe(4);
341
+ await env.dispatch("message", { data: { type: "pylon:session", signedIn: true } });
342
+ expect(shellFetches()).toBe(4);
343
+ await env.dispatch("message", { data: { type: "pylon:session", signedIn: false } });
344
+ expect(shellFetches()).toBe(6);
345
+ });
346
+
347
+ test("signing out drops a shell the server now redirects", async () => {
348
+ let signedIn = true;
349
+ const env = makeScope(async (url, init) => {
350
+ if (!signedIn && init?.headers?.["X-Pylon-Offline-Shell"] === "1") {
351
+ const r = html("sign in");
352
+ Object.defineProperty(r, "redirected", { value: true });
353
+ return r;
354
+ }
355
+ return onlineServer()(url, init);
356
+ });
357
+ await env.dispatch("install");
358
+ signedIn = false;
359
+ await env.dispatch("message", { data: { type: "pylon:session", signedIn: false } });
360
+ expect(await env.caches.get("pylon-v1-shells")!.match("/leads")).toBeUndefined();
361
+ });
362
+ });
363
+
364
+ describe("app updates", () => {
365
+ test("skip-waiting activates the waiting worker", async () => {
366
+ const env = makeScope(onlineServer());
367
+ await env.dispatch("install");
368
+ expect(env.s.skippedWaiting).toBe(0);
369
+ await env.dispatch("message", { data: { type: "pylon:skip-waiting" } });
370
+ expect(env.s.skippedWaiting).toBe(1);
371
+ });
372
+
373
+ test("a version request is answered on its port", async () => {
374
+ const env = makeScope(onlineServer());
375
+ const replies: unknown[] = [];
376
+ await env.dispatch("message", {
377
+ data: { type: "pylon:version" },
378
+ ports: [{ postMessage: (m: unknown) => replies.push(m) }],
379
+ });
380
+ expect(replies).toEqual([{ type: "pylon:version", version: "v1" }]);
381
+ // No port: nothing to answer on, and no error.
382
+ await env.dispatch("message", { data: { type: "pylon:version" } });
383
+ expect(replies).toHaveLength(1);
384
+ });
385
+
386
+ test("other messages do nothing", async () => {
387
+ const env = makeScope(onlineServer());
388
+ await env.dispatch("message", { data: { type: "something-else" } });
389
+ await env.dispatch("message", { data: null });
390
+ expect(env.s.skippedWaiting).toBe(0);
391
+ expect(env.fetchLog).toHaveLength(0);
392
+ });
393
+ });