@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.
@@ -0,0 +1,577 @@
1
+ // `pylon build` — the production artifact.
2
+ //
3
+ // The Rust CLI evaluates app.ts into `pylon.manifest.json`, then runs this
4
+ // module (through build-cli.ts) to write a directory that `pylon start <dir>`
5
+ // runs with no source tree and no `node_modules`:
6
+ //
7
+ // <out>/
8
+ // pylon-build.json build info; `pylon start` detects an artifact by it
9
+ // pylon.manifest.json the app manifest
10
+ // server/runner.js entry: installs the stdout fence, loads the rest
11
+ // server/chunks/ functions, SSR runtime, and app modules
12
+ // server/assets/ files the server reads at run time (OG image wasm, fonts)
13
+ // server/node_modules/ only the packages listed in build.server.external
14
+ // client/ hydration bundle (served under /_pylon/build/)
15
+ // public/ copy of public/
16
+ // app/ non-code files under app/ (icons, social images)
17
+ //
18
+ // The Rust CLI adds `static/` (static routes), `web/dist` (a web/ SPA), and
19
+ // studio files after this returns.
20
+
21
+ import { _doBuildInner, CLIENT_BUILD_DIR } from "./ssr-client-bundler";
22
+ import type { ManifestBuildConfig } from "./build-compat";
23
+
24
+ declare const Bun: {
25
+ resolveSync(specifier: string, from: string): string;
26
+ build(options: any): Promise<any>;
27
+ };
28
+
29
+ /** File name of the build info file. Its presence marks an artifact dir. */
30
+ export const BUILD_INFO_FILE = "pylon-build.json";
31
+
32
+ /** App modules the SSR runtime imports by convention, by base name. */
33
+ const APP_MODULE_NAMES = new Set([
34
+ "page",
35
+ "layout",
36
+ "not-found",
37
+ "error",
38
+ "loading",
39
+ "route",
40
+ "opengraph-image",
41
+ "sitemap",
42
+ "robots",
43
+ "llms",
44
+ ]);
45
+ const CODE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
46
+ /** Non-code files under app/ that the runtime reads from disk. */
47
+ const APP_ASSET_EXTS = new Set([
48
+ ".png",
49
+ ".jpg",
50
+ ".jpeg",
51
+ ".webp",
52
+ ".gif",
53
+ ".avif",
54
+ ".svg",
55
+ ".ico",
56
+ ".txt",
57
+ ".xml",
58
+ ".webmanifest",
59
+ ]);
60
+
61
+ export interface ProductionBuildOptions {
62
+ /** Project root. */
63
+ cwd: string;
64
+ /** Absolute output directory. */
65
+ outDir: string;
66
+ /** App (routes) directory, relative to cwd. Default `app`. */
67
+ appDir?: string;
68
+ /** Functions directory, relative to cwd. Default `functions`. */
69
+ functionsDir?: string;
70
+ /** The app manifest. Default `<cwd>/pylon.manifest.json`. */
71
+ manifestPath?: string;
72
+ }
73
+
74
+ export interface ProductionBuildResult {
75
+ outDir: string;
76
+ functions: number;
77
+ workflows: number;
78
+ appModules: number;
79
+ clientRoutes: number;
80
+ externals: string[];
81
+ polyfills: boolean;
82
+ }
83
+
84
+ export async function buildProduction(
85
+ opts: ProductionBuildOptions,
86
+ ): Promise<ProductionBuildResult> {
87
+ const fs: any = await import("node:fs");
88
+ const path: any = await import("node:path");
89
+ const url: any = await import("node:url");
90
+ const { cwd, outDir } = opts;
91
+ const appDir = opts.appDir ?? "app";
92
+ const functionsDir = opts.functionsDir ?? "functions";
93
+
94
+ const manifestPath = opts.manifestPath ?? path.join(cwd, "pylon.manifest.json");
95
+ if (!fs.existsSync(manifestPath)) {
96
+ throw new Error(`${manifestPath} not found. Run \`pylon build\`, which writes it first.`);
97
+ }
98
+ const manifestRaw = fs.readFileSync(manifestPath, "utf8");
99
+ const manifest = JSON.parse(manifestRaw);
100
+ const build: ManifestBuildConfig = manifest.build ?? {};
101
+
102
+ prepareOutDir(fs, path, cwd, outDir);
103
+
104
+ // ---- client --------------------------------------------------------------
105
+ const appAbs = path.join(cwd, appDir);
106
+ const hasApp = fs.existsSync(appAbs) && fs.statSync(appAbs).isDirectory();
107
+ let clientRoutes = 0;
108
+ let polyfills = false;
109
+ const clientOut = path.join(outDir, "client");
110
+ if (hasApp && hasPageModules(fs, path, appAbs)) {
111
+ const { manifestPath: clientManifest } = await _doBuildInner(fs, path, cwd, appDir, {
112
+ outdir: clientOut,
113
+ manifestOutdir: "client",
114
+ buildConfig: build,
115
+ strict: true,
116
+ manifestPath,
117
+ });
118
+ const cm = JSON.parse(fs.readFileSync(clientManifest, "utf8"));
119
+ clientRoutes = Object.keys(cm.routes ?? {}).length;
120
+ polyfills = typeof cm.polyfills === "string";
121
+ // `pylon start` serves this bundle as-is and never rebuilds it.
122
+ fs.writeFileSync(path.join(clientOut, ".prebuilt"), "", "utf8");
123
+ }
124
+
125
+ // ---- server --------------------------------------------------------------
126
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
127
+ const functions = listModules(fs, path, path.join(cwd, functionsDir));
128
+ const workflows = listModules(fs, path, path.join(cwd, "workflows"));
129
+ const appModules = hasApp ? collectAppModules(fs, path, cwd, appDir, manifest) : [];
130
+
131
+ const stage = path.join(cwd, ".pylon", "server-entry");
132
+ fs.rmSync(stage, { recursive: true, force: true });
133
+ fs.mkdirSync(stage, { recursive: true });
134
+ const spec = (abs: string) => JSON.stringify(abs.replace(/\\/g, "/"));
135
+
136
+ const resvgWasm = Bun.resolveSync("@resvg/resvg-wasm/index_bg.wasm", here);
137
+ const fontsDir = path.join(here, "..", "assets", "fonts");
138
+
139
+ fs.writeFileSync(
140
+ path.join(stage, "registry.ts"),
141
+ generateRegistry({
142
+ spec,
143
+ path,
144
+ serverBundleModule: path.join(here, "server-bundle.ts"),
145
+ functions,
146
+ workflows,
147
+ appModules: appModules.map((m) => ({ key: m.key, file: m.file })),
148
+ clientDir: hasApp && clientRoutes > 0 ? "client" : CLIENT_BUILD_DIR,
149
+ resvgWasm,
150
+ interRegular: path.join(fontsDir, "Inter-Regular.ttf"),
151
+ interSemiBold: path.join(fontsDir, "Inter-SemiBold.ttf"),
152
+ }),
153
+ "utf8",
154
+ );
155
+ // Two stages, joined by dynamic imports so the order holds however Bun
156
+ // splits the chunks (a static import is hoisted above the importing
157
+ // module's own code):
158
+ // 1. runner.ts installs the stdout fence, then loads main.ts. Everything
159
+ // main.ts reaches, including the static imports of external packages,
160
+ // is in later chunks, so nothing a package prints at load time can
161
+ // reach the protocol stream.
162
+ // 2. main.ts sets the registry, then loads the runtime, whose top-level
163
+ // main() reads it.
164
+ fs.writeFileSync(
165
+ path.join(stage, "main.ts"),
166
+ [
167
+ "// Generated by `pylon build`.",
168
+ `import "./registry.ts";`,
169
+ `await import(${spec(path.join(here, "runtime.ts"))});`,
170
+ "",
171
+ ].join("\n"),
172
+ "utf8",
173
+ );
174
+ const entry = path.join(stage, "runner.ts");
175
+ fs.writeFileSync(
176
+ entry,
177
+ [
178
+ "// Generated by `pylon build`.",
179
+ `import { fenceStdout } from ${spec(path.join(here, "stdout-fence.ts"))};`,
180
+ "fenceStdout();",
181
+ `await import("./main.ts");`,
182
+ "",
183
+ ].join("\n"),
184
+ "utf8",
185
+ );
186
+
187
+ const serverOut = path.join(outDir, "server");
188
+ const bundleAll = build.server?.bundle !== false;
189
+ const externals = bundleAll ? build.server?.external ?? [] : [];
190
+ const result = await Bun.build({
191
+ entrypoints: [entry],
192
+ outdir: serverOut,
193
+ root: cwd,
194
+ target: "bun",
195
+ format: "esm",
196
+ // Keep identifiers: server stack traces stay readable.
197
+ minify: { whitespace: true, syntax: true },
198
+ sourcemap: build.sourcemap === true ? "linked" : "none",
199
+ splitting: true,
200
+ // `pkg/*` keeps subpath imports (`pkg/server`) external too.
201
+ ...(bundleAll
202
+ ? { external: externals.flatMap((e) => [e, `${e}/*`]) }
203
+ : { packages: "external" }),
204
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
205
+ naming: {
206
+ entry: "[name].js",
207
+ chunk: "chunks/[name]-[hash].js",
208
+ asset: "assets/[name]-[hash].[ext]",
209
+ },
210
+ });
211
+ if (!result.success) {
212
+ const msgs = (result.logs ?? [])
213
+ .map((l: any) => `${l.level}: ${l.message}`)
214
+ .join("\n");
215
+ throw new Error(`server bundle failed:\n${msgs || "(no log messages)"}`);
216
+ }
217
+
218
+ // Packages left out of the bundle ship in server/node_modules with their
219
+ // dependencies. `server.bundle: false` leaves out every package.
220
+ const externalRoots = bundleAll
221
+ ? externals
222
+ : Object.keys(readPackageJson(fs, path, cwd)?.dependencies ?? {});
223
+ const copied = copyPackageClosure(fs, path, cwd, externalRoots, path.join(serverOut, "node_modules"));
224
+
225
+ // ---- static files ----------------------------------------------------------
226
+ const publicDir = path.join(cwd, "public");
227
+ if (fs.existsSync(publicDir)) {
228
+ fs.cpSync(publicDir, path.join(outDir, "public"), { recursive: true, dereference: true });
229
+ }
230
+ if (hasApp) copyAppAssets(fs, path, appAbs, path.join(outDir, appDir));
231
+ for (const inc of build.include ?? []) {
232
+ const src = path.resolve(cwd, inc);
233
+ const rel = checkInclude(path, cwd, outDir, inc, src);
234
+ if (!fs.existsSync(src)) throw new Error(`build.include: "${inc}" does not exist`);
235
+ fs.cpSync(src, path.join(outDir, rel), { recursive: true, dereference: true });
236
+ }
237
+
238
+ fs.writeFileSync(path.join(outDir, "pylon.manifest.json"), manifestRaw, "utf8");
239
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
240
+ fs.writeFileSync(
241
+ path.join(outDir, BUILD_INFO_FILE),
242
+ JSON.stringify(
243
+ {
244
+ format: 1,
245
+ app: manifest.name,
246
+ appVersion: manifest.version,
247
+ pylonFunctions: pkg.version,
248
+ builtAt: new Date().toISOString(),
249
+ server: "server/runner.js",
250
+ client: clientRoutes > 0 ? "client" : null,
251
+ target: build.target ?? null,
252
+ polyfill: build.polyfill ?? false,
253
+ externals: copied,
254
+ },
255
+ null,
256
+ 2,
257
+ ) + "\n",
258
+ "utf8",
259
+ );
260
+ fs.rmSync(stage, { recursive: true, force: true });
261
+
262
+ return {
263
+ outDir,
264
+ functions: functions.length,
265
+ workflows: workflows.length,
266
+ appModules: appModules.length,
267
+ clientRoutes,
268
+ externals: copied,
269
+ polyfills,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Empty the output directory. Refuses a directory that is the project root,
275
+ * contains it, or holds files and is not an earlier artifact: that is someone
276
+ * else's data.
277
+ */
278
+ function prepareOutDir(fs: any, path: any, cwd: string, outDir: string): void {
279
+ // `cwd` relative to `outDir`: "" or a path without a leading ".." means
280
+ // `outDir` is the project dir or an ancestor of it. On Windows, another
281
+ // drive gives an absolute path, which is neither.
282
+ const rel = path.relative(outDir, cwd);
283
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
284
+ throw new Error(`--out ${outDir} is the project directory or contains it`);
285
+ }
286
+ if (fs.existsSync(outDir)) {
287
+ const entries = fs.readdirSync(outDir);
288
+ if (entries.length > 0 && !entries.includes(BUILD_INFO_FILE)) {
289
+ throw new Error(
290
+ `${outDir} is not empty and holds no ${BUILD_INFO_FILE}, so it is not an ` +
291
+ `earlier build artifact. Remove it or choose another --out directory. ` +
292
+ `(A pylon build before 0.12 wrote static pages there; they now go to static/ inside the artifact.)`,
293
+ );
294
+ }
295
+ fs.rmSync(outDir, { recursive: true, force: true });
296
+ }
297
+ fs.mkdirSync(outDir, { recursive: true });
298
+ }
299
+
300
+ /** Top-level artifact names that `build.include` must not write into. */
301
+ const RESERVED_INCLUDE_NAMES = new Set([
302
+ BUILD_INFO_FILE,
303
+ "pylon.manifest.json",
304
+ "server",
305
+ "client",
306
+ "static",
307
+ "web",
308
+ "bin",
309
+ ".pylon",
310
+ ]);
311
+
312
+ /** The artifact-relative path for one `build.include` entry. Throws when the
313
+ * entry cannot be copied into the artifact as-is. */
314
+ export function checkInclude(
315
+ path: any,
316
+ cwd: string,
317
+ outDir: string,
318
+ inc: string,
319
+ src: string,
320
+ ): string {
321
+ const rel = path.relative(cwd, src);
322
+ const isOutside = (r: string) =>
323
+ r === ".." || r.startsWith(`..${path.sep}`) || r.startsWith("../") || path.isAbsolute(r);
324
+ if (rel === "") throw new Error(`build.include: "${inc}" is the project directory`);
325
+ if (isOutside(rel)) {
326
+ throw new Error(`build.include: "${inc}" is outside the project directory`);
327
+ }
328
+ // Neither the output directory nor anything that contains it or is in it.
329
+ if (!isOutside(path.relative(src, outDir)) || !isOutside(path.relative(outDir, src))) {
330
+ throw new Error(`build.include: "${inc}" overlaps the output directory`);
331
+ }
332
+ const top = rel.split(/[\\/]/)[0];
333
+ if (RESERVED_INCLUDE_NAMES.has(top)) {
334
+ throw new Error(`build.include: "${inc}" would write into the artifact's own "${top}"`);
335
+ }
336
+ return rel;
337
+ }
338
+
339
+ function hasPageModules(fs: any, path: any, dir: string): boolean {
340
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
341
+ if (e.isDirectory()) {
342
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
343
+ if (hasPageModules(fs, path, path.join(dir, e.name))) return true;
344
+ } else if (CODE_EXTS.some((x) => e.name === `page${x}`)) {
345
+ return true;
346
+ }
347
+ }
348
+ return false;
349
+ }
350
+
351
+ /** `.ts` / `.js` files directly in `dir`, as the runtime loads them. */
352
+ function listModules(fs: any, path: any, dir: string): Array<{ name: string; file: string }> {
353
+ if (!fs.existsSync(dir)) return [];
354
+ return fs
355
+ .readdirSync(dir)
356
+ .filter((f: string) => f.endsWith(".ts") || f.endsWith(".js"))
357
+ .sort()
358
+ .map((f: string) => ({
359
+ name: path.basename(f, f.endsWith(".ts") ? ".ts" : ".js"),
360
+ file: path.join(dir, f),
361
+ }));
362
+ }
363
+
364
+ /**
365
+ * Every app module the SSR runtime can import: files named by convention
366
+ * anywhere under the app dir, plus every component and layout the manifest
367
+ * routes name. Keys are the runtime's module paths (`app/blog/page`).
368
+ */
369
+ export function collectAppModules(
370
+ fs: any,
371
+ path: any,
372
+ cwd: string,
373
+ appDir: string,
374
+ manifest: any,
375
+ ): Array<{ key: string; file: string }> {
376
+ const found = new Map<string, string>();
377
+ const keyOf = (abs: string) =>
378
+ path.relative(cwd, abs).replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "");
379
+ const walk = (dir: string) => {
380
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
381
+ const abs = path.join(dir, e.name);
382
+ if (e.isDirectory()) {
383
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
384
+ walk(abs);
385
+ continue;
386
+ }
387
+ const ext = CODE_EXTS.find((x) => e.name.endsWith(x));
388
+ if (!ext) continue;
389
+ const base = e.name.slice(0, -ext.length);
390
+ if (!APP_MODULE_NAMES.has(base)) continue;
391
+ const key = keyOf(abs);
392
+ // Same precedence as the runtime's extension probe: .tsx, .ts, .jsx, .js.
393
+ const prev = found.get(key);
394
+ if (!prev || CODE_EXTS.indexOf(ext) < CODE_EXTS.findIndex((x) => prev.endsWith(x))) {
395
+ found.set(key, abs);
396
+ }
397
+ }
398
+ };
399
+ walk(path.join(cwd, appDir));
400
+
401
+ const named = new Set<string>();
402
+ for (const r of manifest.routes ?? []) {
403
+ if (typeof r.component === "string") named.add(r.component);
404
+ for (const l of r.layouts ?? []) if (typeof l === "string") named.add(l);
405
+ }
406
+ for (const n of named) {
407
+ const key = n.replace(/\\/g, "/");
408
+ if (found.has(key)) continue;
409
+ const ext = CODE_EXTS.find((x) => fs.existsSync(path.join(cwd, `${key}${x}`)));
410
+ if (ext) found.set(key, path.join(cwd, `${key}${ext}`));
411
+ }
412
+ return [...found.entries()]
413
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
414
+ .map(([key, file]) => ({ key, file }));
415
+ }
416
+
417
+ function generateRegistry(args: {
418
+ spec: (abs: string) => string;
419
+ path: any;
420
+ serverBundleModule: string;
421
+ functions: Array<{ name: string; file: string }>;
422
+ workflows: Array<{ name: string; file: string }>;
423
+ appModules: Array<{ key: string; file: string }>;
424
+ clientDir: string;
425
+ resvgWasm: string;
426
+ interRegular: string;
427
+ interSemiBold: string;
428
+ }): string {
429
+ const { spec } = args;
430
+ const lines: string[] = [
431
+ "// Generated by `pylon build`. The production server's module registry.",
432
+ `import { setServerBundle } from ${spec(args.serverBundleModule)};`,
433
+ `import { isAbsolute } from "node:path";`,
434
+ `import { fileURLToPath } from "node:url";`,
435
+ `import * as React from "react";`,
436
+ `import * as ReactDomServer from "react-dom/server.browser";`,
437
+ `import resvgWasm from ${spec(args.resvgWasm)} with { type: "file" };`,
438
+ `import interRegular from ${spec(args.interRegular)} with { type: "file" };`,
439
+ `import interSemiBold from ${spec(args.interSemiBold)} with { type: "file" };`,
440
+ ];
441
+ const obj = (items: string[]) => (items.length ? `{\n${items.join("\n")}\n }` : "{}");
442
+ lines.push(
443
+ "",
444
+ "// A file import is a path relative to this bundle once bundled.",
445
+ "const assetPath = (p: string) =>",
446
+ " isAbsolute(p) ? p : fileURLToPath(new URL(p, import.meta.url));",
447
+ "",
448
+ "setServerBundle({",
449
+ ` functions: ${obj(args.functions.map((f) => ` ${JSON.stringify(f.name)}: () => import(${spec(f.file)}),`))},`,
450
+ ` workflows: ${obj(args.workflows.map((w) => ` ${JSON.stringify(w.name)}: () => import(${spec(w.file)}),`))},`,
451
+ ` modules: ${obj(args.appModules.map((m) => ` ${JSON.stringify(m.key)}: () => import(${spec(m.file)}),`))},`,
452
+ " react: React,",
453
+ " reactDomServer: ReactDomServer,",
454
+ ` clientDir: ${JSON.stringify(args.clientDir)},`,
455
+ " ogAssets: {",
456
+ " resvgWasm: assetPath(resvgWasm),",
457
+ " interRegular: assetPath(interRegular),",
458
+ " interSemiBold: assetPath(interSemiBold),",
459
+ " },",
460
+ "});",
461
+ "",
462
+ );
463
+ return lines.join("\n");
464
+ }
465
+
466
+ function readPackageJson(fs: any, path: any, dir: string): any {
467
+ const p = path.join(dir, "package.json");
468
+ if (!fs.existsSync(p)) return null;
469
+ return JSON.parse(fs.readFileSync(p, "utf8"));
470
+ }
471
+
472
+ /** The directory of an installed package, resolved from `fromDir` the way
473
+ * Node and Bun resolve: the nearest `node_modules/<name>` walking up. */
474
+ function findPackageDir(fs: any, path: any, name: string, fromDir: string): string | null {
475
+ let dir = fromDir;
476
+ for (;;) {
477
+ const candidate = path.join(dir, "node_modules", name);
478
+ if (fs.existsSync(path.join(candidate, "package.json"))) return fs.realpathSync(candidate);
479
+ const parent = path.dirname(dir);
480
+ if (parent === dir) return null;
481
+ dir = parent;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Copy `roots` and their dependency closure (dependencies, optional
487
+ * dependencies that are installed, and peers) into `dest` (a node_modules
488
+ * dir).
489
+ * Each package resolves its own dependencies from its install location, so
490
+ * npm, pnpm, and Bun layouts all work. A package goes to the top of `dest`
491
+ * unless another version already holds that name; then it nests under the
492
+ * dependent package's own `node_modules`, where run-time resolution finds it
493
+ * first. Returns the copied package names.
494
+ */
495
+ export function copyPackageClosure(
496
+ fs: any,
497
+ path: any,
498
+ cwd: string,
499
+ roots: string[],
500
+ dest: string,
501
+ ): string[] {
502
+ // Installed package dir → the dir it was copied to.
503
+ const copiedTo = new Map<string, string>();
504
+ // Top-level name in `dest` → the installed dir that holds it.
505
+ const topLevel = new Map<string, string>();
506
+ const names = new Set<string>();
507
+ type Item = { name: string; from: string; parentCopy: string | null; required: boolean };
508
+ const queue: Item[] = roots.map((name) => ({ name, from: cwd, parentCopy: null, required: true }));
509
+ while (queue.length > 0) {
510
+ const { name, from, parentCopy, required } = queue.shift()!;
511
+ const dir = findPackageDir(fs, path, name, from);
512
+ if (!dir) {
513
+ if (required) {
514
+ throw new Error(
515
+ `package "${name}" (needed by ${from === cwd ? "build.server" : from}) is not installed`,
516
+ );
517
+ }
518
+ continue; // an optional dependency that is not installed
519
+ }
520
+ let target: string;
521
+ const holder = topLevel.get(name);
522
+ if (holder === undefined) {
523
+ target = path.join(dest, name);
524
+ topLevel.set(name, dir);
525
+ } else if (holder === dir) {
526
+ continue; // this exact install is already at the top level
527
+ } else if (parentCopy !== null) {
528
+ target = path.join(parentCopy, "node_modules", name);
529
+ } else {
530
+ throw new Error(`two installs of "${name}" are both build roots`);
531
+ }
532
+ if (copiedTo.get(dir) === target || fs.existsSync(target)) continue;
533
+ copiedTo.set(dir, target);
534
+ names.add(name);
535
+ fs.cpSync(dir, target, {
536
+ recursive: true,
537
+ dereference: true,
538
+ filter: (src: string) =>
539
+ !src.slice(dir.length).split(/[\\/]/).includes("node_modules"),
540
+ });
541
+ const pkg = readPackageJson(fs, path, dir) ?? {};
542
+ for (const dep of Object.keys(pkg.dependencies ?? {})) {
543
+ queue.push({ name: dep, from: dir, parentCopy: target, required: true });
544
+ }
545
+ for (const dep of Object.keys(pkg.optionalDependencies ?? {})) {
546
+ queue.push({ name: dep, from: dir, parentCopy: target, required: false });
547
+ }
548
+ // A package requires its peers from its own location at run time, so
549
+ // they ship too. The copy is separate from any bundled copy of the same
550
+ // package (see the build docs on shared state).
551
+ const peerMeta = pkg.peerDependenciesMeta ?? {};
552
+ for (const dep of Object.keys(pkg.peerDependencies ?? {})) {
553
+ queue.push({
554
+ name: dep,
555
+ from: dir,
556
+ parentCopy: target,
557
+ required: peerMeta[dep]?.optional !== true,
558
+ });
559
+ }
560
+ }
561
+ return [...names].sort();
562
+ }
563
+
564
+ function copyAppAssets(fs: any, path: any, srcDir: string, destDir: string): void {
565
+ for (const e of fs.readdirSync(srcDir, { withFileTypes: true })) {
566
+ const src = path.join(srcDir, e.name);
567
+ if (e.isDirectory()) {
568
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
569
+ copyAppAssets(fs, path, src, path.join(destDir, e.name));
570
+ continue;
571
+ }
572
+ const ext = path.extname(e.name).toLowerCase();
573
+ if (!APP_ASSET_EXTS.has(ext)) continue;
574
+ fs.mkdirSync(destDir, { recursive: true });
575
+ fs.copyFileSync(src, path.join(destDir, e.name));
576
+ }
577
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `ctx.shards.ticket` — the frame the runtime sends to the host, and the
3
+ * ticket string it returns. The host signs (it holds the secret); this
4
+ * checks the TS half. Same child-process NDJSON harness as
5
+ * runtime-email.test.ts.
6
+ */
7
+ import { expect, test } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
13
+
14
+ function parseFrames(text: string): Record<string, unknown>[] {
15
+ return text
16
+ .split("\n")
17
+ .filter((l) => l.trim().startsWith("{"))
18
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
19
+ }
20
+
21
+ test("ctx.shards.ticket sends sign_shard_ticket and returns the host's ticket", async () => {
22
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-ticket-"));
23
+ mkdirSync(join(dir, "functions"));
24
+ writeFileSync(
25
+ join(dir, "functions", "join.ts"),
26
+ `export default {
27
+ type: "mutation",
28
+ handler: async (ctx) => {
29
+ const ticket = await ctx.shards.ticket("zone-3", {
30
+ subscriberId: "char_12",
31
+ claims: { realm: "north" },
32
+ ttlSecs: 30,
33
+ });
34
+ return { ticket };
35
+ },
36
+ };
37
+ `,
38
+ );
39
+ const proc = Bun.spawn([process.execPath, RUNTIME, "./functions"], {
40
+ cwd: dir,
41
+ stdin: "pipe",
42
+ stdout: "pipe",
43
+ stderr: "pipe",
44
+ });
45
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
46
+ const decoder = new TextDecoder();
47
+ let buffered = "";
48
+ const readUntil = async (pred: (f: Record<string, unknown>[]) => boolean) => {
49
+ let frames = parseFrames(buffered);
50
+ while (!pred(frames)) {
51
+ const { done, value } = await reader.read();
52
+ if (done) break;
53
+ buffered += decoder.decode(value, { stream: true });
54
+ frames = parseFrames(buffered);
55
+ }
56
+ return frames;
57
+ };
58
+ const send = (msg: Record<string, unknown>) =>
59
+ (proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
60
+
61
+ await readUntil((fs) => fs.some((f) => f.type === "ready"));
62
+ send({
63
+ type: "call",
64
+ call_id: "c_1",
65
+ fn_name: "join",
66
+ fn_type: "mutation",
67
+ args: {},
68
+ auth: { user_id: "u1", is_admin: false, tenant_id: null },
69
+ });
70
+
71
+ let frames = await readUntil((fs) => fs.some((f) => f.type === "sign_shard_ticket"));
72
+ expect(frames.find((f) => f.type === "sign_shard_ticket")).toMatchObject({
73
+ call_id: "c_1",
74
+ shard: "zone-3",
75
+ subscriber_id: "char_12",
76
+ claims: { realm: "north" },
77
+ ttl_secs: 30,
78
+ });
79
+ send({ type: "result", call_id: "c_1", data: "v1.payload.sig" });
80
+
81
+ frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
82
+ expect(frames.find((f) => f.type === "return")).toMatchObject({
83
+ call_id: "c_1",
84
+ value: { ticket: "v1.payload.sig" },
85
+ });
86
+ proc.kill();
87
+ });