@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,330 @@
|
|
|
1
|
+
// The offline service worker, served at `/pylon-sw.js` for apps that set
|
|
2
|
+
// `buildManifest({ offline })`.
|
|
3
|
+
//
|
|
4
|
+
// The client bundler writes the worker into the client build with this
|
|
5
|
+
// build's config inlined (`renderServiceWorker`). The worker body is the
|
|
6
|
+
// source of `pylonServiceWorker` itself, serialized with `toString()`, so
|
|
7
|
+
// the tests in offline-sw.test.ts run the same code the browser runs. That
|
|
8
|
+
// imposes one rule on this file: `pylonServiceWorker` and `classifyRequest`
|
|
9
|
+
// may not reference anything outside their own bodies (no imports, no
|
|
10
|
+
// module-level helpers).
|
|
11
|
+
//
|
|
12
|
+
// What it does:
|
|
13
|
+
// - install: precache the build's client files for the offline routes,
|
|
14
|
+
// the web app manifest and the icons, then fetch each static offline
|
|
15
|
+
// route's HTML ("shell") with `X-Pylon-Offline-Shell: 1` and keep it when
|
|
16
|
+
// the runtime answers `Pylon-Offline-Shell: 1` (the HTML is the same for
|
|
17
|
+
// every signed-in user).
|
|
18
|
+
// - navigations: network first. When the network fails (or, for an offline
|
|
19
|
+
// route, takes longer than `navigationTimeoutMs`), answer with the
|
|
20
|
+
// route's shell, else the start URL's shell. The client runtime renders
|
|
21
|
+
// the real URL from the local replica.
|
|
22
|
+
// - build files: cache first. They are content-hashed, so a cached copy
|
|
23
|
+
// is always correct.
|
|
24
|
+
// - everything else (API, auth, sync, dev endpoints): not intercepted, never
|
|
25
|
+
// cached. Data comes from the sync engine, not the HTTP cache.
|
|
26
|
+
// - activate: delete caches from other builds. A new deploy installs a new
|
|
27
|
+
// worker, which waits until every tab of the app is closed (the next
|
|
28
|
+
// launch) before it takes over. No `skipWaiting`.
|
|
29
|
+
|
|
30
|
+
/** The build-specific values inlined into `/pylon-sw.js`. */
|
|
31
|
+
export interface ServiceWorkerConfig {
|
|
32
|
+
/** Hash of everything below. Cache names carry it. */
|
|
33
|
+
version: string;
|
|
34
|
+
/** The client build's public prefix (`/_pylon/build/`, or a CDN URL). */
|
|
35
|
+
buildPrefix: string;
|
|
36
|
+
/** URLs to keep at install: the offline routes' build files, the web app
|
|
37
|
+
* manifest, the icons. */
|
|
38
|
+
precache: string[];
|
|
39
|
+
/** Offline routes without dynamic segments: their HTML is kept at install. */
|
|
40
|
+
shells: string[];
|
|
41
|
+
/** Every offline route pattern (`/leads/[id]` included). */
|
|
42
|
+
routes: string[];
|
|
43
|
+
/** Path of the start URL; its shell answers any navigation with no
|
|
44
|
+
* better match. */
|
|
45
|
+
startPath: string;
|
|
46
|
+
/** How long a navigation to an offline route waits for the network before
|
|
47
|
+
* the worker answers with the shell. */
|
|
48
|
+
navigationTimeoutMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the worker does with one request. */
|
|
52
|
+
export type RequestKind =
|
|
53
|
+
/** A page load: network first, shell when offline. */
|
|
54
|
+
| "navigate"
|
|
55
|
+
/** A file of the client build: cache first. */
|
|
56
|
+
| "build"
|
|
57
|
+
/** The web app manifest or an icon: cache first. */
|
|
58
|
+
| "precached"
|
|
59
|
+
/** Not intercepted, never cached. */
|
|
60
|
+
| "network";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify one request. Pure; `self.location.origin` is passed in.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyRequest(
|
|
66
|
+
request: { url: string; method: string; mode: string },
|
|
67
|
+
config: ServiceWorkerConfig,
|
|
68
|
+
origin: string,
|
|
69
|
+
): RequestKind {
|
|
70
|
+
if (request.method !== "GET") return "network";
|
|
71
|
+
let url: URL;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(request.url);
|
|
74
|
+
} catch {
|
|
75
|
+
return "network";
|
|
76
|
+
}
|
|
77
|
+
const sameOrigin = url.origin === origin;
|
|
78
|
+
if (sameOrigin) {
|
|
79
|
+
const path = url.pathname;
|
|
80
|
+
// Data and control endpoints. Data reaches the page through the sync
|
|
81
|
+
// engine; a cached API answer would be a stale copy of the replica, and
|
|
82
|
+
// a cached auth answer could sign in the wrong person.
|
|
83
|
+
const neverCached = [
|
|
84
|
+
"/api/",
|
|
85
|
+
"/auth/",
|
|
86
|
+
"/_pylon/dev/",
|
|
87
|
+
"/studio",
|
|
88
|
+
"/events",
|
|
89
|
+
"/health",
|
|
90
|
+
"/metrics",
|
|
91
|
+
"/.well-known/",
|
|
92
|
+
];
|
|
93
|
+
for (const prefix of neverCached) {
|
|
94
|
+
if (path === prefix.replace(/\/$/, "") || path.startsWith(prefix)) return "network";
|
|
95
|
+
}
|
|
96
|
+
if (path === "/pylon-sw.js") return "network";
|
|
97
|
+
if (request.mode === "navigate") return "navigate";
|
|
98
|
+
if (path.startsWith("/_pylon/build/")) return "build";
|
|
99
|
+
}
|
|
100
|
+
if (!sameOrigin && config.buildPrefix && url.href.startsWith(config.buildPrefix)) {
|
|
101
|
+
return "build";
|
|
102
|
+
}
|
|
103
|
+
if (sameOrigin && config.precache.indexOf(url.pathname) !== -1) return "precached";
|
|
104
|
+
return "network";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The worker. `sw` is the ServiceWorkerGlobalScope (`self`); passing it in
|
|
109
|
+
* keeps the body free of globals so tests can supply their own. `classify`
|
|
110
|
+
* is `classifyRequest`, passed in for the same reason.
|
|
111
|
+
*/
|
|
112
|
+
export function pylonServiceWorker(
|
|
113
|
+
sw: any,
|
|
114
|
+
config: ServiceWorkerConfig,
|
|
115
|
+
classify: typeof classifyRequest,
|
|
116
|
+
): void {
|
|
117
|
+
const ASSETS = "pylon-" + config.version + "-assets";
|
|
118
|
+
const SHELLS = "pylon-" + config.version + "-shells";
|
|
119
|
+
const STATE_KEY = "/__pylon_sw_state";
|
|
120
|
+
const SHELL_REQUEST_HEADER = "X-Pylon-Offline-Shell";
|
|
121
|
+
const SHELL_RESPONSE_HEADER = "Pylon-Offline-Shell";
|
|
122
|
+
|
|
123
|
+
function warn(message: string): void {
|
|
124
|
+
try {
|
|
125
|
+
sw.console.warn("[pylon offline] " + message);
|
|
126
|
+
} catch {
|
|
127
|
+
// No console in this scope.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function splitPath(p: string): string[] {
|
|
132
|
+
const trimmed = p.replace(/\/+$/, "");
|
|
133
|
+
return trimmed === "" ? [] : trimmed.slice(1).split("/");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Does `pathname` match a route pattern (`/leads/[id]`, `/docs/[...slug]`)?
|
|
137
|
+
function matchesPattern(pattern: string, pathname: string): boolean {
|
|
138
|
+
const pat = splitPath(pattern);
|
|
139
|
+
const target = splitPath(pathname);
|
|
140
|
+
for (let i = 0; i < pat.length; i++) {
|
|
141
|
+
const seg = pat[i];
|
|
142
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) return target.length > i;
|
|
143
|
+
if (seg.startsWith("[[...") && seg.endsWith("]]")) return true;
|
|
144
|
+
if (i >= target.length) return false;
|
|
145
|
+
if (seg.startsWith("[") && seg.endsWith("]")) continue;
|
|
146
|
+
if (seg !== target[i]) return false;
|
|
147
|
+
}
|
|
148
|
+
return pat.length === target.length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isOfflineRoute(pathname: string): boolean {
|
|
152
|
+
for (const route of config.routes) {
|
|
153
|
+
if (matchesPattern(route, pathname)) return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizePath(pathname: string): string {
|
|
159
|
+
return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Fetch each static offline route's HTML and keep the ones the runtime
|
|
163
|
+
// marks as identical for every signed-in user. Returns false when the
|
|
164
|
+
// network failed, so the caller does not record the refresh as done.
|
|
165
|
+
async function refreshShells(): Promise<boolean> {
|
|
166
|
+
const cache = await sw.caches.open(SHELLS);
|
|
167
|
+
let reached = false;
|
|
168
|
+
for (const path of config.shells) {
|
|
169
|
+
let res: any;
|
|
170
|
+
try {
|
|
171
|
+
res = await sw.fetch(path, {
|
|
172
|
+
headers: { [SHELL_REQUEST_HEADER]: "1" },
|
|
173
|
+
credentials: "same-origin",
|
|
174
|
+
cache: "no-store",
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
reached = true;
|
|
180
|
+
const verdict = res.headers.get(SHELL_RESPONSE_HEADER);
|
|
181
|
+
if (res.ok && !res.redirected && verdict === "1") {
|
|
182
|
+
await cache.put(path, res);
|
|
183
|
+
} else if (res.redirected || (verdict && verdict !== "1") || res.status === 404) {
|
|
184
|
+
// A definite answer that this page is not a shell for the current
|
|
185
|
+
// session (a redirect to sign-in, a page that reads the user): drop
|
|
186
|
+
// the old copy so it is not shown to the wrong session. A server
|
|
187
|
+
// error keeps the old copy.
|
|
188
|
+
await cache.delete(path);
|
|
189
|
+
warn(
|
|
190
|
+
path +
|
|
191
|
+
" is not kept for offline use: " +
|
|
192
|
+
(verdict || (res.redirected ? "redirected to " + res.url : "status " + res.status)),
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
warn(path + " is not kept for offline use: status " + res.status);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return reached;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The client posts its signed-in state after every page load. The shells
|
|
202
|
+
// carry that one bit, so refetch them when it changed.
|
|
203
|
+
async function syncSession(signedIn: boolean): Promise<void> {
|
|
204
|
+
const cache = await sw.caches.open(SHELLS);
|
|
205
|
+
const stored = await cache.match(STATE_KEY);
|
|
206
|
+
if (stored) {
|
|
207
|
+
const state = await stored.json();
|
|
208
|
+
if (state && state.signedIn === signedIn) return;
|
|
209
|
+
}
|
|
210
|
+
if (await refreshShells()) {
|
|
211
|
+
await cache.put(
|
|
212
|
+
STATE_KEY,
|
|
213
|
+
new sw.Response(JSON.stringify({ signedIn }), {
|
|
214
|
+
headers: { "Content-Type": "application/json" },
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function shellFor(pathname: string): Promise<any> {
|
|
221
|
+
const cache = await sw.caches.open(SHELLS);
|
|
222
|
+
const exact = await cache.match(normalizePath(pathname));
|
|
223
|
+
if (exact) return exact;
|
|
224
|
+
return cache.match(config.startPath);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const timer = sw.setTimeout(() => reject(new Error("timeout")), ms);
|
|
230
|
+
promise.then(
|
|
231
|
+
(v) => {
|
|
232
|
+
sw.clearTimeout(timer);
|
|
233
|
+
resolve(v);
|
|
234
|
+
},
|
|
235
|
+
(e) => {
|
|
236
|
+
sw.clearTimeout(timer);
|
|
237
|
+
reject(e);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleNavigation(request: any): Promise<any> {
|
|
244
|
+
const pathname = new URL(request.url).pathname;
|
|
245
|
+
const offlineRoute = isOfflineRoute(pathname);
|
|
246
|
+
const network = sw.fetch(request);
|
|
247
|
+
try {
|
|
248
|
+
// Only an offline route has a page to show instead, so only an
|
|
249
|
+
// offline route stops waiting for a slow network.
|
|
250
|
+
return await (offlineRoute
|
|
251
|
+
? withTimeout(network, config.navigationTimeoutMs)
|
|
252
|
+
: network);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
const shell = await shellFor(pathname);
|
|
255
|
+
if (shell) return shell;
|
|
256
|
+
// Nothing kept: wait for the network after all (a slow answer beats
|
|
257
|
+
// the browser's offline page), and let a real failure through.
|
|
258
|
+
return network;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function cacheFirst(request: any): Promise<any> {
|
|
263
|
+
const cache = await sw.caches.open(ASSETS);
|
|
264
|
+
const hit = await cache.match(request);
|
|
265
|
+
if (hit) return hit;
|
|
266
|
+
const res = await sw.fetch(request);
|
|
267
|
+
// Keep build files the page loads lazily (a dynamic import, an image
|
|
268
|
+
// the page imports), so they work offline after their first use.
|
|
269
|
+
if (res && res.ok) await cache.put(request, res.clone());
|
|
270
|
+
return res;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
sw.addEventListener("install", (event: any) => {
|
|
274
|
+
event.waitUntil(
|
|
275
|
+
(async () => {
|
|
276
|
+
const cache = await sw.caches.open(ASSETS);
|
|
277
|
+
// All-or-nothing: a build file that fails to download fails the
|
|
278
|
+
// install, and the previous worker keeps serving the previous build.
|
|
279
|
+
await cache.addAll(config.precache);
|
|
280
|
+
await refreshShells();
|
|
281
|
+
})(),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
sw.addEventListener("activate", (event: any) => {
|
|
286
|
+
event.waitUntil(
|
|
287
|
+
(async () => {
|
|
288
|
+
const names: string[] = await sw.caches.keys();
|
|
289
|
+
await Promise.all(
|
|
290
|
+
names
|
|
291
|
+
.filter(
|
|
292
|
+
(n) => n.indexOf("pylon-") === 0 && n !== ASSETS && n !== SHELLS,
|
|
293
|
+
)
|
|
294
|
+
.map((n) => sw.caches.delete(n)),
|
|
295
|
+
);
|
|
296
|
+
// Control the tab that installed the first worker, so the app works
|
|
297
|
+
// offline after one visit. With an older worker this runs only after
|
|
298
|
+
// every tab closed, so it never swaps a build mid-session.
|
|
299
|
+
await sw.clients.claim();
|
|
300
|
+
})(),
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
sw.addEventListener("fetch", (event: any) => {
|
|
305
|
+
const request = event.request;
|
|
306
|
+
const kind = classify(request, config, sw.location.origin);
|
|
307
|
+
if (kind === "navigate") event.respondWith(handleNavigation(request));
|
|
308
|
+
else if (kind === "build" || kind === "precached") event.respondWith(cacheFirst(request));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
sw.addEventListener("message", (event: any) => {
|
|
312
|
+
const data = event.data;
|
|
313
|
+
if (data && data.type === "pylon:session" && typeof data.signedIn === "boolean") {
|
|
314
|
+
event.waitUntil(syncSession(data.signedIn));
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The `/pylon-sw.js` source for one build. */
|
|
320
|
+
export function renderServiceWorker(config: ServiceWorkerConfig): string {
|
|
321
|
+
return (
|
|
322
|
+
"// Generated by Pylon for this build. Do not edit.\n(" +
|
|
323
|
+
pylonServiceWorker.toString() +
|
|
324
|
+
")(self, " +
|
|
325
|
+
JSON.stringify(config) +
|
|
326
|
+
", " +
|
|
327
|
+
classifyRequest.toString() +
|
|
328
|
+
");\n"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -909,3 +909,66 @@ describe("client router popstate", () => {
|
|
|
909
909
|
expect(src.match(/renderedPath = location\.pathname \+ location\.search;/g)?.length).toBeGreaterThanOrEqual(3);
|
|
910
910
|
});
|
|
911
911
|
});
|
|
912
|
+
|
|
913
|
+
describe("offline service worker (buildManifest({ offline }))", () => {
|
|
914
|
+
test("an offline app's build writes pylon-sw.js with each offline route's files", async () => {
|
|
915
|
+
tempDir = makeFixture(
|
|
916
|
+
{
|
|
917
|
+
"page.tsx": PAGE_BODY("Home"),
|
|
918
|
+
"leads/page.tsx": PAGE_BODY("Leads"),
|
|
919
|
+
"leads/[id]/page.tsx": PAGE_BODY("Lead"),
|
|
920
|
+
"settings/page.tsx": PAGE_BODY("Settings"),
|
|
921
|
+
},
|
|
922
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
923
|
+
);
|
|
924
|
+
fs.writeFileSync(
|
|
925
|
+
path.join(tempDir, "pylon.manifest.json"),
|
|
926
|
+
JSON.stringify({
|
|
927
|
+
offline: {
|
|
928
|
+
name: "Field Sales",
|
|
929
|
+
icons: [{ src: "/icons/192.png", sizes: "192x192" }],
|
|
930
|
+
startUrl: "/leads",
|
|
931
|
+
display: "standalone",
|
|
932
|
+
routes: ["/leads", "/leads/[id]"],
|
|
933
|
+
},
|
|
934
|
+
}),
|
|
935
|
+
);
|
|
936
|
+
originalCwd = process.cwd();
|
|
937
|
+
process.chdir(tempDir);
|
|
938
|
+
|
|
939
|
+
const { manifestPath, outdir } = await buildClientBundle();
|
|
940
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
|
|
941
|
+
expect(manifest.offline?.routes).toEqual(["/leads", "/leads/[id]"]);
|
|
942
|
+
expect(manifest.offline?.version).toMatch(/^[0-9a-f]{16}$/);
|
|
943
|
+
|
|
944
|
+
const sw = fs.readFileSync(path.join(outdir, "pylon-sw.js"), "utf8");
|
|
945
|
+
// Valid script (compiled, not run).
|
|
946
|
+
const { Script } = await import("node:vm");
|
|
947
|
+
expect(() => new Script(sw)).not.toThrow();
|
|
948
|
+
const config = JSON.parse(sw.slice(sw.indexOf(")(self, ") + 8, sw.lastIndexOf(", function")));
|
|
949
|
+
expect(config.version).toBe(manifest.offline!.version);
|
|
950
|
+
expect(config.shells).toEqual(["/leads"]);
|
|
951
|
+
const prefix = manifest.public_prefix;
|
|
952
|
+
for (const component of ["app/leads/page", "app/leads/[id]/page"]) {
|
|
953
|
+
expect(config.precache).toContain(prefix + manifest.routes[component].file);
|
|
954
|
+
}
|
|
955
|
+
// Not an offline route: its entry is not precached.
|
|
956
|
+
expect(config.precache).not.toContain(prefix + manifest.routes["app/settings/page"].file);
|
|
957
|
+
expect(config.precache).toContain("/icons/192.png");
|
|
958
|
+
|
|
959
|
+
// The same build gives the same version: no reinstall per boot.
|
|
960
|
+
const again = await buildClientBundle();
|
|
961
|
+
const second = JSON.parse(fs.readFileSync(again.manifestPath, "utf8")) as PylonBundleManifest;
|
|
962
|
+
expect(second.offline?.version).toBe(manifest.offline!.version);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("an app without offline gets no worker", async () => {
|
|
966
|
+
tempDir = makeFixture({ "page.tsx": PAGE_BODY("Home") }, { "layout.tsx": LAYOUT_BODY });
|
|
967
|
+
originalCwd = process.cwd();
|
|
968
|
+
process.chdir(tempDir);
|
|
969
|
+
const { manifestPath, outdir } = await buildClientBundle();
|
|
970
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
|
|
971
|
+
expect(manifest.offline).toBeUndefined();
|
|
972
|
+
expect(fs.existsSync(path.join(outdir, "pylon-sw.js"))).toBe(false);
|
|
973
|
+
});
|
|
974
|
+
});
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type ManifestBuildConfig,
|
|
43
43
|
} from "./build-compat";
|
|
44
44
|
import { serverBundle } from "./server-bundle";
|
|
45
|
+
import { buildOfflineWorker, readManifestOffline, type BundleOffline } from "./ssr-offline";
|
|
45
46
|
|
|
46
47
|
type Send = (msg: Record<string, unknown>) => void;
|
|
47
48
|
|
|
@@ -357,7 +358,7 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
|
|
|
357
358
|
|
|
358
359
|
import { createElement, Fragment, Suspense } from "react";
|
|
359
360
|
import { hydrateRoot } from "react-dom/client";
|
|
360
|
-
import { configureClient } from "@pylonsync/react";
|
|
361
|
+
import { configureClient, registerOfflineWorker } from "@pylonsync/react";
|
|
361
362
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
362
363
|
import { LOADING_MODULES } from "./loading-registry";
|
|
363
364
|
import { createNavPayloadCache } from "./nav-cache";
|
|
@@ -771,6 +772,25 @@ export function hydrate(component, Page, Layouts) {
|
|
|
771
772
|
},
|
|
772
773
|
});
|
|
773
774
|
installNavHandlers();
|
|
775
|
+
// Registers the offline service worker for an app that opted in, and
|
|
776
|
+
// removes one left on this origin by an earlier build otherwise.
|
|
777
|
+
registerOfflineWorker({
|
|
778
|
+
optedIn: !!data.offline,
|
|
779
|
+
production: !(data.offline && data.offline.dev),
|
|
780
|
+
signedIn: !!(data.props && data.props.session && data.props.session.exists),
|
|
781
|
+
});
|
|
782
|
+
if (data.offline) {
|
|
783
|
+
// The service worker answered this load with the page it kept for
|
|
784
|
+
// another URL: a dynamic offline route, or no network. The page on
|
|
785
|
+
// screen is that URL's; render the address bar's URL from the replica.
|
|
786
|
+
const shellPath = data.props && data.props.pathname;
|
|
787
|
+
if (typeof shellPath === "string" && shellPath !== location.pathname) {
|
|
788
|
+
navigate(location.pathname + location.search + location.hash, {
|
|
789
|
+
replace: true,
|
|
790
|
+
shellPath,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
774
794
|
return;
|
|
775
795
|
}
|
|
776
796
|
// Subsequent hydrate calls fire from dynamic-loaded entries
|
|
@@ -959,6 +979,24 @@ async function navigate(href, opts) {
|
|
|
959
979
|
html = await res.text();
|
|
960
980
|
}
|
|
961
981
|
} catch {
|
|
982
|
+
// No network. An offline route renders from the replica; anything else
|
|
983
|
+
// needs the server.
|
|
984
|
+
if (myEpoch !== navEpoch) return;
|
|
985
|
+
if (await renderOffline(url, myEpoch, opts, push)) {
|
|
986
|
+
navState.settled = true;
|
|
987
|
+
if (placeholderTimer) clearTimeout(placeholderTimer);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (opts && opts.shellPath) {
|
|
991
|
+
// This load already came from a kept page and the URL cannot open
|
|
992
|
+
// offline. Reloading would get the same kept page again; show its URL.
|
|
993
|
+
navState.settled = true;
|
|
994
|
+
if (placeholderTimer) clearTimeout(placeholderTimer);
|
|
995
|
+
history.replaceState({ component: currentComponent }, "", opts.shellPath);
|
|
996
|
+
renderedPath = location.pathname + location.search;
|
|
997
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
962
1000
|
fullLoad();
|
|
963
1001
|
return;
|
|
964
1002
|
}
|
|
@@ -1038,6 +1076,67 @@ async function navigate(href, opts) {
|
|
|
1038
1076
|
}
|
|
1039
1077
|
}
|
|
1040
1078
|
|
|
1079
|
+
// Render an offline route with no server: the page reads its data from the
|
|
1080
|
+
// local replica (db.useQuery), so it needs only its code, its params and the
|
|
1081
|
+
// identity-free props the last page had. Returns false when the URL is not an
|
|
1082
|
+
// offline route, or its code is not on the device.
|
|
1083
|
+
async function renderOffline(url, myEpoch, opts, push) {
|
|
1084
|
+
const manifest = await loadManifest();
|
|
1085
|
+
if (!manifest || !manifest.offline || !activeRoot) return false;
|
|
1086
|
+
const offlineRoutes = {};
|
|
1087
|
+
for (const r of manifest.offline.routes || []) offlineRoutes[r] = { path: r };
|
|
1088
|
+
if (!matchRoute({ routes: offlineRoutes }, url.pathname)) return false;
|
|
1089
|
+
const matched = matchRoute(manifest, url.pathname);
|
|
1090
|
+
if (!matched) return false;
|
|
1091
|
+
let route;
|
|
1092
|
+
try {
|
|
1093
|
+
route = await loadRouteEntry(matched.component);
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
if (myEpoch !== navEpoch) return true;
|
|
1098
|
+
const prev = currentPageProps || {};
|
|
1099
|
+
const searchParams = {};
|
|
1100
|
+
url.searchParams.forEach((v, k) => {
|
|
1101
|
+
searchParams[k] = v;
|
|
1102
|
+
});
|
|
1103
|
+
const data = {
|
|
1104
|
+
component: matched.component,
|
|
1105
|
+
props: {
|
|
1106
|
+
pathname: url.pathname,
|
|
1107
|
+
url: url.pathname,
|
|
1108
|
+
host: prev.host || "",
|
|
1109
|
+
params: matched.params,
|
|
1110
|
+
searchParams,
|
|
1111
|
+
auth: prev.auth,
|
|
1112
|
+
session: prev.session,
|
|
1113
|
+
headers: {},
|
|
1114
|
+
cookies: {},
|
|
1115
|
+
},
|
|
1116
|
+
ssrData: {},
|
|
1117
|
+
};
|
|
1118
|
+
setNavParams(data);
|
|
1119
|
+
currentPageProps = withClientProps(data);
|
|
1120
|
+
const tree = withBoundary(
|
|
1121
|
+
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
1122
|
+
data.component,
|
|
1123
|
+
myEpoch,
|
|
1124
|
+
);
|
|
1125
|
+
currentComponent = data.component;
|
|
1126
|
+
navPayloads.clear();
|
|
1127
|
+
activeRoot.render(tree);
|
|
1128
|
+
const target = url.pathname + url.search + url.hash;
|
|
1129
|
+
if (opts && opts.replace) {
|
|
1130
|
+
history.replaceState({ component: data.component }, "", target);
|
|
1131
|
+
} else if (push) {
|
|
1132
|
+
history.pushState({ component: data.component }, "", target);
|
|
1133
|
+
}
|
|
1134
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
1135
|
+
renderedPath = location.pathname + location.search;
|
|
1136
|
+
window.scrollTo(0, 0);
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1041
1140
|
function installNavHandlers() {
|
|
1042
1141
|
document.addEventListener("click", (e) => {
|
|
1043
1142
|
if (e.defaultPrevented) return;
|
|
@@ -1225,6 +1324,11 @@ export interface PylonBundleManifest {
|
|
|
1225
1324
|
* independent); rendered into every SSR `<head>` against `public_prefix`.
|
|
1226
1325
|
* Absent when the app declares no `font({...})`. */
|
|
1227
1326
|
fonts?: ManifestFonts;
|
|
1327
|
+
/** `buildManifest({ offline })`: the install tags for every page's head,
|
|
1328
|
+
* the routes the client may render with no network, and the service
|
|
1329
|
+
* worker version. The worker is `pylon-sw.js` in the outdir. Absent when
|
|
1330
|
+
* the app did not opt in. */
|
|
1331
|
+
offline?: BundleOffline;
|
|
1228
1332
|
/** Set when the Tailwind compile failed for this build: the pages are
|
|
1229
1333
|
* serving WITHOUT styles. Dev surfaces it as an on-page banner (see
|
|
1230
1334
|
* pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
|
|
@@ -1955,6 +2059,21 @@ export async function _doBuildInner(
|
|
|
1955
2059
|
console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
|
|
1956
2060
|
}
|
|
1957
2061
|
|
|
2062
|
+
// Offline service worker (`buildManifest({ offline })`). After the CSS
|
|
2063
|
+
// and fonts, because the precache list is every file the offline routes'
|
|
2064
|
+
// pages load. A failure never kills the build: the app still works
|
|
2065
|
+
// online, and the runtime answers 404 for `/pylon-sw.js`.
|
|
2066
|
+
try {
|
|
2067
|
+
const offline = readManifestOffline(fs, path, cwd, manifestFile);
|
|
2068
|
+
if (offline) {
|
|
2069
|
+
manifest.offline = await buildOfflineWorker(fs, path, outdir, manifest, offline);
|
|
2070
|
+
}
|
|
2071
|
+
} catch (oErr: any) {
|
|
2072
|
+
if (opts.strict) throw new Error(`offline worker build failed: ${oErr?.message ?? oErr}`);
|
|
2073
|
+
// eslint-disable-next-line no-console
|
|
2074
|
+
console.warn(`[pylon offline] service worker build failed: ${oErr?.message ?? oErr}`);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1958
2077
|
// App-overridable rate-limit page. The rate limiter short-circuits in the
|
|
1959
2078
|
// Rust layer BEFORE SSR, so it can't render a route per (shed) request —
|
|
1960
2079
|
// instead pre-render `app/rate-limit.tsx` ONCE here to a self-contained
|