@pylonsync/functions 0.11.5 → 0.12.0
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/build-cli.d.ts +1 -0
- package/dist/build-compat.d.ts +81 -0
- package/dist/index.d.ts +1 -1
- package/dist/production-build.d.ts +47 -0
- package/dist/server-bundle.d.ts +31 -0
- package/dist/ssr-client-bundler.d.ts +39 -2
- package/dist/ssr-fonts.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +13 -1
- package/dist/stdout-fence.d.ts +18 -0
- package/dist/types.d.ts +42 -0
- package/package.json +8 -4
- package/src/build-cli.ts +36 -0
- package/src/build-compat.test.ts +173 -0
- package/src/build-compat.ts +430 -0
- package/src/index.ts +1 -0
- package/src/production-build.test.ts +269 -0
- package/src/production-build.ts +577 -0
- package/src/runtime-shard-ticket.test.ts +87 -0
- package/src/runtime.ts +64 -82
- package/src/server-bundle.test.ts +89 -0
- package/src/server-bundle.ts +63 -0
- package/src/ssr-client-bundler.test.ts +51 -0
- package/src/ssr-client-bundler.ts +150 -21
- package/src/ssr-fonts.ts +1 -1
- package/src/ssr-og-runtime.ts +17 -5
- package/src/ssr-runtime.ts +112 -47
- package/src/stdout-fence.ts +69 -0
- package/src/types.ts +42 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// `pylon build` artifact (production-build.ts).
|
|
2
|
+
//
|
|
3
|
+
// The end-to-end test builds a fixture app, moves the artifact to a
|
|
4
|
+
// directory with no `node_modules` anywhere above it, and starts the bundled
|
|
5
|
+
// runner: it must register the app's functions from the bundle alone. The
|
|
6
|
+
// CLI-level smoke test (tools/smoke-production-build.sh) covers serving
|
|
7
|
+
// pages through `pylon start <dir>`.
|
|
8
|
+
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
BUILD_INFO_FILE,
|
|
16
|
+
buildProduction,
|
|
17
|
+
checkInclude,
|
|
18
|
+
collectAppModules,
|
|
19
|
+
copyPackageClosure,
|
|
20
|
+
} from "./production-build";
|
|
21
|
+
|
|
22
|
+
const PKG_DIR = path.resolve(import.meta.dir, "..");
|
|
23
|
+
const dirs: string[] = [];
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A directory inside this package, so fixture imports of `react` resolve
|
|
29
|
+
* from the workspace `node_modules`. With `withDeps`, its `node_modules`
|
|
30
|
+
* links the packages of examples/ssr-hello, which has `@pylonsync/react`
|
|
31
|
+
* (the client runtime imports it), as in ssr-client-bundler.test.ts. */
|
|
32
|
+
function fixtureDir(files: Record<string, string>, withDeps = false): string {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(PKG_DIR, ".prod-build-test-"));
|
|
34
|
+
dirs.push(dir);
|
|
35
|
+
if (withDeps) {
|
|
36
|
+
const deps = path.resolve(PKG_DIR, "..", "..", "examples", "ssr-hello", "node_modules");
|
|
37
|
+
if (!fs.existsSync(deps)) {
|
|
38
|
+
throw new Error(`fixture deps missing at ${deps}; run \`bun install\` in examples/ssr-hello`);
|
|
39
|
+
}
|
|
40
|
+
// A real node_modules that links each shared package, so a test can
|
|
41
|
+
// add its own packages next to them.
|
|
42
|
+
fs.mkdirSync(path.join(dir, "node_modules"));
|
|
43
|
+
for (const name of fs.readdirSync(deps)) {
|
|
44
|
+
fs.symlinkSync(path.join(deps, name), path.join(dir, "node_modules", name));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
48
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
50
|
+
}
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PAGE = (name: string) => `import React from "react";
|
|
55
|
+
export default function ${name}() { return <p>${name}</p>; }
|
|
56
|
+
`;
|
|
57
|
+
const LAYOUT = `import React from "react";
|
|
58
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
59
|
+
return <html><body>{children}</body></html>;
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function manifest(routes: Array<{ path: string; component: string }>): string {
|
|
64
|
+
return JSON.stringify({
|
|
65
|
+
manifest_version: 1,
|
|
66
|
+
name: "fixture",
|
|
67
|
+
version: "0.0.1",
|
|
68
|
+
entities: [],
|
|
69
|
+
routes: routes.map((r) => ({ ...r, mode: "ssr", layouts: ["app/layout"] })),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Start `runner.js` and return its first protocol frame (the `ready`). */
|
|
74
|
+
async function readyFrame(runner: string, cwd: string): Promise<any> {
|
|
75
|
+
const proc = Bun.spawn(["bun", runner], {
|
|
76
|
+
cwd,
|
|
77
|
+
stdin: "pipe",
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
const reader = proc.stdout.getReader();
|
|
82
|
+
const decoder = new TextDecoder();
|
|
83
|
+
let buf = "";
|
|
84
|
+
const deadline = Date.now() + 15_000;
|
|
85
|
+
try {
|
|
86
|
+
while (!buf.includes("\n")) {
|
|
87
|
+
if (Date.now() > deadline) throw new Error("runner sent no frame");
|
|
88
|
+
const { value, done } = await reader.read();
|
|
89
|
+
if (done) {
|
|
90
|
+
const err = await new Response(proc.stderr).text();
|
|
91
|
+
throw new Error(`runner exited before ready:\n${err}`);
|
|
92
|
+
}
|
|
93
|
+
buf += decoder.decode(value, { stream: true });
|
|
94
|
+
}
|
|
95
|
+
return JSON.parse(buf.slice(0, buf.indexOf("\n")));
|
|
96
|
+
} finally {
|
|
97
|
+
proc.kill();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("buildProduction", () => {
|
|
102
|
+
test("the artifact runs its functions with no source tree or node_modules", async () => {
|
|
103
|
+
const cwd = fixtureDir({
|
|
104
|
+
"app/layout.tsx": LAYOUT,
|
|
105
|
+
"app/page.tsx": PAGE("Home"),
|
|
106
|
+
"app/(marketing)/about/page.tsx": PAGE("About"),
|
|
107
|
+
"app/not-found.tsx": PAGE("Missing"),
|
|
108
|
+
"app/components/card.tsx": PAGE("Card"),
|
|
109
|
+
"app/icon.png": "png-bytes",
|
|
110
|
+
// A function with no imports: default-exported definition shape.
|
|
111
|
+
"functions/hello.ts": `export default { type: "query", handler: async () => "hi" };`,
|
|
112
|
+
"functions/_helper.ts": `export const x = 1;`,
|
|
113
|
+
// Throws while loading. In source mode the runtime logs and skips it;
|
|
114
|
+
// the artifact must too, not fail every other function with it.
|
|
115
|
+
"functions/broken.ts": `throw new Error("boom at load");`,
|
|
116
|
+
// Imports an external package that prints on load. The stdout fence
|
|
117
|
+
// must be up first, or the line lands in the protocol stream.
|
|
118
|
+
"functions/noisy.ts": `import { tag } from "noisy-pkg";
|
|
119
|
+
export default { type: "query", handler: async () => tag };`,
|
|
120
|
+
"public/robots.txt": "User-agent: *\n",
|
|
121
|
+
"content/doc.md": "# doc\n",
|
|
122
|
+
"pylon.manifest.json": manifest([
|
|
123
|
+
{ path: "/", component: "app/page" },
|
|
124
|
+
{ path: "/about", component: "app/(marketing)/about/page" },
|
|
125
|
+
]),
|
|
126
|
+
}, true);
|
|
127
|
+
// Add the build block the SDK would write.
|
|
128
|
+
const m = JSON.parse(fs.readFileSync(path.join(cwd, "pylon.manifest.json"), "utf8"));
|
|
129
|
+
m.build = { include: ["content"], server: { external: ["noisy-pkg"] } };
|
|
130
|
+
fs.writeFileSync(path.join(cwd, "pylon.manifest.json"), JSON.stringify(m));
|
|
131
|
+
|
|
132
|
+
const noisy = path.join(cwd, "node_modules", "noisy-pkg");
|
|
133
|
+
fs.mkdirSync(noisy);
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
path.join(noisy, "package.json"),
|
|
136
|
+
JSON.stringify({ name: "noisy-pkg", version: "1.0.0", main: "index.js" }),
|
|
137
|
+
);
|
|
138
|
+
fs.writeFileSync(
|
|
139
|
+
path.join(noisy, "index.js"),
|
|
140
|
+
`console.log("noisy-pkg loaded"); exports.tag = "noisy";`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const outDir = path.join(cwd, "dist");
|
|
144
|
+
const res = await buildProduction({ cwd, outDir });
|
|
145
|
+
expect(res.clientRoutes).toBe(3);
|
|
146
|
+
expect(res.functions).toBe(4);
|
|
147
|
+
expect(res.externals).toEqual(["noisy-pkg"]);
|
|
148
|
+
|
|
149
|
+
const info = JSON.parse(fs.readFileSync(path.join(outDir, BUILD_INFO_FILE), "utf8"));
|
|
150
|
+
expect(info.server).toBe("server/runner.js");
|
|
151
|
+
expect(info.client).toBe("client");
|
|
152
|
+
const clientManifest = JSON.parse(
|
|
153
|
+
fs.readFileSync(path.join(outDir, "client", "manifest.json"), "utf8"),
|
|
154
|
+
);
|
|
155
|
+
expect(clientManifest.outdir).toBe("client");
|
|
156
|
+
expect(Object.keys(clientManifest.routes).sort()).toEqual([
|
|
157
|
+
"app/(marketing)/about/page",
|
|
158
|
+
"app/not-found",
|
|
159
|
+
"app/page",
|
|
160
|
+
]);
|
|
161
|
+
expect(fs.existsSync(path.join(outDir, "client", ".prebuilt"))).toBe(true);
|
|
162
|
+
expect(fs.readFileSync(path.join(outDir, "public", "robots.txt"), "utf8")).toContain("User-agent");
|
|
163
|
+
expect(fs.existsSync(path.join(outDir, "app", "icon.png"))).toBe(true);
|
|
164
|
+
expect(fs.existsSync(path.join(outDir, "app", "page.tsx"))).toBe(false);
|
|
165
|
+
expect(fs.existsSync(path.join(outDir, "content", "doc.md"))).toBe(true);
|
|
166
|
+
// The build's staging files are gone.
|
|
167
|
+
expect(fs.existsSync(path.join(cwd, ".pylon", "server-entry"))).toBe(false);
|
|
168
|
+
|
|
169
|
+
// Move the artifact where nothing above it has node_modules.
|
|
170
|
+
const runDir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-artifact-run-"));
|
|
171
|
+
dirs.push(runDir);
|
|
172
|
+
fs.cpSync(outDir, path.join(runDir, "dist"), { recursive: true });
|
|
173
|
+
const artifact = path.join(runDir, "dist");
|
|
174
|
+
// readyFrame parses the FIRST stdout line: a stray package log fails it.
|
|
175
|
+
const ready = await readyFrame(path.join(artifact, "server", "runner.js"), artifact);
|
|
176
|
+
expect(ready.type).toBe("ready");
|
|
177
|
+
// `__`-prefixed names are the runtime's built-in functions.
|
|
178
|
+
const appFns = ready.functions
|
|
179
|
+
.map((f: any) => f.name)
|
|
180
|
+
.filter((n: string) => !n.startsWith("__"))
|
|
181
|
+
.sort();
|
|
182
|
+
expect(appFns).toEqual(["hello", "noisy"]);
|
|
183
|
+
}, 60_000);
|
|
184
|
+
|
|
185
|
+
test("refuses to delete a directory that is not an earlier artifact", async () => {
|
|
186
|
+
const cwd = fixtureDir({
|
|
187
|
+
"functions/hello.ts": `export default { type: "query", handler: async () => "hi" };`,
|
|
188
|
+
"pylon.manifest.json": manifest([]),
|
|
189
|
+
"out/notes.txt": "keep me",
|
|
190
|
+
});
|
|
191
|
+
await expect(buildProduction({ cwd, outDir: path.join(cwd, "out") })).rejects.toThrow(
|
|
192
|
+
"not empty",
|
|
193
|
+
);
|
|
194
|
+
expect(fs.readFileSync(path.join(cwd, "out", "notes.txt"), "utf8")).toBe("keep me");
|
|
195
|
+
await expect(buildProduction({ cwd, outDir: cwd })).rejects.toThrow("project directory");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("collectAppModules keeps route modules and skips other components", () => {
|
|
200
|
+
const cwd = fixtureDir({
|
|
201
|
+
"app/page.tsx": "",
|
|
202
|
+
"app/page.js": "",
|
|
203
|
+
"app/(g)/x/layout.ts": "",
|
|
204
|
+
"app/blog/[slug]/opengraph-image.tsx": "",
|
|
205
|
+
"app/sitemap.ts": "",
|
|
206
|
+
"app/components/button.tsx": "",
|
|
207
|
+
"app/page.test.tsx": "",
|
|
208
|
+
});
|
|
209
|
+
const keys = collectAppModules(fs, path, cwd, "app", { routes: [] }).map((m) => m.key);
|
|
210
|
+
expect(keys).toEqual([
|
|
211
|
+
"app/(g)/x/layout",
|
|
212
|
+
"app/blog/[slug]/opengraph-image",
|
|
213
|
+
"app/page",
|
|
214
|
+
"app/sitemap",
|
|
215
|
+
]);
|
|
216
|
+
const page = collectAppModules(fs, path, cwd, "app", { routes: [] }).find(
|
|
217
|
+
(m) => m.key === "app/page",
|
|
218
|
+
)!;
|
|
219
|
+
expect(page.file.endsWith("page.tsx")).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("copyPackageClosure copies peers and nests a second version under its dependent", () => {
|
|
223
|
+
const pkg = (name: string, version: string, deps: Record<string, string> = {}) =>
|
|
224
|
+
JSON.stringify({ name, version, dependencies: deps });
|
|
225
|
+
const cwd = fixtureDir({
|
|
226
|
+
"node_modules/a/package.json": JSON.stringify({
|
|
227
|
+
name: "a",
|
|
228
|
+
version: "1.0.0",
|
|
229
|
+
dependencies: { b: "^1", c: "^2" },
|
|
230
|
+
peerDependencies: { p: "*", q: "*" },
|
|
231
|
+
peerDependenciesMeta: { q: { optional: true } },
|
|
232
|
+
}),
|
|
233
|
+
"node_modules/p/package.json": pkg("p", "1.0.0"),
|
|
234
|
+
"node_modules/a/index.js": "",
|
|
235
|
+
"node_modules/b/package.json": pkg("b", "1.0.0", { c: "^1" }),
|
|
236
|
+
"node_modules/b/node_modules/c/package.json": pkg("c", "1.0.0"),
|
|
237
|
+
"node_modules/c/package.json": pkg("c", "2.0.0"),
|
|
238
|
+
});
|
|
239
|
+
const dest = path.join(cwd, "out", "node_modules");
|
|
240
|
+
const names = copyPackageClosure(fs, path, cwd, ["a"], dest);
|
|
241
|
+
// The required peer ships; the optional peer `q` is not installed.
|
|
242
|
+
expect(names).toEqual(["a", "b", "c", "p"]);
|
|
243
|
+
const ver = (p: string) => JSON.parse(fs.readFileSync(path.join(dest, p, "package.json"), "utf8")).version;
|
|
244
|
+
expect(ver("c")).toBe("2.0.0");
|
|
245
|
+
expect(ver("b/node_modules/c")).toBe("1.0.0");
|
|
246
|
+
expect(() => copyPackageClosure(fs, path, cwd, ["missing"], path.join(cwd, "o2"))).toThrow(
|
|
247
|
+
"not installed",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("checkInclude allows project files and refuses the rest", () => {
|
|
252
|
+
const cwd = path.join(os.tmpdir(), "proj");
|
|
253
|
+
const out = path.join(cwd, "dist");
|
|
254
|
+
const check = (inc: string) => checkInclude(path, cwd, out, inc, path.resolve(cwd, inc));
|
|
255
|
+
expect(check("content")).toBe("content");
|
|
256
|
+
expect(check("..data")).toBe("..data");
|
|
257
|
+
expect(check("data/seed.json")).toBe(path.join("data", "seed.json"));
|
|
258
|
+
expect(() => check(".")).toThrow("is the project directory");
|
|
259
|
+
expect(() => check("../elsewhere")).toThrow("outside the project");
|
|
260
|
+
expect(() => check("/etc")).toThrow("outside the project");
|
|
261
|
+
expect(() => check("dist")).toThrow("overlaps the output");
|
|
262
|
+
expect(() => check("dist/x")).toThrow("overlaps the output");
|
|
263
|
+
expect(() => check("server")).toThrow('own "server"');
|
|
264
|
+
expect(() => check("pylon.manifest.json")).toThrow("own");
|
|
265
|
+
// An output dir inside an included dir.
|
|
266
|
+
expect(() =>
|
|
267
|
+
checkInclude(path, cwd, path.join(cwd, "build", "out"), "build", path.join(cwd, "build")),
|
|
268
|
+
).toThrow("overlaps the output");
|
|
269
|
+
});
|