@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/dist/index.d.ts +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +44 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +34 -5
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-runtime.d.ts +12 -8
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +36 -0
- package/src/offline-sw.ts +15 -2
- package/src/runtime-files.test.ts +55 -0
- package/src/runtime.ts +17 -4
- package/src/ssr-barrel-imports.test.ts +70 -0
- package/src/ssr-barrel-imports.ts +104 -0
- package/src/ssr-bucket-serverdata.test.ts +167 -0
- package/src/ssr-client-bundler.test.ts +160 -0
- package/src/ssr-client-bundler.ts +315 -73
- package/src/ssr-client-nav-runtime.fixture.ts +273 -0
- package/src/ssr-client-nav-runtime.test.ts +79 -0
- package/src/ssr-client-nav.test.ts +78 -0
- package/src/ssr-client-nav.ts +70 -0
- package/src/ssr-offline-shell.test.ts +7 -2
- package/src/ssr-offline.ts +114 -8
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-runtime.ts +45 -26
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
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
|
+
});
|