@pylonsync/functions 0.22.50 → 0.22.59

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/src/runtime.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  EmailOptions,
24
24
  EmailSender,
25
25
  Files,
26
+ FileInfo,
26
27
  ActionFiles,
27
28
  Crdt,
28
29
  StoredFile,
@@ -622,8 +623,8 @@ function buildShards(callId: string): Shards {
622
623
  };
623
624
  }
624
625
 
625
- /** `ctx.files` — signed download URLs, minted host-side. */
626
- function buildFiles(callId: string): Files {
626
+ /** `ctx.files` — signed download URLs, minted host-side, and what the host knows of a file. */
627
+ export function buildFiles(callId: string): Files {
627
628
  return {
628
629
  async signedUrl(fileId, opts) {
629
630
  return rpc(callId, {
@@ -632,11 +633,14 @@ function buildFiles(callId: string): Files {
632
633
  ttl_secs: opts?.ttlSecs,
633
634
  }) as Promise<string>;
634
635
  },
636
+ async info(fileId) {
637
+ return rpc(callId, { type: "files_op", op: "info", file_id: fileId }) as Promise<FileInfo | null>;
638
+ },
635
639
  };
636
640
  }
637
641
 
638
- /** `ctx.files` in an action: {@link buildFiles} plus store and delete. */
639
- function buildActionFiles(callId: string): ActionFiles {
642
+ /** `ctx.files` in an action: {@link buildFiles} plus store, read and delete. */
643
+ export function buildActionFiles(callId: string): ActionFiles {
640
644
  return {
641
645
  ...buildFiles(callId),
642
646
  async store(data, opts) {
@@ -667,6 +671,15 @@ function buildActionFiles(callId: string): ActionFiles {
667
671
  file_id: fileId,
668
672
  }) as Promise<{ deleted: boolean }>;
669
673
  },
674
+ async read(fileId) {
675
+ const out = (await rpc(callId, { type: "files_op", op: "read", file_id: fileId })) as {
676
+ id: string;
677
+ contentType: string | null;
678
+ size: number;
679
+ data_base64: string;
680
+ };
681
+ return { id: out.id, contentType: out.contentType ?? null, size: out.size, data: fromBase64(out.data_base64) };
682
+ },
670
683
  };
671
684
  }
672
685
 
@@ -0,0 +1,70 @@
1
+ // Namespace barrels (`radix-ui`): parsing the entry, and rewriting named
2
+ // imports of it into imports of the packages it re-exports.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { parseNamespaceBarrel, rewriteBarrelImports } from "./ssr-barrel-imports";
6
+
7
+ const RADIX = `// src/index.ts
8
+ "use client";
9
+ import * as AlertDialog from "@radix-ui/react-alert-dialog";
10
+ import * as Dialog from "@radix-ui/react-dialog";
11
+ import * as unstable_PasswordToggleField from "@radix-ui/react-password-toggle-field";
12
+ export {
13
+ AlertDialog,
14
+ Dialog,
15
+ unstable_PasswordToggleField
16
+ };
17
+ //# sourceMappingURL=index.mjs.map
18
+ `;
19
+
20
+ describe("parseNamespaceBarrel", () => {
21
+ test("maps each exported namespace to its package", () => {
22
+ expect(Object.fromEntries(parseNamespaceBarrel(RADIX)!)).toEqual({
23
+ AlertDialog: "@radix-ui/react-alert-dialog",
24
+ Dialog: "@radix-ui/react-dialog",
25
+ unstable_PasswordToggleField: "@radix-ui/react-password-toggle-field",
26
+ });
27
+ });
28
+
29
+ test("anything else is not a barrel", () => {
30
+ expect(parseNamespaceBarrel(`export const x = 1;`)).toBeNull();
31
+ expect(parseNamespaceBarrel(`import * as A from "a"; export { A }; console.log(1);`)).toBeNull();
32
+ expect(parseNamespaceBarrel(`import { a } from "a"; export { a };`)).toBeNull();
33
+ expect(parseNamespaceBarrel(`import * as A from "a"; export { A, B };`)).toBeNull();
34
+ expect(parseNamespaceBarrel(``)).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("rewriteBarrelImports", () => {
39
+ const map = parseNamespaceBarrel(RADIX)!;
40
+ const barrels = (spec: string) => (spec === "radix-ui" ? map : null);
41
+ const same = (_barrel: string, spec: string) => spec;
42
+
43
+ test("one namespace import per name, keeping local names", () => {
44
+ expect(
45
+ rewriteBarrelImports(`import { Dialog as SheetPrimitive } from "radix-ui";`, barrels, same),
46
+ ).toBe(`import * as SheetPrimitive from "@radix-ui/react-dialog";`);
47
+ expect(
48
+ rewriteBarrelImports(`import {\n AlertDialog,\n Dialog,\n} from 'radix-ui'`, barrels, same),
49
+ ).toBe(`import * as AlertDialog from '@radix-ui/react-alert-dialog'; import * as Dialog from '@radix-ui/react-dialog';`);
50
+ });
51
+
52
+ test("type imports, unknown names and other packages stay", () => {
53
+ expect(rewriteBarrelImports(`import type { Dialog } from "radix-ui";`, barrels, same)).toBe(
54
+ `import type { Dialog } from "radix-ui";`,
55
+ );
56
+ expect(
57
+ rewriteBarrelImports(`import { Dialog, type Foo, Missing } from "radix-ui";`, barrels, same),
58
+ ).toBe(
59
+ `import { type Foo, Missing } from "radix-ui"; import * as Dialog from "@radix-ui/react-dialog";`,
60
+ );
61
+ const other = `import { useState } from "react";`;
62
+ expect(rewriteBarrelImports(other, barrels, same)).toBe(other);
63
+ });
64
+
65
+ test("the target specifier comes from the resolver", () => {
66
+ expect(
67
+ rewriteBarrelImports(`import { Dialog } from "radix-ui";`, barrels, () => "/abs/react-dialog/index.mjs"),
68
+ ).toBe(`import * as Dialog from "/abs/react-dialog/index.mjs";`);
69
+ });
70
+ });
@@ -0,0 +1,104 @@
1
+ // Per-route vendor code for "barrel" packages.
2
+ //
3
+ // A barrel is a package whose entry only re-exports other packages as
4
+ // namespaces, like `radix-ui`:
5
+ //
6
+ // import * as Dialog from "@radix-ui/react-dialog";
7
+ // import * as Select from "@radix-ui/react-select";
8
+ // export { Dialog, Select };
9
+ //
10
+ // With code splitting, every route that imports anything from the barrel
11
+ // reaches every package it re-exports, so the bundler puts all of them in
12
+ // one shared chunk that loads on every such route (55 KB gzip of Radix on
13
+ // the Veritas home screen, which uses one Radix component). Rewriting
14
+ //
15
+ // import { Dialog as SheetPrimitive } from "radix-ui";
16
+ //
17
+ // in app source to
18
+ //
19
+ // import * as SheetPrimitive from "@radix-ui/react-dialog";
20
+ //
21
+ // lets each route reach only the packages it uses, so the splitter keeps
22
+ // the rest out of its chunks. The client bundler applies this to app files
23
+ // (not node_modules) for every bare import whose entry is a barrel.
24
+
25
+ /**
26
+ * The namespaces a barrel entry re-exports (exported name → module
27
+ * specifier), or null when `source` is not a pure namespace barrel: any
28
+ * statement other than `import * as X from "..."` and `export { ... }` of
29
+ * those names makes it null.
30
+ */
31
+ export function parseNamespaceBarrel(source: string): Map<string, string> | null {
32
+ const code = source
33
+ .replace(/\/\*[\s\S]*?\*\//g, "")
34
+ .replace(/^\s*\/\/.*$/gm, "")
35
+ .replace(/["']use client["'];?/g, "");
36
+ const imports = new Map<string, string>();
37
+ let rest = code.replace(
38
+ /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+(["'])([^"']+)\2\s*;?/g,
39
+ (_m, local: string, _q: string, spec: string) => {
40
+ imports.set(local, spec);
41
+ return "";
42
+ },
43
+ );
44
+ const exported = new Map<string, string>();
45
+ let ok = true;
46
+ rest = rest.replace(/export\s*\{([^}]*)\}\s*;?/g, (_m, list: string) => {
47
+ for (const item of list.split(",")) {
48
+ const part = item.trim();
49
+ if (!part) continue;
50
+ const m = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
51
+ const spec = m ? imports.get(m[1]!) : undefined;
52
+ if (!m || !spec) {
53
+ ok = false;
54
+ continue;
55
+ }
56
+ exported.set(m[2] ?? m[1]!, spec);
57
+ }
58
+ return "";
59
+ });
60
+ if (!ok || rest.trim() !== "" || exported.size === 0) return null;
61
+ return exported;
62
+ }
63
+
64
+ /**
65
+ * Rewrite `import { A, B as C } from "<barrel>"` in `source` to one
66
+ * namespace import per name, from `resolveTarget(spec)` (the specifier the
67
+ * importing file should use for the re-exported module). Type-only imports
68
+ * and names the barrel does not re-export stay on the barrel import.
69
+ * `barrels` maps a bare specifier to its parsed barrel. Returns the source
70
+ * unchanged when nothing applies.
71
+ */
72
+ export function rewriteBarrelImports(
73
+ source: string,
74
+ barrels: (specifier: string) => Map<string, string> | null,
75
+ resolveTarget: (barrel: string, spec: string) => string,
76
+ ): string {
77
+ return source.replace(
78
+ /import\s+(type\s+)?\{([^}]*)\}\s*from\s*(["'])([^"'./][^"']*)\3\s*;?/g,
79
+ (whole, typeOnly: string | undefined, list: string, quote: string, specifier: string) => {
80
+ if (typeOnly) return whole;
81
+ const map = barrels(specifier);
82
+ if (!map) return whole;
83
+ const kept: string[] = [];
84
+ const lines: string[] = [];
85
+ for (const item of list.split(",")) {
86
+ const part = item.trim();
87
+ if (!part) continue;
88
+ const m = part.match(/^(type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
89
+ const target = m && !m[1] ? map.get(m[2]!) : undefined;
90
+ if (!m || !target) {
91
+ kept.push(part);
92
+ continue;
93
+ }
94
+ const local = m[3] ?? m[2]!;
95
+ lines.push(`import * as ${local} from ${quote}${resolveTarget(specifier, target)}${quote};`);
96
+ }
97
+ if (lines.length === 0) return whole;
98
+ if (kept.length > 0) {
99
+ lines.unshift(`import { ${kept.join(", ")} } from ${quote}${specifier}${quote};`);
100
+ }
101
+ return lines.join(" ");
102
+ },
103
+ );
104
+ }
@@ -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
+ });
@@ -956,12 +956,72 @@ describe("offline service worker (buildManifest({ offline }))", () => {
956
956
  expect(config.precache).not.toContain(prefix + manifest.routes["app/settings/page"].file);
957
957
  expect(config.precache).toContain("/icons/192.png");
958
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
+
959
966
  // The same build gives the same version: no reinstall per boot.
960
967
  const again = await buildClientBundle();
961
968
  const second = JSON.parse(fs.readFileSync(again.manifestPath, "utf8")) as PylonBundleManifest;
962
969
  expect(second.offline?.version).toBe(manifest.offline!.version);
963
970
  });
964
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
+
965
1025
  test("an app without offline gets no worker", async () => {
966
1026
  tempDir = makeFixture({ "page.tsx": PAGE_BODY("Home") }, { "layout.tsx": LAYOUT_BODY });
967
1027
  originalCwd = process.cwd();
@@ -972,3 +1032,103 @@ describe("offline service worker (buildManifest({ offline }))", () => {
972
1032
  expect(fs.existsSync(path.join(outdir, "pylon-sw.js"))).toBe(false);
973
1033
  });
974
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
+ });