@pylonsync/functions 0.22.46 → 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.
@@ -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