@pylonsync/functions 0.22.47 → 0.22.50
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/dist/offline-sw.d.ts +46 -0
- package/dist/ssr-client-bundler.d.ts +6 -0
- package/dist/ssr-offline.d.ts +64 -0
- package/dist/ssr-route-assets.d.ts +35 -0
- package/dist/ssr-runtime.d.ts +34 -1
- package/package.json +1 -1
- package/src/offline-sw.test.ts +357 -0
- package/src/offline-sw.ts +330 -0
- package/src/ssr-client-bundler.test.ts +63 -0
- package/src/ssr-client-bundler.ts +120 -1
- package/src/ssr-offline-shell.test.ts +248 -0
- package/src/ssr-offline.ts +205 -0
- package/src/ssr-route-assets.ts +68 -0
- package/src/ssr-runtime.ts +121 -9
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** The build-specific values inlined into `/pylon-sw.js`. */
|
|
2
|
+
export interface ServiceWorkerConfig {
|
|
3
|
+
/** Hash of everything below. Cache names carry it. */
|
|
4
|
+
version: string;
|
|
5
|
+
/** The client build's public prefix (`/_pylon/build/`, or a CDN URL). */
|
|
6
|
+
buildPrefix: string;
|
|
7
|
+
/** URLs to keep at install: the offline routes' build files, the web app
|
|
8
|
+
* manifest, the icons. */
|
|
9
|
+
precache: string[];
|
|
10
|
+
/** Offline routes without dynamic segments: their HTML is kept at install. */
|
|
11
|
+
shells: string[];
|
|
12
|
+
/** Every offline route pattern (`/leads/[id]` included). */
|
|
13
|
+
routes: string[];
|
|
14
|
+
/** Path of the start URL; its shell answers any navigation with no
|
|
15
|
+
* better match. */
|
|
16
|
+
startPath: string;
|
|
17
|
+
/** How long a navigation to an offline route waits for the network before
|
|
18
|
+
* the worker answers with the shell. */
|
|
19
|
+
navigationTimeoutMs: number;
|
|
20
|
+
}
|
|
21
|
+
/** What the worker does with one request. */
|
|
22
|
+
export type RequestKind =
|
|
23
|
+
/** A page load: network first, shell when offline. */
|
|
24
|
+
"navigate"
|
|
25
|
+
/** A file of the client build: cache first. */
|
|
26
|
+
| "build"
|
|
27
|
+
/** The web app manifest or an icon: cache first. */
|
|
28
|
+
| "precached"
|
|
29
|
+
/** Not intercepted, never cached. */
|
|
30
|
+
| "network";
|
|
31
|
+
/**
|
|
32
|
+
* Classify one request. Pure; `self.location.origin` is passed in.
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifyRequest(request: {
|
|
35
|
+
url: string;
|
|
36
|
+
method: string;
|
|
37
|
+
mode: string;
|
|
38
|
+
}, config: ServiceWorkerConfig, origin: string): RequestKind;
|
|
39
|
+
/**
|
|
40
|
+
* The worker. `sw` is the ServiceWorkerGlobalScope (`self`); passing it in
|
|
41
|
+
* keeps the body free of globals so tests can supply their own. `classify`
|
|
42
|
+
* is `classifyRequest`, passed in for the same reason.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pylonServiceWorker(sw: any, config: ServiceWorkerConfig, classify: typeof classifyRequest): void;
|
|
45
|
+
/** The `/pylon-sw.js` source for one build. */
|
|
46
|
+
export declare function renderServiceWorker(config: ServiceWorkerConfig): string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ManifestFonts } from "./ssr-fonts";
|
|
2
2
|
import { type ManifestBuildConfig } from "./build-compat";
|
|
3
|
+
import { type BundleOffline } from "./ssr-offline";
|
|
3
4
|
type Send = (msg: Record<string, unknown>) => void;
|
|
4
5
|
interface BundleClientMessage {
|
|
5
6
|
type: "bundle_client";
|
|
@@ -96,6 +97,11 @@ export interface PylonBundleManifest {
|
|
|
96
97
|
* independent); rendered into every SSR `<head>` against `public_prefix`.
|
|
97
98
|
* Absent when the app declares no `font({...})`. */
|
|
98
99
|
fonts?: ManifestFonts;
|
|
100
|
+
/** `buildManifest({ offline })`: the install tags for every page's head,
|
|
101
|
+
* the routes the client may render with no network, and the service
|
|
102
|
+
* worker version. The worker is `pylon-sw.js` in the outdir. Absent when
|
|
103
|
+
* the app did not opt in. */
|
|
104
|
+
offline?: BundleOffline;
|
|
99
105
|
/** Set when the Tailwind compile failed for this build: the pages are
|
|
100
106
|
* serving WITHOUT styles. Dev surfaces it as an on-page banner (see
|
|
101
107
|
* pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ServiceWorkerConfig } from "./offline-sw";
|
|
2
|
+
import { type RouteAssetManifest } from "./ssr-route-assets";
|
|
3
|
+
/** `buildManifest({ offline })` as the SDK writes it to pylon.manifest.json. */
|
|
4
|
+
export interface ManifestOfflineInput {
|
|
5
|
+
name: string;
|
|
6
|
+
shortName?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
icons?: Array<{
|
|
9
|
+
src: string;
|
|
10
|
+
sizes: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
purpose?: string;
|
|
13
|
+
}>;
|
|
14
|
+
themeColor?: string;
|
|
15
|
+
backgroundColor?: string;
|
|
16
|
+
startUrl: string;
|
|
17
|
+
display: string;
|
|
18
|
+
routes: string[];
|
|
19
|
+
}
|
|
20
|
+
/** What the bundle manifest keeps for the render and the client runtime. */
|
|
21
|
+
export interface BundleOffline {
|
|
22
|
+
name: string;
|
|
23
|
+
shortName?: string;
|
|
24
|
+
themeColor?: string;
|
|
25
|
+
icons?: Array<{
|
|
26
|
+
src: string;
|
|
27
|
+
sizes: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
purpose?: string;
|
|
30
|
+
}>;
|
|
31
|
+
startUrl: string;
|
|
32
|
+
routes: string[];
|
|
33
|
+
/** The service worker's version (its cache names carry it). */
|
|
34
|
+
version: string;
|
|
35
|
+
}
|
|
36
|
+
/** How long a navigation to an offline route waits for the network before
|
|
37
|
+
* the worker shows the kept page. Long enough for a slow but working
|
|
38
|
+
* connection to win; short enough that a dead one does not leave a blank
|
|
39
|
+
* screen. */
|
|
40
|
+
export declare const NAVIGATION_TIMEOUT_MS = 3000;
|
|
41
|
+
/** The `offline` block of `<cwd>/pylon.manifest.json`, or null. */
|
|
42
|
+
export declare function readManifestOffline(fs: any, path: any, cwd: string, manifestPath?: string): ManifestOfflineInput | null;
|
|
43
|
+
/** Every route pattern in `routes` that names a page in this build, with
|
|
44
|
+
* the page's component. A pattern with no page is reported, not dropped
|
|
45
|
+
* silently. */
|
|
46
|
+
export declare function resolveOfflineRoutes(manifest: RouteAssetManifest, routes: string[]): {
|
|
47
|
+
matched: Array<{
|
|
48
|
+
pattern: string;
|
|
49
|
+
component: string;
|
|
50
|
+
}>;
|
|
51
|
+
unknown: string[];
|
|
52
|
+
};
|
|
53
|
+
/** True for a pattern with a dynamic segment: one shell cannot serve it. */
|
|
54
|
+
export declare function isDynamicPattern(pattern: string): boolean;
|
|
55
|
+
/** The service worker config for this build. */
|
|
56
|
+
export declare function offlineWorkerConfig(manifest: RouteAssetManifest, offline: ManifestOfflineInput): Promise<{
|
|
57
|
+
config: ServiceWorkerConfig;
|
|
58
|
+
unknownRoutes: string[];
|
|
59
|
+
}>;
|
|
60
|
+
/** Write `pylon-sw.js` into `outdir` and return the bundle manifest's
|
|
61
|
+
* `offline` block. */
|
|
62
|
+
export declare function buildOfflineWorker(fs: any, path: any, outdir: string, manifest: RouteAssetManifest, offline: ManifestOfflineInput): Promise<BundleOffline>;
|
|
63
|
+
/** `<head>` tags that make the app installable. */
|
|
64
|
+
export declare function offlineHeadTags(offline: BundleOffline): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The bundle-manifest fields this module reads. */
|
|
2
|
+
export interface RouteAssetManifest {
|
|
3
|
+
public_prefix?: string;
|
|
4
|
+
polyfills?: string;
|
|
5
|
+
routes: Record<string, {
|
|
6
|
+
file: string;
|
|
7
|
+
imports?: string[];
|
|
8
|
+
css?: string[];
|
|
9
|
+
path?: string;
|
|
10
|
+
} | undefined>;
|
|
11
|
+
fonts?: {
|
|
12
|
+
preload?: string[];
|
|
13
|
+
} | null;
|
|
14
|
+
}
|
|
15
|
+
/** Outdir-relative files for one route, in the order the page loads them. */
|
|
16
|
+
export interface RouteAssetFiles {
|
|
17
|
+
/** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
|
|
18
|
+
styles: string[];
|
|
19
|
+
/** `<link rel="modulepreload">` in the head. */
|
|
20
|
+
preloads: string[];
|
|
21
|
+
/** `<link rel="preload" as="font">` in the head. */
|
|
22
|
+
fonts: string[];
|
|
23
|
+
/** `<script type="module">` in the body tail: the polyfills, then the
|
|
24
|
+
* route entry. */
|
|
25
|
+
scripts: string[];
|
|
26
|
+
}
|
|
27
|
+
/** The scripts the hydration tail loads for a route entry. */
|
|
28
|
+
export declare function routeScriptFiles(route: {
|
|
29
|
+
file: string;
|
|
30
|
+
}, polyfills: string | undefined): string[];
|
|
31
|
+
/** Every client-build file the page for `component` loads, or null when the
|
|
32
|
+
* build has no entry for it. */
|
|
33
|
+
export declare function routeAssetFiles(manifest: RouteAssetManifest, component: string): RouteAssetFiles | null;
|
|
34
|
+
/** All of a route's files as URLs under the build's public prefix. */
|
|
35
|
+
export declare function routeAssetUrls(manifest: RouteAssetManifest, component: string): string[];
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -490,6 +490,9 @@ export declare function buildHydrationTail(args: {
|
|
|
490
490
|
bucketAuth?: {
|
|
491
491
|
signedIn: boolean;
|
|
492
492
|
};
|
|
493
|
+
/** The app opted into `buildManifest({ offline })`: the client registers
|
|
494
|
+
* the service worker (production only). */
|
|
495
|
+
offline?: boolean;
|
|
493
496
|
/** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
|
|
494
497
|
* nothing else. The client already has the runtime and resolves the route
|
|
495
498
|
* entry from the build manifest, so the entry `<script>`, the preload tags
|
|
@@ -529,7 +532,7 @@ export declare function errorDigest(err: any): string;
|
|
|
529
532
|
* can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
|
|
530
533
|
* — keeping hydration free of mismatches.
|
|
531
534
|
*/
|
|
532
|
-
export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void): any;
|
|
535
|
+
export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void, onRead?: () => void): any;
|
|
533
536
|
/**
|
|
534
537
|
* #278: does this route STREAM (vs buffer the whole document)? Streaming is
|
|
535
538
|
* opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
|
|
@@ -600,6 +603,36 @@ export declare function computeBucketVerdict(args: {
|
|
|
600
603
|
wantsStream: boolean;
|
|
601
604
|
status: number;
|
|
602
605
|
}): boolean;
|
|
606
|
+
/** Whether the host forwarded the service worker's offline-shell request
|
|
607
|
+
* header (`X-Pylon-Offline-Shell: 1`). */
|
|
608
|
+
export declare function isOfflineShellRequest(headers: Record<string, unknown> | undefined): boolean;
|
|
609
|
+
/**
|
|
610
|
+
* May the offline service worker keep this render as the route's shell? The
|
|
611
|
+
* shell is stored on the device and shown, with no network, to whoever is
|
|
612
|
+
* signed in at the time, so its HTML must not depend on who rendered it:
|
|
613
|
+
*
|
|
614
|
+
* - `authTouched`: reading `props.auth` (or a `serverData.fn` call with an
|
|
615
|
+
* identity) makes the HTML one user's. Read `props.session.exists` for a
|
|
616
|
+
* signed-in check; the shell keeps that bit and nothing else.
|
|
617
|
+
* - `dynamicTouched`: request headers and cookies are one request's.
|
|
618
|
+
* - `serverDataTouched`: offline there is no server; an offline route
|
|
619
|
+
* renders its data from the replica with `db.useQuery`.
|
|
620
|
+
* - a Set-Cookie, or any status but 200 (a redirect to sign-in, an error).
|
|
621
|
+
*
|
|
622
|
+
* Pure and exported for the leak-class tests.
|
|
623
|
+
*/
|
|
624
|
+
export declare function computeOfflineShellVerdict(args: {
|
|
625
|
+
authTouched: boolean;
|
|
626
|
+
dynamicTouched: boolean;
|
|
627
|
+
serverDataTouched: boolean;
|
|
628
|
+
cookieCount: number;
|
|
629
|
+
status: number;
|
|
630
|
+
}): {
|
|
631
|
+
ok: true;
|
|
632
|
+
} | {
|
|
633
|
+
ok: false;
|
|
634
|
+
reason: string;
|
|
635
|
+
};
|
|
603
636
|
/**
|
|
604
637
|
* Dev HUD: explain the cache verdict for a finished render in human terms — the
|
|
605
638
|
* verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
|
package/package.json
CHANGED
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
"/.well-known/openid-configuration",
|
|
71
|
+
]) {
|
|
72
|
+
expect(classifyRequest(req(path), CONFIG, ORIGIN)).toBe("network");
|
|
73
|
+
expect(classifyRequest(req(path, "navigate"), CONFIG, ORIGIN)).toBe("network");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("writes and other origins are not intercepted", () => {
|
|
78
|
+
expect(classifyRequest(req("/_pylon/build/x.js", "cors", "POST"), CONFIG, ORIGIN)).toBe(
|
|
79
|
+
"network",
|
|
80
|
+
);
|
|
81
|
+
expect(classifyRequest(req("https://maps.example/tile.png"), CONFIG, ORIGIN)).toBe("network");
|
|
82
|
+
expect(classifyRequest(req("/favicon.ico"), CONFIG, ORIGIN)).toBe("network");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// A fake ServiceWorkerGlobalScope.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
type Handler = (event: any) => void;
|
|
91
|
+
|
|
92
|
+
class FakeCache {
|
|
93
|
+
entries = new Map<string, Response>();
|
|
94
|
+
constructor(private scope: any) {}
|
|
95
|
+
key(r: any): string {
|
|
96
|
+
const url = typeof r === "string" ? r : r.url;
|
|
97
|
+
return new URL(url, ORIGIN).href;
|
|
98
|
+
}
|
|
99
|
+
async match(r: any) {
|
|
100
|
+
const hit = this.entries.get(this.key(r));
|
|
101
|
+
return hit ? hit.clone() : undefined;
|
|
102
|
+
}
|
|
103
|
+
async put(r: any, res: Response) {
|
|
104
|
+
this.entries.set(this.key(r), res);
|
|
105
|
+
}
|
|
106
|
+
async delete(r: any) {
|
|
107
|
+
return this.entries.delete(this.key(r));
|
|
108
|
+
}
|
|
109
|
+
async addAll(urls: string[]) {
|
|
110
|
+
for (const u of urls) {
|
|
111
|
+
const res = await this.scope.fetch(u);
|
|
112
|
+
if (!res.ok) throw new Error(`addAll: ${u} ${res.status}`);
|
|
113
|
+
await this.put(u, res);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeScope(fetchImpl: (url: string, init?: any) => Promise<Response>) {
|
|
119
|
+
const handlers: Record<string, Handler[]> = {};
|
|
120
|
+
const caches = new Map<string, FakeCache>();
|
|
121
|
+
const fetchLog: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
122
|
+
const s: any = {
|
|
123
|
+
location: { origin: ORIGIN },
|
|
124
|
+
console: { warn: () => {} },
|
|
125
|
+
Response,
|
|
126
|
+
setTimeout,
|
|
127
|
+
clearTimeout,
|
|
128
|
+
claimed: false,
|
|
129
|
+
clients: {
|
|
130
|
+
claim: async () => {
|
|
131
|
+
s.claimed = true;
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
caches: {
|
|
135
|
+
open: async (name: string) => {
|
|
136
|
+
if (!caches.has(name)) caches.set(name, new FakeCache(s));
|
|
137
|
+
return caches.get(name)!;
|
|
138
|
+
},
|
|
139
|
+
keys: async () => Array.from(caches.keys()),
|
|
140
|
+
delete: async (name: string) => caches.delete(name),
|
|
141
|
+
},
|
|
142
|
+
fetch: async (input: any, init?: any) => {
|
|
143
|
+
const url = new URL(typeof input === "string" ? input : input.url, ORIGIN).href;
|
|
144
|
+
fetchLog.push({ url, headers: { ...(init?.headers ?? {}) } });
|
|
145
|
+
return fetchImpl(url, init);
|
|
146
|
+
},
|
|
147
|
+
addEventListener: (type: string, h: Handler) => {
|
|
148
|
+
(handlers[type] ||= []).push(h);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
runInNewContext(renderServiceWorker(CONFIG), { self: s, URL });
|
|
152
|
+
async function dispatch(type: string, extra: Record<string, unknown> = {}) {
|
|
153
|
+
let responded: Promise<Response> | null = null;
|
|
154
|
+
const waits: Promise<unknown>[] = [];
|
|
155
|
+
const event = {
|
|
156
|
+
...extra,
|
|
157
|
+
waitUntil: (p: Promise<unknown>) => waits.push(p),
|
|
158
|
+
respondWith: (p: Promise<Response>) => {
|
|
159
|
+
responded = p;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
for (const h of handlers[type] ?? []) h(event);
|
|
163
|
+
await Promise.all(waits);
|
|
164
|
+
return responded as Promise<Response> | null;
|
|
165
|
+
}
|
|
166
|
+
return { s, caches, fetchLog, dispatch };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function html(body: string, headers: Record<string, string> = {}) {
|
|
170
|
+
return new Response(body, {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { "Content-Type": "text/html", ...headers },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A server that answers every URL and marks shell requests with `verdict`. */
|
|
177
|
+
function onlineServer(verdict = "1") {
|
|
178
|
+
return async (url: string, init?: any) => {
|
|
179
|
+
const path = new URL(url).pathname;
|
|
180
|
+
if (init?.headers?.["X-Pylon-Offline-Shell"] === "1") {
|
|
181
|
+
return html(`shell ${path}`, { "Pylon-Offline-Shell": verdict });
|
|
182
|
+
}
|
|
183
|
+
return new Response(`file ${path}`, { status: 200 });
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const offline = async (): Promise<Response> => {
|
|
188
|
+
throw new TypeError("Failed to fetch");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const text = async (p: Promise<Response | null>) => (await p)!.text();
|
|
192
|
+
|
|
193
|
+
describe("install", () => {
|
|
194
|
+
test("precaches the build files and keeps shells the runtime marked", async () => {
|
|
195
|
+
const env = makeScope(onlineServer());
|
|
196
|
+
await env.dispatch("install");
|
|
197
|
+
const assets = env.caches.get("pylon-v1-assets")!;
|
|
198
|
+
for (const url of CONFIG.precache) {
|
|
199
|
+
expect(await assets.match(url)).toBeDefined();
|
|
200
|
+
}
|
|
201
|
+
const shells = env.caches.get("pylon-v1-shells")!;
|
|
202
|
+
expect(await (await shells.match("/leads"))!.text()).toBe("shell /leads");
|
|
203
|
+
expect(await (await shells.match("/map"))!.text()).toBe("shell /map");
|
|
204
|
+
expect(
|
|
205
|
+
env.fetchLog
|
|
206
|
+
.filter((f) => f.headers["X-Pylon-Offline-Shell"] === "1")
|
|
207
|
+
.map((f) => f.url),
|
|
208
|
+
).toEqual([`${ORIGIN}/leads`, `${ORIGIN}/map`]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a page the runtime refused is not kept", async () => {
|
|
212
|
+
const env = makeScope(onlineServer("refused; the page read props.auth"));
|
|
213
|
+
await env.dispatch("install");
|
|
214
|
+
const shells = env.caches.get("pylon-v1-shells")!;
|
|
215
|
+
expect(await shells.match("/leads")).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a build file that fails to download fails the install", async () => {
|
|
219
|
+
const env = makeScope(async (url) =>
|
|
220
|
+
url.endsWith(".css") ? new Response("", { status: 404 }) : new Response("ok"),
|
|
221
|
+
);
|
|
222
|
+
await expect(env.dispatch("install")).rejects.toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("navigation", () => {
|
|
227
|
+
test("online: the network answers", async () => {
|
|
228
|
+
const env = makeScope(onlineServer());
|
|
229
|
+
await env.dispatch("install");
|
|
230
|
+
expect(await text(env.dispatch("fetch", { request: req("/leads", "navigate") }))).toBe(
|
|
231
|
+
"file /leads",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("offline: the route's own shell, else the start URL's", async () => {
|
|
236
|
+
let online = true;
|
|
237
|
+
const env = makeScope(async (url, init) => (online ? onlineServer()(url, init) : offline()));
|
|
238
|
+
await env.dispatch("install");
|
|
239
|
+
online = false;
|
|
240
|
+
const nav = (path: string) => text(env.dispatch("fetch", { request: req(path, "navigate") }));
|
|
241
|
+
expect(await nav("/map")).toBe("shell /map");
|
|
242
|
+
expect(await nav("/map/")).toBe("shell /map");
|
|
243
|
+
// A dynamic offline route has no shell of its own.
|
|
244
|
+
expect(await nav("/leads/l_42")).toBe("shell /leads");
|
|
245
|
+
// Not an offline route: the start page beats the browser's offline page.
|
|
246
|
+
expect(await nav("/settings")).toBe("shell /leads");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a slow network on an offline route gives way to the shell", async () => {
|
|
250
|
+
let slow = false;
|
|
251
|
+
const env = makeScope(async (url, init) => {
|
|
252
|
+
if (slow) return new Promise<Response>((r) => setTimeout(() => r(html("late")), 500));
|
|
253
|
+
return onlineServer()(url, init);
|
|
254
|
+
});
|
|
255
|
+
await env.dispatch("install");
|
|
256
|
+
slow = true;
|
|
257
|
+
expect(await text(env.dispatch("fetch", { request: req("/leads", "navigate") }))).toBe(
|
|
258
|
+
"shell /leads",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a slow network on another route is waited for", async () => {
|
|
263
|
+
let slow = false;
|
|
264
|
+
const env = makeScope(async (url, init) => {
|
|
265
|
+
if (slow) return new Promise<Response>((r) => setTimeout(() => r(html("late")), 120));
|
|
266
|
+
return onlineServer()(url, init);
|
|
267
|
+
});
|
|
268
|
+
await env.dispatch("install");
|
|
269
|
+
slow = true;
|
|
270
|
+
expect(await text(env.dispatch("fetch", { request: req("/settings", "navigate") }))).toBe(
|
|
271
|
+
"late",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("offline with nothing kept: the network error goes through", async () => {
|
|
276
|
+
const env = makeScope(offline);
|
|
277
|
+
// dispatch() adopts the respondWith promise, so its rejection surfaces here.
|
|
278
|
+
await expect(env.dispatch("fetch", { request: req("/leads", "navigate") })).rejects.toThrow(
|
|
279
|
+
"Failed to fetch",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("build files and other requests", () => {
|
|
285
|
+
test("cache first; a miss is fetched once and kept", async () => {
|
|
286
|
+
let online = true;
|
|
287
|
+
const env = makeScope(async (url, init) => (online ? onlineServer()(url, init) : offline()));
|
|
288
|
+
await env.dispatch("install");
|
|
289
|
+
online = false;
|
|
290
|
+
expect(
|
|
291
|
+
await text(env.dispatch("fetch", { request: req("/_pylon/build/chunks/chunk-def67890.js") })),
|
|
292
|
+
).toBe("file /_pylon/build/chunks/chunk-def67890.js");
|
|
293
|
+
|
|
294
|
+
online = true;
|
|
295
|
+
const lazy = "/_pylon/build/chunks/lazy-11112222.js";
|
|
296
|
+
await text(env.dispatch("fetch", { request: req(lazy) }));
|
|
297
|
+
online = false;
|
|
298
|
+
expect(await text(env.dispatch("fetch", { request: req(lazy) }))).toBe(`file ${lazy}`);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("API and sync requests are left to the network", async () => {
|
|
302
|
+
const env = makeScope(onlineServer());
|
|
303
|
+
for (const path of ["/api/sync/pull", "/api/auth/session", "/api/entities/Lead"]) {
|
|
304
|
+
expect(await env.dispatch("fetch", { request: req(path) })).toBeNull();
|
|
305
|
+
}
|
|
306
|
+
expect(
|
|
307
|
+
await env.dispatch("fetch", { request: req("/api/entities/Lead", "cors", "POST") }),
|
|
308
|
+
).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("activate and session changes", () => {
|
|
313
|
+
test("activate deletes other builds' caches, keeps the app's own, and claims", async () => {
|
|
314
|
+
const env = makeScope(onlineServer());
|
|
315
|
+
await env.s.caches.open("pylon-old-assets");
|
|
316
|
+
await env.s.caches.open("pylon-old-shells");
|
|
317
|
+
await env.s.caches.open("app-images");
|
|
318
|
+
await env.dispatch("install");
|
|
319
|
+
await env.dispatch("activate");
|
|
320
|
+
expect((await env.s.caches.keys()).sort()).toEqual([
|
|
321
|
+
"app-images",
|
|
322
|
+
"pylon-v1-assets",
|
|
323
|
+
"pylon-v1-shells",
|
|
324
|
+
]);
|
|
325
|
+
expect(env.s.claimed).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a change in signed-in state refetches the shells; no change does not", async () => {
|
|
329
|
+
const env = makeScope(onlineServer());
|
|
330
|
+
await env.dispatch("install");
|
|
331
|
+
const shellFetches = () =>
|
|
332
|
+
env.fetchLog.filter((f) => f.headers["X-Pylon-Offline-Shell"] === "1").length;
|
|
333
|
+
expect(shellFetches()).toBe(2);
|
|
334
|
+
await env.dispatch("message", { data: { type: "pylon:session", signedIn: true } });
|
|
335
|
+
expect(shellFetches()).toBe(4);
|
|
336
|
+
await env.dispatch("message", { data: { type: "pylon:session", signedIn: true } });
|
|
337
|
+
expect(shellFetches()).toBe(4);
|
|
338
|
+
await env.dispatch("message", { data: { type: "pylon:session", signedIn: false } });
|
|
339
|
+
expect(shellFetches()).toBe(6);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("signing out drops a shell the server now redirects", async () => {
|
|
343
|
+
let signedIn = true;
|
|
344
|
+
const env = makeScope(async (url, init) => {
|
|
345
|
+
if (!signedIn && init?.headers?.["X-Pylon-Offline-Shell"] === "1") {
|
|
346
|
+
const r = html("sign in");
|
|
347
|
+
Object.defineProperty(r, "redirected", { value: true });
|
|
348
|
+
return r;
|
|
349
|
+
}
|
|
350
|
+
return onlineServer()(url, init);
|
|
351
|
+
});
|
|
352
|
+
await env.dispatch("install");
|
|
353
|
+
signedIn = false;
|
|
354
|
+
await env.dispatch("message", { data: { type: "pylon:session", signedIn: false } });
|
|
355
|
+
expect(await env.caches.get("pylon-v1-shells")!.match("/leads")).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
});
|