@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.
@@ -0,0 +1,248 @@
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
+ },
63
+ };
64
+
65
+ function makeApp(pageBody: string, manifest: object = BUNDLE_MANIFEST): string {
66
+ const dir = mkdtempSync(join(tmpdir(), "pylon-offline-shell-"));
67
+ mkdirSync(join(dir, "app"), { recursive: true });
68
+ mkdirSync(join(dir, ".pylon", "client-build"), { recursive: true });
69
+ symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
70
+ writeFileSync(
71
+ join(dir, "app", "layout.tsx"),
72
+ `export default function Layout({ children }: any) {
73
+ return <html><head><title>t</title></head><body>{children}</body></html>;
74
+ }
75
+ `,
76
+ );
77
+ writeFileSync(
78
+ join(dir, "app", "page.tsx"),
79
+ `export default function Page(props: any) {
80
+ ${pageBody}
81
+ }
82
+ `,
83
+ );
84
+ writeFileSync(join(dir, ".pylon", "client-build", "styles-eeee5555.css"), "p{color:red}");
85
+ writeFileSync(
86
+ join(dir, ".pylon", "client-build", "manifest.json"),
87
+ JSON.stringify(manifest),
88
+ );
89
+ return dir;
90
+ }
91
+
92
+ const SIGNED_IN = {
93
+ user_id: "u_ana",
94
+ is_admin: false,
95
+ tenant_id: "t_veritas",
96
+ roles: ["rep"],
97
+ };
98
+
99
+ async function render(
100
+ dir: string,
101
+ opts: { shell?: boolean; auth?: object; env?: Record<string, string> } = {},
102
+ ): Promise<{ start: Frame | undefined; html: string; data: any }> {
103
+ const headers: Record<string, string> = { host: "app.example" };
104
+ if (opts.shell) headers["x-pylon-offline-shell"] = "1";
105
+ const auth = opts.auth ?? SIGNED_IN;
106
+ const msg = {
107
+ type: "render_route",
108
+ call_id: "c1",
109
+ component: "app/page",
110
+ layouts: ["app/layout"],
111
+ route_path: "/",
112
+ url: "/",
113
+ host: "app.example",
114
+ params: {},
115
+ search_params: {},
116
+ headers,
117
+ cookies: { sid: "secret-session" },
118
+ auth,
119
+ session_present: !!(auth as any).user_id,
120
+ };
121
+ const probe = `
122
+ import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
123
+ const frames = [];
124
+ await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
125
+ console.error("FRAMES " + JSON.stringify(frames));
126
+ setTimeout(() => process.exit(0), 50);
127
+ `;
128
+ const probePath = join(dir, `probe-${Math.random().toString(36).slice(2)}.ts`);
129
+ writeFileSync(probePath, probe);
130
+ const proc = Bun.spawn(["bun", "run", probePath], {
131
+ cwd: dir,
132
+ env: { ...process.env, PYLON_DEV_MODE: "0", ...(opts.env ?? {}) },
133
+ stdin: "pipe",
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ });
137
+ const [out, err] = await Promise.all([
138
+ new Response(proc.stdout).text(),
139
+ new Response(proc.stderr).text(),
140
+ ]);
141
+ const code = await proc.exited;
142
+ const line = err.split("\n").find((l) => l.includes("FRAMES "));
143
+ if (code !== 0 || !line) {
144
+ throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
145
+ }
146
+ const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + 7)) as Frame[];
147
+ const html = frames
148
+ .filter((f) => f.type === "render_chunk")
149
+ .map((f) => Buffer.from(f.data!, "base64").toString("utf8"))
150
+ .join("");
151
+ const match = html.match(/<script id="__PYLON_DATA__"[^>]*>([\s\S]*?)<\/script>/);
152
+ return {
153
+ start: frames.find((f) => f.type === "response_start"),
154
+ html,
155
+ data: match ? JSON.parse(match[1]) : null,
156
+ };
157
+ }
158
+
159
+ const SESSION_ONLY_PAGE = `return <p>{props.session.exists ? "signed in" : "signed out"}</p>;`;
160
+
161
+ test("a shell request for a page that reads only the signed-in bit is kept, without identity", async () => {
162
+ const dir = makeApp(SESSION_ONLY_PAGE);
163
+ const { start, html, data } = await render(dir, { shell: true });
164
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("1");
165
+ expect(html).toContain("signed in");
166
+ expect(data.props.auth).toEqual({ signedIn: true });
167
+ expect(data.props.session).toEqual({ exists: true });
168
+ expect(html).not.toContain("u_ana");
169
+ expect(html).not.toContain("t_veritas");
170
+ expect(html).not.toContain("secret-session");
171
+ });
172
+
173
+ test("a page that reads props.auth is refused, with the reason", async () => {
174
+ const dir = makeApp(`return <p>{props.auth.userId ?? "anon"}</p>;`);
175
+ const { start } = await render(dir, { shell: true });
176
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("refused; the page read props.auth");
177
+ });
178
+
179
+ test("a page that reads serverData is refused", async () => {
180
+ const dir = makeApp(`props.serverData.list("Lead"); return <p>x</p>;`);
181
+ const { start } = await render(dir, { shell: true });
182
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe("refused; the page read serverData");
183
+ });
184
+
185
+ test("a page that reads cookies is refused", async () => {
186
+ const dir = makeApp(`return <p>{String(props.cookies.theme)}</p>;`);
187
+ const { start } = await render(dir, { shell: true });
188
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBe(
189
+ "refused; the page read request headers or cookies",
190
+ );
191
+ });
192
+
193
+ test("an ordinary request carries no shell verdict and keeps its real auth", async () => {
194
+ const dir = makeApp(SESSION_ONLY_PAGE);
195
+ const { start, data } = await render(dir);
196
+ expect(start?.headers?.["x-pylon-offline-shell"]).toBeUndefined();
197
+ expect(data.props.auth.user_id).toBe("u_ana");
198
+ });
199
+
200
+ test("every page of an offline app gets the install tags and the register flag", async () => {
201
+ const dir = makeApp(SESSION_ONLY_PAGE);
202
+ const { html, data } = await render(dir);
203
+ expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest">');
204
+ expect(html).toContain('<meta name="theme-color" content="#0b2545">');
205
+ expect(html).toContain('<meta name="apple-mobile-web-app-title" content="Sales">');
206
+ expect(html).toContain('<link rel="apple-touch-icon" href="/icons/512.png">');
207
+ expect(data.offline).toEqual({ dev: false });
208
+
209
+ const dev = await render(dir, { env: { PYLON_DEV_MODE: "1" } });
210
+ expect(dev.data.offline).toEqual({ dev: true });
211
+ });
212
+
213
+ test("an app without offline gets neither", async () => {
214
+ const { offline: _o, ...plain } = BUNDLE_MANIFEST;
215
+ const dir = makeApp(SESSION_ONLY_PAGE, plain);
216
+ const { html, data } = await render(dir);
217
+ expect(html).not.toContain('rel="manifest"');
218
+ expect(data.offline).toBeUndefined();
219
+ });
220
+
221
+ test("the worker precaches exactly the files the route's HTML loads", async () => {
222
+ const dir = makeApp(SESSION_ONLY_PAGE);
223
+ const { html } = await render(dir);
224
+ // Every build file the page references: stylesheets, modulepreloads,
225
+ // font preloads, module scripts.
226
+ const loaded = new Set<string>();
227
+ for (const m of html.matchAll(/<(?:link|script)\b[^>]*?(?:href|src)="(\/_pylon\/build\/[^"]+)"/g)) {
228
+ loaded.add(m[1]);
229
+ }
230
+ // The stylesheet is a link by default; record it whether linked or inlined.
231
+ for (const m of html.matchAll(/data-pylon-css="([^"]+)"/g)) loaded.add(`/_pylon/build/${m[1]}`);
232
+ const listed = routeAssetUrls(BUNDLE_MANIFEST, "app/page");
233
+ expect(new Set(listed)).toEqual(loaded);
234
+ expect(loaded.size).toBe(6);
235
+
236
+ const { config } = await offlineWorkerConfig(BUNDLE_MANIFEST, {
237
+ name: "Field Sales",
238
+ startUrl: "/",
239
+ display: "standalone",
240
+ routes: ["/"],
241
+ icons: BUNDLE_MANIFEST.offline.icons,
242
+ });
243
+ for (const url of loaded) expect(config.precache).toContain(url);
244
+ expect(config.precache).toContain("/_pylon/build/manifest.json");
245
+ expect(config.precache).toContain("/manifest.webmanifest");
246
+ expect(config.precache).toContain("/icons/512.png");
247
+ expect(config.shells).toEqual(["/"]);
248
+ });
@@ -0,0 +1,205 @@
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
+ // - `offlineHeadTags`: the install tags every page's `<head>` gets.
8
+ //
9
+ // The worker itself is offline-sw.ts. The Rust host serves
10
+ // `/manifest.webmanifest` and `/pylon-sw.js` (crates/runtime/src/frontend/
11
+ // offline.rs).
12
+
13
+ import { renderServiceWorker, type ServiceWorkerConfig } from "./offline-sw";
14
+ import { matchRoute } from "./ssr-route-match";
15
+ import { routeAssetUrls, type RouteAssetManifest } from "./ssr-route-assets";
16
+
17
+ /** `buildManifest({ offline })` as the SDK writes it to pylon.manifest.json. */
18
+ export interface ManifestOfflineInput {
19
+ name: string;
20
+ shortName?: string;
21
+ description?: string;
22
+ icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
23
+ themeColor?: string;
24
+ backgroundColor?: string;
25
+ startUrl: string;
26
+ display: string;
27
+ routes: string[];
28
+ }
29
+
30
+ /** What the bundle manifest keeps for the render and the client runtime. */
31
+ export interface BundleOffline {
32
+ name: string;
33
+ shortName?: string;
34
+ themeColor?: string;
35
+ icons?: Array<{ src: string; sizes: string; type?: string; purpose?: string }>;
36
+ startUrl: string;
37
+ routes: string[];
38
+ /** The service worker's version (its cache names carry it). */
39
+ version: string;
40
+ }
41
+
42
+ /** How long a navigation to an offline route waits for the network before
43
+ * the worker shows the kept page. Long enough for a slow but working
44
+ * connection to win; short enough that a dead one does not leave a blank
45
+ * screen. */
46
+ export const NAVIGATION_TIMEOUT_MS = 3000;
47
+
48
+ /** The `offline` block of `<cwd>/pylon.manifest.json`, or null. */
49
+ export function readManifestOffline(
50
+ fs: any,
51
+ path: any,
52
+ cwd: string,
53
+ manifestPath: string = path.join(cwd, "pylon.manifest.json"),
54
+ ): ManifestOfflineInput | null {
55
+ if (!fs.existsSync(manifestPath)) return null;
56
+ try {
57
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
58
+ const o = m?.offline;
59
+ if (!o || typeof o.name !== "string") return null;
60
+ return {
61
+ ...o,
62
+ startUrl: typeof o.startUrl === "string" ? o.startUrl : "/",
63
+ display: typeof o.display === "string" ? o.display : "standalone",
64
+ routes: Array.isArray(o.routes) ? o.routes : [],
65
+ };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** Every route pattern in `routes` that names a page in this build, with
72
+ * the page's component. A pattern with no page is reported, not dropped
73
+ * silently. */
74
+ export function resolveOfflineRoutes(
75
+ manifest: RouteAssetManifest,
76
+ routes: string[],
77
+ ): { matched: Array<{ pattern: string; component: string }>; unknown: string[] } {
78
+ const matched: Array<{ pattern: string; component: string }> = [];
79
+ const unknown: string[] = [];
80
+ for (const pattern of routes) {
81
+ const component = Object.keys(manifest.routes).find(
82
+ (c) => manifest.routes[c]?.path === pattern,
83
+ );
84
+ if (component) {
85
+ matched.push({ pattern, component });
86
+ continue;
87
+ }
88
+ // A concrete path (`/leads/today`) served by a dynamic page.
89
+ const m = matchRoute(manifest as any, pattern);
90
+ if (m) matched.push({ pattern, component: m.component });
91
+ else unknown.push(pattern);
92
+ }
93
+ return { matched, unknown };
94
+ }
95
+
96
+ /** True for a pattern with a dynamic segment: one shell cannot serve it. */
97
+ export function isDynamicPattern(pattern: string): boolean {
98
+ return /\[[^\]]+\]/.test(pattern);
99
+ }
100
+
101
+ /** The service worker config for this build. */
102
+ export async function offlineWorkerConfig(
103
+ manifest: RouteAssetManifest,
104
+ offline: ManifestOfflineInput,
105
+ ): Promise<{ config: ServiceWorkerConfig; unknownRoutes: string[] }> {
106
+ const prefix = manifest.public_prefix || "/_pylon/build/";
107
+ const { matched, unknown } = resolveOfflineRoutes(manifest, offline.routes);
108
+ const precache = new Set<string>();
109
+ // The client runtime loads the bundle manifest before it can render a
110
+ // route; it is always same-origin.
111
+ precache.add("/_pylon/build/manifest.json");
112
+ for (const { component } of matched) {
113
+ for (const url of routeAssetUrls(manifest, component)) precache.add(url);
114
+ }
115
+ precache.add("/manifest.webmanifest");
116
+ for (const icon of offline.icons ?? []) precache.add(icon.src);
117
+ const startPath = offline.startUrl.split(/[?#]/)[0] || "/";
118
+ const shells = matched
119
+ .map((m) => m.pattern)
120
+ .filter((p) => !isDynamicPattern(p));
121
+ if (!shells.includes(startPath)) shells.unshift(startPath);
122
+ const base = {
123
+ buildPrefix: prefix,
124
+ precache: Array.from(precache).sort(),
125
+ shells,
126
+ routes: offline.routes,
127
+ startPath,
128
+ navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
129
+ };
130
+ // Content-derived: the build files are content-hashed, so the same build
131
+ // gives the same version on every machine and every boot. A version that
132
+ // changed per boot would reinstall the worker on every deploy machine.
133
+ const { createHash } = await import("node:crypto");
134
+ const version = createHash("sha256")
135
+ .update(JSON.stringify(base))
136
+ .digest("hex")
137
+ .slice(0, 16);
138
+ return { config: { version, ...base }, unknownRoutes: unknown };
139
+ }
140
+
141
+ /** Write `pylon-sw.js` into `outdir` and return the bundle manifest's
142
+ * `offline` block. */
143
+ export async function buildOfflineWorker(
144
+ fs: any,
145
+ path: any,
146
+ outdir: string,
147
+ manifest: RouteAssetManifest,
148
+ offline: ManifestOfflineInput,
149
+ ): Promise<BundleOffline> {
150
+ const { config, unknownRoutes } = await offlineWorkerConfig(manifest, offline);
151
+ if (unknownRoutes.length > 0) {
152
+ // eslint-disable-next-line no-console
153
+ console.warn(
154
+ `[pylon offline] offline.routes names no page for: ${unknownRoutes.join(", ")}. ` +
155
+ "Those paths will not open offline.",
156
+ );
157
+ }
158
+ fs.writeFileSync(path.join(outdir, "pylon-sw.js"), renderServiceWorker(config), "utf8");
159
+ return {
160
+ name: offline.name,
161
+ ...(offline.shortName ? { shortName: offline.shortName } : {}),
162
+ ...(offline.themeColor ? { themeColor: offline.themeColor } : {}),
163
+ ...(offline.icons && offline.icons.length > 0 ? { icons: offline.icons } : {}),
164
+ startUrl: offline.startUrl,
165
+ routes: offline.routes,
166
+ version: config.version,
167
+ };
168
+ }
169
+
170
+ function attr(value: string): string {
171
+ return value
172
+ .replace(/&/g, "&amp;")
173
+ .replace(/"/g, "&quot;")
174
+ .replace(/</g, "&lt;")
175
+ .replace(/>/g, "&gt;");
176
+ }
177
+
178
+ /** The largest square icon, for `apple-touch-icon` (iOS does not read the
179
+ * web app manifest's icons). */
180
+ function touchIcon(icons: BundleOffline["icons"]): string | null {
181
+ let best: { src: string; size: number } | null = null;
182
+ for (const icon of icons ?? []) {
183
+ if (icon.purpose && !icon.purpose.split(" ").includes("any")) continue;
184
+ for (const s of icon.sizes.split(" ")) {
185
+ const m = s.match(/^(\d+)x(\d+)$/);
186
+ const size = m && m[1] === m[2] ? Number(m[1]) : 0;
187
+ if (size > 0 && (!best || size > best.size)) best = { src: icon.src, size };
188
+ }
189
+ }
190
+ return best ? best.src : null;
191
+ }
192
+
193
+ /** `<head>` tags that make the app installable. */
194
+ export function offlineHeadTags(offline: BundleOffline): string {
195
+ let tags = `<link rel="manifest" href="/manifest.webmanifest">`;
196
+ if (offline.themeColor) {
197
+ tags += `<meta name="theme-color" content="${attr(offline.themeColor)}">`;
198
+ }
199
+ tags += `<meta name="mobile-web-app-capable" content="yes">`;
200
+ tags += `<meta name="apple-mobile-web-app-capable" content="yes">`;
201
+ tags += `<meta name="apple-mobile-web-app-title" content="${attr(offline.shortName ?? offline.name)}">`;
202
+ const icon = touchIcon(offline.icons);
203
+ if (icon) tags += `<link rel="apple-touch-icon" href="${attr(icon)}">`;
204
+ return tags;
205
+ }
@@ -0,0 +1,68 @@
1
+ // The client build files one page needs, from the bundle manifest.
2
+ //
3
+ // One list, two readers: the SSR render emits a tag for each file (stylesheet
4
+ // links, modulepreloads, the polyfill and entry scripts, font preloads), and
5
+ // the offline service worker precaches the same files for each offline route.
6
+ // Computing them in one place keeps the two from drifting: a file the page
7
+ // loads but the worker did not keep would break the page with no network.
8
+
9
+ /** The bundle-manifest fields this module reads. */
10
+ export interface RouteAssetManifest {
11
+ public_prefix?: string;
12
+ polyfills?: string;
13
+ routes: Record<
14
+ string,
15
+ { file: string; imports?: string[]; css?: string[]; path?: string } | undefined
16
+ >;
17
+ fonts?: { preload?: string[] } | null;
18
+ }
19
+
20
+ /** Outdir-relative files for one route, in the order the page loads them. */
21
+ export interface RouteAssetFiles {
22
+ /** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
23
+ styles: string[];
24
+ /** `<link rel="modulepreload">` in the head. */
25
+ preloads: string[];
26
+ /** `<link rel="preload" as="font">` in the head. */
27
+ fonts: string[];
28
+ /** `<script type="module">` in the body tail: the polyfills, then the
29
+ * route entry. */
30
+ scripts: string[];
31
+ }
32
+
33
+ /** The scripts the hydration tail loads for a route entry. */
34
+ export function routeScriptFiles(
35
+ route: { file: string },
36
+ polyfills: string | undefined,
37
+ ): string[] {
38
+ return polyfills ? [polyfills, route.file] : [route.file];
39
+ }
40
+
41
+ /** Every client-build file the page for `component` loads, or null when the
42
+ * build has no entry for it. */
43
+ export function routeAssetFiles(
44
+ manifest: RouteAssetManifest,
45
+ component: string,
46
+ ): RouteAssetFiles | null {
47
+ const route = manifest.routes[component];
48
+ if (!route) return null;
49
+ return {
50
+ styles: route.css ?? [],
51
+ preloads: route.imports ?? [],
52
+ fonts: manifest.fonts?.preload ?? [],
53
+ scripts: routeScriptFiles(route, manifest.polyfills),
54
+ };
55
+ }
56
+
57
+ /** All of a route's files as URLs under the build's public prefix. */
58
+ export function routeAssetUrls(
59
+ manifest: RouteAssetManifest,
60
+ component: string,
61
+ ): string[] {
62
+ const files = routeAssetFiles(manifest, component);
63
+ if (!files) return [];
64
+ const prefix = manifest.public_prefix || "/_pylon/build/";
65
+ return [...files.styles, ...files.preloads, ...files.fonts, ...files.scripts].map(
66
+ (f) => prefix + f,
67
+ );
68
+ }