@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,167 @@
1
+ // A page with `cache = "auth-bucketed"` is stored as ONE shared entry for
2
+ // every signed-in visitor, so its render must not depend on who rendered it.
3
+ // Safe serverData reads apply the caller's read policies on the host, so their
4
+ // rows depend on the caller: a signed-in render that reads serverData is
5
+ // identity-specific and must not carry the `x-pylon-bucket` proof. Before
6
+ // this check, Alice's render of a page listing "my notes" was stored in the
7
+ // signed-in bucket and served to Bob.
8
+ //
9
+ // Each render runs in a child process (ssr-runtime.ts imports runtime.ts,
10
+ // whose main() owns stdin/stdout). The test plays the host: it answers the
11
+ // child's `db` requests over stdin with the rows the policy would give the
12
+ // render's auth.
13
+
14
+ import { expect, test } from "bun:test";
15
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const SSR_RUNTIME = join(import.meta.dir, "ssr-runtime.ts");
20
+ const PKG_NODE_MODULES = join(import.meta.dir, "..", "node_modules");
21
+
22
+ interface Frame {
23
+ type: string;
24
+ status?: number;
25
+ headers?: Record<string, string>;
26
+ data?: string;
27
+ }
28
+
29
+ function makeApp(page: string): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "pylon-bucket-sd-"));
31
+ mkdirSync(join(dir, "app"), { recursive: true });
32
+ mkdirSync(join(dir, ".pylon", "client-build"), { recursive: true });
33
+ symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
34
+ writeFileSync(
35
+ join(dir, "app", "layout.tsx"),
36
+ `export default function Layout({ children }: any) {
37
+ return <html><head><title>t</title></head><body>{children}</body></html>;
38
+ }
39
+ `,
40
+ );
41
+ writeFileSync(join(dir, "app", "page.tsx"), page);
42
+ writeFileSync(join(dir, ".pylon", "client-build", "entry.js"), "");
43
+ writeFileSync(
44
+ join(dir, ".pylon", "client-build", "manifest.json"),
45
+ JSON.stringify({
46
+ outdir: ".pylon/client-build",
47
+ public_prefix: "/_pylon/build/",
48
+ routes: { "app/page": { file: "entry.js", imports: [], css: [] } },
49
+ }),
50
+ );
51
+ return dir;
52
+ }
53
+
54
+ const NOTES_PAGE = `export const cache = "auth-bucketed";
55
+ export const revalidate = 300;
56
+ export default async function Page({ serverData }: any) {
57
+ const notes = await serverData.list("Note");
58
+ return <ul>{notes.map((n: any) => <li key={n.id}>{"NOTE:" + n.body}</li>)}</ul>;
59
+ }
60
+ `;
61
+
62
+ const UNSAFE_NOTES_PAGE = NOTES_PAGE.replace("serverData.list", "serverData.unsafe.list");
63
+
64
+ /** Rows a `Note` read returns: the caller's own notes (owner policy) for a
65
+ * safe read, every note for `serverData.unsafe`. */
66
+ function notesFor(userId: string | null, unsafe: boolean) {
67
+ const all = [
68
+ { id: "n1", userId: "alice", body: "alice-secret" },
69
+ { id: "n2", userId: "bob", body: "bob-secret" },
70
+ ];
71
+ return unsafe ? all : all.filter((n) => n.userId === userId);
72
+ }
73
+
74
+ async function render(dir: string, userId: string | null) {
75
+ const msg = {
76
+ type: "render_route",
77
+ call_id: "c1",
78
+ component: "app/page",
79
+ layouts: ["app/layout"],
80
+ route_path: "/",
81
+ url: "/",
82
+ params: {},
83
+ search_params: {},
84
+ headers: { host: "app.test" },
85
+ cookies: {},
86
+ host: "app.test",
87
+ session_present: userId != null,
88
+ auth: { user_id: userId, is_admin: false, tenant_id: null, roles: [] },
89
+ };
90
+ const probe = `
91
+ import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
92
+ const frames = [];
93
+ await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
94
+ console.error("FRAMES " + JSON.stringify(frames));
95
+ setTimeout(() => process.exit(0), 50);
96
+ `;
97
+ const probePath = join(dir, `probe-${Math.random().toString(36).slice(2)}.ts`);
98
+ writeFileSync(probePath, probe);
99
+ const proc = Bun.spawn(["bun", "run", probePath], {
100
+ cwd: dir,
101
+ env: { ...process.env, PYLON_DEV_MODE: "0" },
102
+ stdin: "pipe",
103
+ stdout: "pipe",
104
+ stderr: "pipe",
105
+ });
106
+ // Answer the child's db requests the way the host would.
107
+ const answerDb = (async () => {
108
+ const decoder = new TextDecoder();
109
+ let buf = "";
110
+ for await (const chunk of proc.stdout) {
111
+ buf += decoder.decode(chunk);
112
+ let nl: number;
113
+ while ((nl = buf.indexOf("\n")) >= 0) {
114
+ const line = buf.slice(0, nl);
115
+ buf = buf.slice(nl + 1);
116
+ let m: any;
117
+ try {
118
+ m = JSON.parse(line);
119
+ } catch {
120
+ continue;
121
+ }
122
+ if (m.type !== "db") continue;
123
+ const data = m.entity === "Note" ? notesFor(userId, m.unsafe_op === true) : [];
124
+ proc.stdin.write(
125
+ JSON.stringify({ type: "result", call_id: m.call_id, op_id: m.op_id, data }) + "\n",
126
+ );
127
+ proc.stdin.flush();
128
+ }
129
+ }
130
+ })();
131
+ const err = await new Response(proc.stderr).text();
132
+ const code = await proc.exited;
133
+ await answerDb;
134
+ const line = err.split("\n").find((l) => l.includes("FRAMES "));
135
+ if (code !== 0 || !line) throw new Error(`probe failed (exit ${code})\nstderr: ${err}`);
136
+ const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + "FRAMES ".length)) as Frame[];
137
+ const html = frames
138
+ .filter((f) => f.type === "render_chunk")
139
+ .map((f) => Buffer.from(f.data!, "base64").toString("utf8"))
140
+ .join("");
141
+ const start = frames.find((f) => f.type === "response_start");
142
+ return { html, headers: start?.headers ?? {} };
143
+ }
144
+
145
+ test("a signed-in render that reads serverData is not stored in the shared bucket", async () => {
146
+ const dir = makeApp(NOTES_PAGE);
147
+ const alice = await render(dir, "alice");
148
+ expect(alice.html).toContain("NOTE:alice-secret");
149
+ // The proof the host needs to store this render under the signed-in
150
+ // bucket, where Bob's request would read it.
151
+ expect(alice.headers["x-pylon-bucket"]).toBeUndefined();
152
+ });
153
+
154
+ test("an anonymous render that reads serverData stays bucketable", async () => {
155
+ // Every anonymous visitor gets the same rows, so the signed-out entry is safe.
156
+ const dir = makeApp(NOTES_PAGE);
157
+ const anon = await render(dir, null);
158
+ expect(anon.html).not.toContain("NOTE:");
159
+ expect(anon.headers["x-pylon-bucket"]).toBe("300");
160
+ });
161
+
162
+ test("serverData.unsafe reads do not depend on the caller and stay bucketable", async () => {
163
+ const dir = makeApp(UNSAFE_NOTES_PAGE);
164
+ const signedIn = await render(dir, "alice");
165
+ expect(signedIn.html).toContain("NOTE:bob-secret");
166
+ expect(signedIn.headers["x-pylon-bucket"]).toBe("300");
167
+ });
@@ -909,3 +909,226 @@ 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
+ // `/_pylon/version` serves this file.
960
+ const versionFile = JSON.parse(fs.readFileSync(path.join(outdir, "pylon-version.json"), "utf8"));
961
+ expect(versionFile.version).toBe(manifest.offline!.version);
962
+ expect(versionFile.builtAt).toBe(manifest.offline!.builtAt);
963
+ expect(Number.isNaN(Date.parse(versionFile.builtAt))).toBe(false);
964
+ expect(versionFile.install).toMatch(/^[0-9a-f]{16}$/);
965
+
966
+ // The same build gives the same version: no reinstall per boot.
967
+ const again = await buildClientBundle();
968
+ const second = JSON.parse(fs.readFileSync(again.manifestPath, "utf8")) as PylonBundleManifest;
969
+ expect(second.offline?.version).toBe(manifest.offline!.version);
970
+ });
971
+
972
+ test("a change to the app's name or icon changes the version and the install identity", async () => {
973
+ const dir = makeFixture(
974
+ { "page.tsx": PAGE_BODY("Home"), "leads/page.tsx": PAGE_BODY("Leads") },
975
+ { "layout.tsx": LAYOUT_BODY },
976
+ );
977
+ tempDir = dir;
978
+ const offline = {
979
+ name: "Field Sales",
980
+ icons: [{ src: "/icons/192.png", sizes: "192x192" }],
981
+ startUrl: "/leads",
982
+ display: "standalone",
983
+ routes: ["/leads"],
984
+ };
985
+ const writeManifest = (o: object) =>
986
+ fs.writeFileSync(path.join(dir, "pylon.manifest.json"), JSON.stringify({ offline: o }));
987
+ fs.mkdirSync(path.join(dir, "public", "icons"), { recursive: true });
988
+ fs.writeFileSync(path.join(dir, "public", "icons", "192.png"), "icon-a");
989
+ writeManifest(offline);
990
+ originalCwd = process.cwd();
991
+ process.chdir(dir);
992
+
993
+ const read = async () => {
994
+ const { outdir } = await buildClientBundle();
995
+ return JSON.parse(fs.readFileSync(path.join(outdir, "pylon-version.json"), "utf8"));
996
+ };
997
+ const base = await read();
998
+
999
+ // New icon bytes at the same URL.
1000
+ fs.writeFileSync(path.join(dir, "public", "icons", "192.png"), "icon-b");
1001
+ const icon = await read();
1002
+ expect(icon.install).not.toBe(base.install);
1003
+ expect(icon.version).not.toBe(base.version);
1004
+
1005
+ // A new name.
1006
+ writeManifest({ ...offline, name: "Veritas" });
1007
+ const named = await read();
1008
+ expect(named.install).not.toBe(icon.install);
1009
+
1010
+ // The app's own manifest file wins over the config.
1011
+ fs.writeFileSync(
1012
+ path.join(dir, "public", "manifest.webmanifest"),
1013
+ JSON.stringify({ name: "Own", display: "standalone", icons: [] }),
1014
+ );
1015
+ const own = await read();
1016
+ expect(own.install).not.toBe(named.install);
1017
+ // A field iOS does not read at install (theme color) changes nothing.
1018
+ fs.writeFileSync(
1019
+ path.join(dir, "public", "manifest.webmanifest"),
1020
+ JSON.stringify({ name: "Own", display: "standalone", icons: [], theme_color: "#000" }),
1021
+ );
1022
+ expect((await read()).install).toBe(own.install);
1023
+ });
1024
+
1025
+ test("an app without offline gets no worker", async () => {
1026
+ tempDir = makeFixture({ "page.tsx": PAGE_BODY("Home") }, { "layout.tsx": LAYOUT_BODY });
1027
+ originalCwd = process.cwd();
1028
+ process.chdir(tempDir);
1029
+ const { manifestPath, outdir } = await buildClientBundle();
1030
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
1031
+ expect(manifest.offline).toBeUndefined();
1032
+ expect(fs.existsSync(path.join(outdir, "pylon-sw.js"))).toBe(false);
1033
+ });
1034
+ });
1035
+
1036
+ describe("instant navigation (export const navigation = \"client\")", () => {
1037
+ test("the manifest marks the page, and its entry passes the static metadata", async () => {
1038
+ const dir = makeFixture(
1039
+ {
1040
+ "page.tsx": PAGE_BODY("Home"),
1041
+ "leads/page.tsx":
1042
+ `export const navigation = "client";\nexport const metadata = { title: "Leads" };\n` +
1043
+ PAGE_BODY("Leads"),
1044
+ "reports/page.tsx": PAGE_BODY("Reports"),
1045
+ },
1046
+ { "layout.tsx": LAYOUT_BODY },
1047
+ );
1048
+ tempDir = dir;
1049
+ originalCwd = process.cwd();
1050
+ process.chdir(dir);
1051
+
1052
+ const { manifestPath, outdir } = await buildClientBundle();
1053
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
1054
+ expect(manifest.routes["app/leads/page"].navigation).toBe("client");
1055
+ expect(manifest.routes["app/reports/page"].navigation).toBeUndefined();
1056
+ expect(manifest.routes["app/page"].navigation).toBeUndefined();
1057
+
1058
+ // The built client code carries the title for the runtime; a page without
1059
+ // static metadata imports none.
1060
+ const code = (component: string) => {
1061
+ const r = manifest.routes[component];
1062
+ return [r.file, ...r.imports]
1063
+ .map((f) => fs.readFileSync(path.join(outdir, f), "utf8"))
1064
+ .join("\n");
1065
+ };
1066
+ expect(code("app/leads/page")).toMatch(/title:\s*"Leads"/);
1067
+ });
1068
+ });
1069
+
1070
+ describe("precompressed build files", () => {
1071
+ test("a production build writes .br and .gz copies of its chunks; precompress: false does not", async () => {
1072
+ const dir = makeFixture({ "page.tsx": PAGE_BODY("Home") }, { "layout.tsx": LAYOUT_BODY });
1073
+ tempDir = dir;
1074
+ originalCwd = process.cwd();
1075
+ process.chdir(dir);
1076
+
1077
+ const { manifestPath, outdir } = await _doBuildInner(fs, path, dir, "app", { precompress: true });
1078
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
1079
+ const route = manifest.routes["app/page"];
1080
+ // The shared chunk holds React: large, and compressed.
1081
+ const shared = route.imports
1082
+ .map((f) => path.join(outdir, f))
1083
+ .sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0];
1084
+ expect(fs.existsSync(shared + ".br")).toBe(true);
1085
+ expect(fs.existsSync(shared + ".gz")).toBe(true);
1086
+ expect(fs.statSync(shared + ".br").size).toBeLessThan(fs.statSync(shared).size / 2);
1087
+
1088
+ const again = await _doBuildInner(fs, path, dir, "app", { precompress: false });
1089
+ const files = fs.readdirSync(again.outdir, { recursive: true }) as string[];
1090
+ expect(files.filter((f) => f.endsWith(".br") || f.endsWith(".gz"))).toEqual([]);
1091
+ });
1092
+ });
1093
+
1094
+ describe("namespace barrels (radix-ui)", () => {
1095
+ test("a route bundles only the barrel packages it imports", async () => {
1096
+ const dir = makeFixture(
1097
+ {
1098
+ "alpha/page.tsx": `import { Alpha } from "fake-ui";\nexport default function P() { return <div>{Alpha.name}</div>; }\n`,
1099
+ "beta/page.tsx": `import { Beta as B } from "fake-ui";\nexport default function P() { return <div>{B.name}</div>; }\n`,
1100
+ },
1101
+ { "layout.tsx": LAYOUT_BODY },
1102
+ );
1103
+ tempDir = dir;
1104
+ // A barrel and the two packages it re-exports, resolvable from app/.
1105
+ const nm = path.join(dir, "app", "node_modules");
1106
+ const pkg = (name: string, files: Record<string, string>) => {
1107
+ fs.mkdirSync(path.join(nm, name), { recursive: true });
1108
+ for (const [f, body] of Object.entries(files)) fs.writeFileSync(path.join(nm, name, f), body);
1109
+ };
1110
+ pkg("fake-ui", {
1111
+ "package.json": JSON.stringify({ name: "fake-ui", module: "index.mjs", main: "index.mjs", sideEffects: false }),
1112
+ "index.mjs": `import * as Alpha from "@fake/alpha";\nimport * as Beta from "@fake/beta";\nexport { Alpha, Beta };\n`,
1113
+ });
1114
+ for (const [name, marker] of [["alpha", "ALPHA_ONLY_CODE"], ["beta", "BETA_ONLY_CODE"]]) {
1115
+ pkg(`@fake/${name}`, {
1116
+ "package.json": JSON.stringify({ name: `@fake/${name}`, module: "index.mjs", main: "index.mjs", sideEffects: false }),
1117
+ "index.mjs": `export const name = ${JSON.stringify(marker + " " + "x".repeat(2000))};\n`,
1118
+ });
1119
+ }
1120
+ originalCwd = process.cwd();
1121
+ process.chdir(dir);
1122
+
1123
+ const { manifestPath, outdir } = await _doBuildInner(fs, path, dir, "app", { precompress: false });
1124
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PylonBundleManifest;
1125
+ const code = (component: string) => {
1126
+ const r = manifest.routes[component];
1127
+ return [r.file, ...r.imports].map((f) => fs.readFileSync(path.join(outdir, f), "utf8")).join("\n");
1128
+ };
1129
+ expect(code("app/alpha/page")).toContain("ALPHA_ONLY_CODE");
1130
+ expect(code("app/alpha/page")).not.toContain("BETA_ONLY_CODE");
1131
+ expect(code("app/beta/page")).toContain("BETA_ONLY_CODE");
1132
+ expect(code("app/beta/page")).not.toContain("ALPHA_ONLY_CODE");
1133
+ });
1134
+ });