@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,253 @@
1
+ // Offline apps (`buildManifest({ offline })`), render side:
2
+ //
3
+ // - A render the service worker asked for (`x-pylon-offline-shell: 1`) is
4
+ // marked keepable only when its HTML is the same for every signed-in
5
+ // user, and then its hydration data carries the signed-in bit and no
6
+ // identity. A page that reads props.auth, serverData, headers or cookies
7
+ // is refused.
8
+ // - Every page of an offline app gets the install tags and tells the
9
+ // client to register the worker.
10
+ // - The files the worker precaches for a route are exactly the files the
11
+ // route's HTML loads (Plan 012 step 2).
12
+ //
13
+ // ssr-runtime.ts pulls in runtime.ts (the bun runner entrypoint, which runs
14
+ // main() on import), so each render runs in a child process, like
15
+ // ssr-page-host.test.ts.
16
+
17
+ import { expect, test } from "bun:test";
18
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { offlineWorkerConfig } from "./ssr-offline";
22
+ import { routeAssetUrls } from "./ssr-route-assets";
23
+
24
+ const SSR_RUNTIME = join(import.meta.dir, "ssr-runtime.ts");
25
+ const PKG_NODE_MODULES = join(import.meta.dir, "..", "node_modules");
26
+
27
+ interface Frame {
28
+ type: string;
29
+ headers?: Record<string, string>;
30
+ data?: string;
31
+ }
32
+
33
+ const BUNDLE_MANIFEST = {
34
+ build_id: "b1",
35
+ outdir: ".pylon/client-build",
36
+ public_prefix: "/_pylon/build/",
37
+ polyfills: "polyfills-aaaa1111.js",
38
+ routes: {
39
+ "app/page": {
40
+ file: "client-entry-app__page-bbbb2222.js",
41
+ imports: ["chunks/chunk-cccc3333.js", "chunks/chunk-dddd4444.js"],
42
+ css: ["styles-eeee5555.css"],
43
+ path: "/",
44
+ },
45
+ },
46
+ fonts: {
47
+ faces: [],
48
+ variables: [],
49
+ preload: ["fonts/inter-ffff6666.woff2"],
50
+ },
51
+ offline: {
52
+ name: "Field Sales",
53
+ shortName: "Sales",
54
+ themeColor: "#0b2545",
55
+ icons: [
56
+ { src: "/icons/192.png", sizes: "192x192", type: "image/png" },
57
+ { src: "/icons/512.png", sizes: "512x512", type: "image/png" },
58
+ ],
59
+ startUrl: "/",
60
+ routes: ["/"],
61
+ version: "v1",
62
+ builtAt: "2026-10-22T12:00:00.000Z",
63
+ },
64
+ };
65
+
66
+ function makeApp(pageBody: string, manifest: object = BUNDLE_MANIFEST): string {
67
+ const dir = mkdtempSync(join(tmpdir(), "pylon-offline-shell-"));
68
+ mkdirSync(join(dir, "app"), { recursive: true });
69
+ mkdirSync(join(dir, ".pylon", "client-build"), { recursive: true });
70
+ symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
71
+ writeFileSync(
72
+ join(dir, "app", "layout.tsx"),
73
+ `export default function Layout({ children }: any) {
74
+ return <html><head><title>t</title></head><body>{children}</body></html>;
75
+ }
76
+ `,
77
+ );
78
+ writeFileSync(
79
+ join(dir, "app", "page.tsx"),
80
+ `export default function Page(props: any) {
81
+ ${pageBody}
82
+ }
83
+ `,
84
+ );
85
+ writeFileSync(join(dir, ".pylon", "client-build", "styles-eeee5555.css"), "p{color:red}");
86
+ writeFileSync(
87
+ join(dir, ".pylon", "client-build", "manifest.json"),
88
+ JSON.stringify(manifest),
89
+ );
90
+ return dir;
91
+ }
92
+
93
+ const SIGNED_IN = {
94
+ user_id: "u_ana",
95
+ is_admin: false,
96
+ tenant_id: "t_veritas",
97
+ roles: ["rep"],
98
+ };
99
+
100
+ async function render(
101
+ dir: string,
102
+ opts: { shell?: boolean; auth?: object; env?: Record<string, string> } = {},
103
+ ): Promise<{ start: Frame | undefined; html: string; data: any }> {
104
+ const headers: Record<string, string> = { host: "app.example" };
105
+ if (opts.shell) headers["x-pylon-offline-shell"] = "1";
106
+ const auth = opts.auth ?? SIGNED_IN;
107
+ const msg = {
108
+ type: "render_route",
109
+ call_id: "c1",
110
+ component: "app/page",
111
+ layouts: ["app/layout"],
112
+ route_path: "/",
113
+ url: "/",
114
+ host: "app.example",
115
+ params: {},
116
+ search_params: {},
117
+ headers,
118
+ cookies: { sid: "secret-session" },
119
+ auth,
120
+ session_present: !!(auth as any).user_id,
121
+ };
122
+ const probe = `
123
+ import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
124
+ const frames = [];
125
+ await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
126
+ console.error("FRAMES " + JSON.stringify(frames));
127
+ setTimeout(() => process.exit(0), 50);
128
+ `;
129
+ const probePath = join(dir, `probe-${Math.random().toString(36).slice(2)}.ts`);
130
+ writeFileSync(probePath, probe);
131
+ const proc = Bun.spawn(["bun", "run", probePath], {
132
+ cwd: dir,
133
+ env: { ...process.env, PYLON_DEV_MODE: "0", ...(opts.env ?? {}) },
134
+ stdin: "pipe",
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ });
138
+ const [out, err] = await Promise.all([
139
+ new Response(proc.stdout).text(),
140
+ new Response(proc.stderr).text(),
141
+ ]);
142
+ const code = await proc.exited;
143
+ const line = err.split("\n").find((l) => l.includes("FRAMES "));
144
+ if (code !== 0 || !line) {
145
+ throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
146
+ }
147
+ const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + 7)) as Frame[];
148
+ const html = frames
149
+ .filter((f) => f.type === "render_chunk")
150
+ .map((f) => Buffer.from(f.data!, "base64").toString("utf8"))
151
+ .join("");
152
+ const match = html.match(/<script id="__PYLON_DATA__"[^>]*>([\s\S]*?)<\/script>/);
153
+ return {
154
+ start: frames.find((f) => f.type === "response_start"),
155
+ html,
156
+ data: match ? JSON.parse(match[1]) : null,
157
+ };
158
+ }
159
+
160
+ const SESSION_ONLY_PAGE = `return <p>{props.session.exists ? "signed in" : "signed out"}</p>;`;
161
+
162
+ test("a shell request for a page that reads only the signed-in bit is kept, without identity", async () => {
163
+ const dir = makeApp(SESSION_ONLY_PAGE);
164
+ const { start, html, data } = await render(dir, { shell: true });
165
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("1");
166
+ expect(html).toContain("signed in");
167
+ expect(data.props.auth).toEqual({ signedIn: true });
168
+ expect(data.props.session).toEqual({ exists: true });
169
+ expect(html).not.toContain("u_ana");
170
+ expect(html).not.toContain("t_veritas");
171
+ expect(html).not.toContain("secret-session");
172
+ });
173
+
174
+ test("a page that reads props.auth is refused, with the reason", async () => {
175
+ const dir = makeApp(`return <p>{props.auth.userId ?? "anon"}</p>;`);
176
+ const { start } = await render(dir, { shell: true });
177
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("refused; the page read props.auth");
178
+ });
179
+
180
+ test("a page that reads serverData is refused", async () => {
181
+ const dir = makeApp(`props.serverData.list("Lead"); return <p>x</p>;`);
182
+ const { start } = await render(dir, { shell: true });
183
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("refused; the page read serverData");
184
+ });
185
+
186
+ test("a page that reads cookies is refused", async () => {
187
+ const dir = makeApp(`return <p>{String(props.cookies.theme)}</p>;`);
188
+ const { start } = await render(dir, { shell: true });
189
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe(
190
+ "refused; the page read request headers or cookies",
191
+ );
192
+ });
193
+
194
+ test("an ordinary request carries no shell verdict and keeps its real auth", async () => {
195
+ const dir = makeApp(SESSION_ONLY_PAGE);
196
+ const { start, data } = await render(dir);
197
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBeUndefined();
198
+ expect(data.props.auth.user_id).toBe("u_ana");
199
+ });
200
+
201
+ test("every page of an offline app gets the install tags and the register flag", async () => {
202
+ const dir = makeApp(SESSION_ONLY_PAGE);
203
+ const { html, data } = await render(dir);
204
+ expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest">');
205
+ expect(html).toContain('<meta name="theme-color" content="#0b2545">');
206
+ expect(html).toContain('<meta name="apple-mobile-web-app-title" content="Sales">');
207
+ expect(html).toContain('<link rel="apple-touch-icon" href="/icons/512.png">');
208
+ expect(data.offline).toEqual({
209
+ dev: false,
210
+ version: "v1",
211
+ builtAt: "2026-10-22T12:00:00.000Z",
212
+ });
213
+
214
+ const dev = await render(dir, { env: { PYLON_DEV_MODE: "1" } });
215
+ expect(dev.data.offline.dev).toBe(true);
216
+ });
217
+
218
+ test("an app without offline gets neither", async () => {
219
+ const { offline: _o, ...plain } = BUNDLE_MANIFEST;
220
+ const dir = makeApp(SESSION_ONLY_PAGE, plain);
221
+ const { html, data } = await render(dir);
222
+ expect(html).not.toContain('rel="manifest"');
223
+ expect(data.offline).toBeUndefined();
224
+ });
225
+
226
+ test("the worker precaches exactly the files the route's HTML loads", async () => {
227
+ const dir = makeApp(SESSION_ONLY_PAGE);
228
+ const { html } = await render(dir);
229
+ // Every build file the page references: stylesheets, modulepreloads,
230
+ // font preloads, module scripts.
231
+ const loaded = new Set<string>();
232
+ for (const m of html.matchAll(/<(?:link|script)\b[^>]*?(?:href|src)="(\/_pylon\/build\/[^"]+)"/g)) {
233
+ loaded.add(m[1]);
234
+ }
235
+ // The stylesheet is a link by default; record it whether linked or inlined.
236
+ for (const m of html.matchAll(/data-pylon-css="([^"]+)"/g)) loaded.add(`/_pylon/build/${m[1]}`);
237
+ const listed = routeAssetUrls(BUNDLE_MANIFEST, "app/page");
238
+ expect(new Set(listed)).toEqual(loaded);
239
+ expect(loaded.size).toBe(6);
240
+
241
+ const { config } = await offlineWorkerConfig(BUNDLE_MANIFEST, {
242
+ name: "Field Sales",
243
+ startUrl: "/",
244
+ display: "standalone",
245
+ routes: ["/"],
246
+ icons: BUNDLE_MANIFEST.offline.icons,
247
+ });
248
+ for (const url of loaded) expect(config.precache).toContain(url);
249
+ expect(config.precache).toContain("/_pylon/build/manifest.json");
250
+ expect(config.precache).toContain("/manifest.webmanifest");
251
+ expect(config.precache).toContain("/icons/512.png");
252
+ expect(config.shells).toEqual(["/"]);
253
+ });
@@ -0,0 +1,311 @@
1
+ // Build-time and render-time pieces of `buildManifest({ offline })`.
2
+ //
3
+ // - `readManifestOffline`: the `offline` block of pylon.manifest.json.
4
+ // - `buildOfflineWorker`: computes the service worker config for one client
5
+ // build (the precache list comes from `routeAssetUrls`, the same list the
6
+ // SSR render emits tags for) and writes `pylon-sw.js` into the build,
7
+ // with `pylon-version.json` beside it (served at `/_pylon/version`).
8
+ // - `offlineHeadTags`: the install tags every page's `<head>` gets.
9
+ //
10
+ // The worker itself is offline-sw.ts. The Rust host serves
11
+ // `/manifest.webmanifest`, `/pylon-sw.js` and `/_pylon/version`
12
+ // (crates/runtime/src/frontend/offline.rs).
13
+
14
+ import { renderServiceWorker, type ServiceWorkerConfig } from "./offline-sw";
15
+ import { matchRoute } from "./ssr-route-match";
16
+ import { routeAssetUrls, type RouteAssetManifest } from "./ssr-route-assets";
17
+
18
+ /** `buildManifest({ offline })` as the SDK writes it to pylon.manifest.json. */
19
+ export interface ManifestOfflineInput {
20
+ name: string;
21
+ shortName?: string;
22
+ description?: string;
23
+ icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
24
+ themeColor?: string;
25
+ backgroundColor?: string;
26
+ startUrl: string;
27
+ display: string;
28
+ routes: string[];
29
+ }
30
+
31
+ /** What the bundle manifest keeps for the render and the client runtime. */
32
+ export interface BundleOffline {
33
+ name: string;
34
+ shortName?: string;
35
+ themeColor?: string;
36
+ icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
37
+ startUrl: string;
38
+ routes: string[];
39
+ /** The service worker's version (its cache names carry it). */
40
+ version: string;
41
+ /** When this build was made (ISO 8601). Not part of `version`: two
42
+ * machines that build the same app get the same version. */
43
+ builtAt: string;
44
+ }
45
+
46
+ /** `pylon-version.json` in the client build, served at `/_pylon/version`.
47
+ * `useAppUpdate` in @pylonsync/react polls it. */
48
+ export interface BuildVersionFile {
49
+ version: string;
50
+ builtAt: string;
51
+ /** Hash of what a home-screen app reads only when it is added (see
52
+ * `installIdentity`). */
53
+ install: string;
54
+ }
55
+
56
+ /** The file the runtime serves at `/_pylon/version`. */
57
+ export const VERSION_FILE = "pylon-version.json";
58
+
59
+ /** How long a navigation to an offline route waits for the network before
60
+ * the worker shows the kept page. Long enough for a slow but working
61
+ * connection to win; short enough that a dead one does not leave a blank
62
+ * screen. */
63
+ export const NAVIGATION_TIMEOUT_MS = 3000;
64
+
65
+ /** The `offline` block of `<cwd>/pylon.manifest.json`, or null. */
66
+ export function readManifestOffline(
67
+ fs: any,
68
+ path: any,
69
+ cwd: string,
70
+ manifestPath: string = path.join(cwd, "pylon.manifest.json"),
71
+ ): ManifestOfflineInput | null {
72
+ if (!fs.existsSync(manifestPath)) return null;
73
+ try {
74
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
75
+ const o = m?.offline;
76
+ if (!o || typeof o.name !== "string") return null;
77
+ return {
78
+ ...o,
79
+ startUrl: typeof o.startUrl === "string" ? o.startUrl : "/",
80
+ display: typeof o.display === "string" ? o.display : "standalone",
81
+ routes: Array.isArray(o.routes) ? o.routes : [],
82
+ };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Every route pattern in `routes` that names a page in this build, with
89
+ * the page's component. A pattern with no page is reported, not dropped
90
+ * silently. */
91
+ export function resolveOfflineRoutes(
92
+ manifest: RouteAssetManifest,
93
+ routes: string[],
94
+ ): { matched: Array<{ pattern: string; component: string }>; unknown: string[] } {
95
+ const matched: Array<{ pattern: string; component: string }> = [];
96
+ const unknown: string[] = [];
97
+ for (const pattern of routes) {
98
+ const component = Object.keys(manifest.routes).find(
99
+ (c) => manifest.routes[c]?.path === pattern,
100
+ );
101
+ if (component) {
102
+ matched.push({ pattern, component });
103
+ continue;
104
+ }
105
+ // A concrete path (`/leads/today`) served by a dynamic page.
106
+ const m = matchRoute(manifest as any, pattern);
107
+ if (m) matched.push({ pattern, component: m.component });
108
+ else unknown.push(pattern);
109
+ }
110
+ return { matched, unknown };
111
+ }
112
+
113
+ /** True for a pattern with a dynamic segment: one shell cannot serve it. */
114
+ export function isDynamicPattern(pattern: string): boolean {
115
+ return /\[[^\]]+\]/.test(pattern);
116
+ }
117
+
118
+ /**
119
+ * Hash of what iOS reads from the web app manifest and the head tags only
120
+ * when the app is added to the home screen: the name, the short name, the
121
+ * display mode, the start URL and the icons (with the bytes of each icon
122
+ * file in `public/`). When it changes, an installed app keeps the old
123
+ * values until it is removed and added again.
124
+ *
125
+ * The manifest is the one the runtime serves: `public/manifest.webmanifest`,
126
+ * then `<appDir>/manifest.webmanifest`, else the `offline` config (see
127
+ * `web_manifest_source` in crates/runtime/src/frontend/offline.rs).
128
+ */
129
+ export async function installIdentity(
130
+ fs: any,
131
+ path: any,
132
+ cwd: string,
133
+ appDirRel: string,
134
+ offline: ManifestOfflineInput,
135
+ ): Promise<string> {
136
+ let served: any = null;
137
+ for (const candidate of [
138
+ path.join(cwd, "public", "manifest.webmanifest"),
139
+ path.join(cwd, appDirRel, "manifest.webmanifest"),
140
+ ]) {
141
+ if (!fs.existsSync(candidate)) continue;
142
+ try {
143
+ served = JSON.parse(fs.readFileSync(candidate, "utf8"));
144
+ } catch {
145
+ served = { unreadable: fs.readFileSync(candidate, "utf8") };
146
+ }
147
+ break;
148
+ }
149
+ const { createHash } = await import("node:crypto");
150
+ const iconContent = (src: unknown): string | null => {
151
+ if (typeof src !== "string" || !src.startsWith("/") || src.startsWith("//")) return null;
152
+ const file = path.join(cwd, "public", src.split(/[?#]/)[0]);
153
+ if (!fs.existsSync(file)) return null;
154
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
155
+ };
156
+ const icons = (list: unknown) =>
157
+ (Array.isArray(list) ? list : []).map((icon: any) => ({
158
+ src: icon?.src ?? null,
159
+ sizes: icon?.sizes ?? null,
160
+ purpose: icon?.purpose ?? null,
161
+ content: iconContent(icon?.src),
162
+ }));
163
+ const identity = served
164
+ ? {
165
+ name: served.name ?? null,
166
+ shortName: served.short_name ?? null,
167
+ display: served.display ?? null,
168
+ startUrl: served.start_url ?? null,
169
+ icons: icons(served.icons),
170
+ unreadable: served.unreadable ?? null,
171
+ }
172
+ : {
173
+ name: offline.name,
174
+ shortName: offline.shortName ?? offline.name,
175
+ display: offline.display,
176
+ startUrl: offline.startUrl,
177
+ icons: icons(offline.icons),
178
+ };
179
+ // The head tags come from the `offline` config even when the app ships its
180
+ // own manifest file.
181
+ const touch = touchIcon(offline.icons);
182
+ const head = {
183
+ title: offline.shortName ?? offline.name,
184
+ touchIcon: touch,
185
+ touchIconContent: iconContent(touch),
186
+ };
187
+ return createHash("sha256")
188
+ .update(JSON.stringify({ identity, head }))
189
+ .digest("hex")
190
+ .slice(0, 16);
191
+ }
192
+
193
+ /** The service worker config for this build. `install` is
194
+ * `installIdentity`; it is part of the version, so a manifest change
195
+ * installs a new worker (which precaches the new manifest). */
196
+ export async function offlineWorkerConfig(
197
+ manifest: RouteAssetManifest,
198
+ offline: ManifestOfflineInput,
199
+ install: string = "",
200
+ ): Promise<{ config: ServiceWorkerConfig; unknownRoutes: string[] }> {
201
+ const prefix = manifest.public_prefix || "/_pylon/build/";
202
+ const { matched, unknown } = resolveOfflineRoutes(manifest, offline.routes);
203
+ const precache = new Set<string>();
204
+ // The client runtime loads the bundle manifest before it can render a
205
+ // route; it is always same-origin.
206
+ precache.add("/_pylon/build/manifest.json");
207
+ for (const { component } of matched) {
208
+ for (const url of routeAssetUrls(manifest, component)) precache.add(url);
209
+ }
210
+ precache.add("/manifest.webmanifest");
211
+ for (const icon of offline.icons ?? []) precache.add(icon.src);
212
+ const startPath = offline.startUrl.split(/[?#]/)[0] || "/";
213
+ const shells = matched
214
+ .map((m) => m.pattern)
215
+ .filter((p) => !isDynamicPattern(p));
216
+ if (!shells.includes(startPath)) shells.unshift(startPath);
217
+ const base = {
218
+ buildPrefix: prefix,
219
+ precache: Array.from(precache).sort(),
220
+ shells,
221
+ routes: offline.routes,
222
+ startPath,
223
+ navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
224
+ };
225
+ // Content-derived: the build files are content-hashed, so the same build
226
+ // gives the same version on every machine and every boot. A version that
227
+ // changed per boot would reinstall the worker on every deploy machine.
228
+ const { createHash } = await import("node:crypto");
229
+ const version = createHash("sha256")
230
+ .update(JSON.stringify({ ...base, install }))
231
+ .digest("hex")
232
+ .slice(0, 16);
233
+ return { config: { version, ...base }, unknownRoutes: unknown };
234
+ }
235
+
236
+ /** Write `pylon-sw.js` and `pylon-version.json` into `outdir` and return
237
+ * the bundle manifest's `offline` block. `cwd` and `appDirRel` locate the
238
+ * app's own manifest file and its icons. */
239
+ export async function buildOfflineWorker(
240
+ fs: any,
241
+ path: any,
242
+ outdir: string,
243
+ manifest: RouteAssetManifest,
244
+ offline: ManifestOfflineInput,
245
+ cwd: string,
246
+ appDirRel: string,
247
+ ): Promise<BundleOffline> {
248
+ const install = await installIdentity(fs, path, cwd, appDirRel, offline);
249
+ const { config, unknownRoutes } = await offlineWorkerConfig(manifest, offline, install);
250
+ if (unknownRoutes.length > 0) {
251
+ // eslint-disable-next-line no-console
252
+ console.warn(
253
+ `[pylon offline] offline.routes names no page for: ${unknownRoutes.join(", ")}. ` +
254
+ "Those paths will not open offline.",
255
+ );
256
+ }
257
+ fs.writeFileSync(path.join(outdir, "pylon-sw.js"), renderServiceWorker(config), "utf8");
258
+ const versionFile: BuildVersionFile = {
259
+ version: config.version,
260
+ builtAt: new Date().toISOString(),
261
+ install,
262
+ };
263
+ fs.writeFileSync(path.join(outdir, VERSION_FILE), JSON.stringify(versionFile), "utf8");
264
+ return {
265
+ name: offline.name,
266
+ ...(offline.shortName ? { shortName: offline.shortName } : {}),
267
+ ...(offline.themeColor ? { themeColor: offline.themeColor } : {}),
268
+ ...(offline.icons && offline.icons.length > 0 ? { icons: offline.icons } : {}),
269
+ startUrl: offline.startUrl,
270
+ routes: offline.routes,
271
+ version: config.version,
272
+ builtAt: versionFile.builtAt,
273
+ };
274
+ }
275
+
276
+ function attr(value: string): string {
277
+ return value
278
+ .replace(/&/g, "&amp;")
279
+ .replace(/"/g, "&quot;")
280
+ .replace(/</g, "&lt;")
281
+ .replace(/>/g, "&gt;");
282
+ }
283
+
284
+ /** The largest square icon, for `apple-touch-icon` (iOS does not read the
285
+ * web app manifest's icons). */
286
+ function touchIcon(icons: BundleOffline["icons"]): string | null {
287
+ let best: { src: string; size: number } | null = null;
288
+ for (const icon of icons ?? []) {
289
+ if (icon.purpose && !icon.purpose.split(" ").includes("any")) continue;
290
+ for (const s of icon.sizes.split(" ")) {
291
+ const m = s.match(/^(\d+)x(\d+)$/);
292
+ const size = m && m[1] === m[2] ? Number(m[1]) : 0;
293
+ if (size > 0 && (!best || size > best.size)) best = { src: icon.src, size };
294
+ }
295
+ }
296
+ return best ? best.src : null;
297
+ }
298
+
299
+ /** `<head>` tags that make the app installable. */
300
+ export function offlineHeadTags(offline: BundleOffline): string {
301
+ let tags = `<link rel="manifest" href="/manifest.webmanifest">`;
302
+ if (offline.themeColor) {
303
+ tags += `<meta name="theme-color" content="${attr(offline.themeColor)}">`;
304
+ }
305
+ tags += `<meta name="mobile-web-app-capable" content="yes">`;
306
+ tags += `<meta name="apple-mobile-web-app-capable" content="yes">`;
307
+ tags += `<meta name="apple-mobile-web-app-title" content="${attr(offline.shortName ?? offline.name)}">`;
308
+ const icon = touchIcon(offline.icons);
309
+ if (icon) tags += `<link rel="apple-touch-icon" href="${attr(icon)}">`;
310
+ return tags;
311
+ }
@@ -0,0 +1,49 @@
1
+ // Build-time brotli and gzip copies of the client build's text files.
2
+
3
+ import { afterEach, expect, test } from "bun:test";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import zlib from "node:zlib";
8
+ import { MIN_COMPRESS_BYTES, precompressBuild } from "./ssr-precompress";
9
+
10
+ let dir = "";
11
+ afterEach(() => {
12
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
13
+ dir = "";
14
+ });
15
+
16
+ const js = "export const rows = [" + Array.from({ length: 400 }, (_, i) => `{id:${i},name:"row ${i}"}`).join(",") + "];\n";
17
+
18
+ test("writes .br and .gz copies that decompress to the file", () => {
19
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-precompress-"));
20
+ fs.mkdirSync(path.join(dir, "chunks"));
21
+ fs.writeFileSync(path.join(dir, "chunks", "chunk-abcd1234.js"), js);
22
+ fs.writeFileSync(path.join(dir, "styles-abcd1234.css"), "a{color:red}".repeat(200));
23
+
24
+ const result = precompressBuild(fs, path, zlib, dir);
25
+ expect(result.files).toBe(2);
26
+ expect(result.brotliBytes).toBeLessThan(result.bytes);
27
+
28
+ const file = path.join(dir, "chunks", "chunk-abcd1234.js");
29
+ expect(zlib.brotliDecompressSync(fs.readFileSync(file + ".br")).toString()).toBe(js);
30
+ expect(zlib.gunzipSync(fs.readFileSync(file + ".gz")).toString()).toBe(js);
31
+ expect(fs.statSync(file + ".br").size).toBeLessThan(js.length / 4);
32
+ });
33
+
34
+ test("skips small files, binary formats and copies that would not be smaller", () => {
35
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-precompress-"));
36
+ fs.writeFileSync(path.join(dir, "tiny-abcd1234.js"), "x".repeat(MIN_COMPRESS_BYTES - 1));
37
+ fs.writeFileSync(path.join(dir, "font-abcd1234.woff2"), js);
38
+ // Random bytes do not compress.
39
+ const noise = Buffer.from(Array.from({ length: 4096 }, () => Math.floor(Math.random() * 256)));
40
+ fs.writeFileSync(path.join(dir, "noise-abcd1234.js"), noise);
41
+
42
+ const result = precompressBuild(fs, path, zlib, dir);
43
+ expect(result.files).toBe(0);
44
+ expect(fs.readdirSync(dir).sort()).toEqual([
45
+ "font-abcd1234.woff2",
46
+ "noise-abcd1234.js",
47
+ "tiny-abcd1234.js",
48
+ ]);
49
+ });
@@ -0,0 +1,67 @@
1
+ // Brotli and gzip copies of the client build's text files, written at build
2
+ // time. The runtime sends `<file>.br` or `<file>.gz` with Content-Encoding
3
+ // when the browser accepts it (serve_pylon_client_bundle in
4
+ // crates/runtime/src/frontend.rs), so no request pays to compress.
5
+
6
+ /** Extensions worth compressing. Images and fonts are compressed already. */
7
+ const COMPRESSIBLE = new Set([".js", ".mjs", ".css", ".json", ".map", ".svg", ".wasm", ".txt", ".html"]);
8
+
9
+ /** Smaller files gain less than the bytes of the encoding headers. */
10
+ export const MIN_COMPRESS_BYTES = 1024;
11
+
12
+ export interface PrecompressResult {
13
+ /** Files that got at least one compressed copy. */
14
+ files: number;
15
+ /** Total bytes of those files, and of their brotli copies. */
16
+ bytes: number;
17
+ brotliBytes: number;
18
+ }
19
+
20
+ /**
21
+ * Write `<file>.br` (brotli, quality 11) and `<file>.gz` (gzip, level 9)
22
+ * next to every compressible file in `outdir` of at least
23
+ * `MIN_COMPRESS_BYTES`. A copy that is not smaller than the file is not
24
+ * written. Copies left by an earlier build are replaced.
25
+ */
26
+ export function precompressBuild(fs: any, path: any, zlib: any, outdir: string): PrecompressResult {
27
+ const result: PrecompressResult = { files: 0, bytes: 0, brotliBytes: 0 };
28
+ const walk = (dir: string) => {
29
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
30
+ const full = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ walk(full);
33
+ continue;
34
+ }
35
+ if (!entry.isFile() || !COMPRESSIBLE.has(path.extname(entry.name))) continue;
36
+ const data: Uint8Array = fs.readFileSync(full);
37
+ if (data.length < MIN_COMPRESS_BYTES) continue;
38
+ const br: Uint8Array = zlib.brotliCompressSync(data, {
39
+ params: {
40
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
41
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length,
42
+ },
43
+ });
44
+ const gz: Uint8Array = zlib.gzipSync(data, { level: 9 });
45
+ let wrote = false;
46
+ for (const [ext, copy] of [
47
+ [".br", br],
48
+ [".gz", gz],
49
+ ] as const) {
50
+ const target = full + ext;
51
+ if (copy.length < data.length) {
52
+ fs.writeFileSync(target, copy);
53
+ wrote = true;
54
+ } else if (fs.existsSync(target)) {
55
+ fs.unlinkSync(target);
56
+ }
57
+ }
58
+ if (wrote) {
59
+ result.files++;
60
+ result.bytes += data.length;
61
+ result.brotliBytes += Math.min(br.length, data.length);
62
+ }
63
+ }
64
+ };
65
+ walk(outdir);
66
+ return result;
67
+ }