@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/src/runtime.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  EmailOptions,
23
23
  EmailSender,
24
24
  Files,
25
+ Shards,
25
26
  Stream,
26
27
  Scheduler,
27
28
  Llm,
@@ -46,6 +47,8 @@ import { normalizeAuthClaims } from "./auth";
46
47
  import { makeRequireMember } from "./member";
47
48
  import { isDevMode } from "./ssr-runtime";
48
49
  import { validateArgs } from "./validators";
50
+ import { serverBundle } from "./server-bundle";
51
+ import { fenceStdout } from "./stdout-fence";
49
52
  import { readdirSync } from "fs";
50
53
  import { join, basename } from "path";
51
54
 
@@ -111,61 +114,6 @@ function send(msg: Record<string, unknown>): void {
111
114
  }
112
115
  }
113
116
 
114
- /**
115
- * Redirect console.* from user code to stderr so handlers can't accidentally
116
- * emit a line that looks like a protocol frame and confuse the Rust reader.
117
- *
118
- * Before this guard, a handler calling `console.log('{"type":"return",...}')`
119
- * — either intentionally or by logging an object shaped that way — would be
120
- * parsed by the host as a real protocol message. Moving all console output
121
- * to stderr keeps stdout reserved for NDJSON protocol frames only.
122
- *
123
- * The original console methods are saved on the console object as
124
- * `__stdoutLog` etc. in case the runtime itself needs to write diagnostics
125
- * to stdout for some reason (it currently doesn't).
126
- */
127
- function fenceStdout(): void {
128
- const toStderr = (prefix: string) => (...args: unknown[]) => {
129
- const line = args
130
- .map((a) => {
131
- if (typeof a === "string") return a;
132
- // Error: JSON.stringify yields `{}` because message/stack are
133
- // non-enumerable. That made `console.error("x:", err)` log as `x: {}`,
134
- // hiding the real failure from operators. Unwrap by hand.
135
- if (a instanceof Error) {
136
- const parts = [a.stack || `${a.name}: ${a.message}`];
137
- const code = (a as { code?: unknown }).code;
138
- if (code !== undefined) parts.push(`code=${String(code)}`);
139
- const cause = (a as { cause?: unknown }).cause;
140
- if (cause !== undefined) {
141
- try {
142
- parts.push(`cause=${cause instanceof Error ? cause.stack || cause.message : JSON.stringify(cause)}`);
143
- } catch {
144
- parts.push(`cause=${String(cause)}`);
145
- }
146
- }
147
- return parts.join(" ");
148
- }
149
- try {
150
- return JSON.stringify(a);
151
- } catch {
152
- return String(a);
153
- }
154
- })
155
- .join(" ");
156
- Bun.write(Bun.stderr, `${prefix}${line}\n`);
157
- };
158
- // Intentional: we want console.* for user handlers to go to stderr.
159
- // Overwrite the globals before any user code is loaded.
160
- const c = globalThis.console as unknown as Record<string, unknown>;
161
- c.__stdoutLog = c.log;
162
- c.log = toStderr("");
163
- c.info = toStderr("");
164
- c.warn = toStderr("[warn] ");
165
- c.error = toStderr("[error] ");
166
- c.debug = toStderr("[debug] ");
167
- }
168
-
169
117
  // ---------------------------------------------------------------------------
170
118
  // Single reader + dispatcher
171
119
  // ---------------------------------------------------------------------------
@@ -575,6 +523,21 @@ export function buildDbReader(callId: string, ssrRead = false): DbReader {
575
523
  * op_id) is correct here: concurrent calls queue settle-chained on the one
576
524
  * call_id, matching how ctx.runQuery behaves inside a function.
577
525
  */
526
+ /** `ctx.shards` — shard tickets, signed host-side. */
527
+ function buildShards(callId: string): Shards {
528
+ return {
529
+ async ticket(shardId, opts) {
530
+ return rpc(callId, {
531
+ type: "sign_shard_ticket",
532
+ shard: shardId,
533
+ subscriber_id: opts?.subscriberId,
534
+ claims: opts?.claims ?? {},
535
+ ttl_secs: opts?.ttlSecs,
536
+ }) as Promise<string>;
537
+ },
538
+ };
539
+ }
540
+
578
541
  /** `ctx.files` — signed download URLs, minted host-side. */
579
542
  function buildFiles(callId: string): Files {
580
543
  return {
@@ -1192,6 +1155,7 @@ function buildActionCtx(
1192
1155
  return err;
1193
1156
  },
1194
1157
  files: buildFiles(callId),
1158
+ shards: buildShards(callId),
1195
1159
  // Actions have no ctx.db; read membership via the built-in internal query.
1196
1160
  requireMember: makeRequireMember(auth.userId, (entity, filter) =>
1197
1161
  rpc(callId, {
@@ -1344,6 +1308,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1344
1308
  reader.query(entity, { ...filter, $limit: 1 }),
1345
1309
  ),
1346
1310
  files: buildFiles(msg.call_id),
1311
+ shards: buildShards(msg.call_id),
1347
1312
  };
1348
1313
  break;
1349
1314
  }
@@ -1368,6 +1333,7 @@ async function handleCall(msg: CallMessage): Promise<void> {
1368
1333
  writer.query(entity, { ...filter, $limit: 1 }),
1369
1334
  ),
1370
1335
  files: buildFiles(msg.call_id),
1336
+ shards: buildShards(msg.call_id),
1371
1337
  };
1372
1338
  break;
1373
1339
  }
@@ -1474,6 +1440,30 @@ function firstStackFrame(err: unknown): string {
1474
1440
  // Startup: scan functions dir, send ready, then start reader loop
1475
1441
  // ---------------------------------------------------------------------------
1476
1442
 
1443
+ /**
1444
+ * The `.ts` / `.js` modules directly inside `dir`, each with its name (file
1445
+ * name without extension) and a loader. A missing directory yields none: a
1446
+ * pure-SSR app has no `functions/`, and most apps have no `workflows/`. The
1447
+ * runner must still send `ready` and serve renders in that case.
1448
+ */
1449
+ function listModuleFiles(
1450
+ dir: string,
1451
+ ): Array<{ name: string; file: string; load: () => Promise<any> }> {
1452
+ let files: string[];
1453
+ try {
1454
+ files = readdirSync(dir).filter(
1455
+ (f) => f.endsWith(".ts") || f.endsWith(".js"),
1456
+ );
1457
+ } catch {
1458
+ return [];
1459
+ }
1460
+ return files.map((file) => ({
1461
+ name: basename(file, file.endsWith(".ts") ? ".ts" : ".js"),
1462
+ file,
1463
+ load: () => import(join(dir, file)),
1464
+ }));
1465
+ }
1466
+
1477
1467
  async function main() {
1478
1468
  // Fence user `console.*` away from stdout BEFORE any user code is
1479
1469
  // imported — the import side-effects alone could print a stray line
@@ -1481,27 +1471,21 @@ async function main() {
1481
1471
  fenceStdout();
1482
1472
 
1483
1473
  const fnDir = process.argv[2] || "./functions";
1474
+ const bundle = serverBundle();
1484
1475
 
1485
- let files: string[];
1486
- try {
1487
- files = readdirSync(fnDir).filter(
1488
- (f) => f.endsWith(".ts") || f.endsWith(".js")
1489
- );
1490
- } catch {
1491
- // No `functions/` directory. Legitimate for a pure-SSR app (file-based
1492
- // `app/**/page.tsx` routes + entity CRUD, no server functions) — the host
1493
- // still spawns this runner to execute SSR renders. Load zero functions and
1494
- // fall through so we send `ready` AND start the reader loop; returning here
1495
- // would leave the runner unable to serve renders (silent 404s).
1496
- files = [];
1497
- }
1476
+ const fnSources = bundle
1477
+ ? Object.keys(bundle.functions).map((name) => ({
1478
+ name,
1479
+ file: `${name} (bundled)`,
1480
+ load: bundle.functions[name],
1481
+ }))
1482
+ : listModuleFiles(join(process.cwd(), fnDir));
1498
1483
 
1499
1484
  const { isAgentDefinition, AGENT_MARKER } = await import("./agent");
1500
1485
  let agentsPresent = false;
1501
- for (const file of files) {
1502
- const name = basename(file, file.endsWith(".ts") ? ".ts" : ".js");
1486
+ for (const { name, file, load } of fnSources) {
1503
1487
  try {
1504
- const mod = await import(join(process.cwd(), fnDir, file));
1488
+ const mod = await load();
1505
1489
  const def = mod.default as FnDefinition | undefined;
1506
1490
  // Runtime shape check — a misnamed/malformed export should
1507
1491
  // log + skip, not crash the loader. TS narrows `def.handler`
@@ -1547,18 +1531,16 @@ async function main() {
1547
1531
  );
1548
1532
  type WorkflowDefinition = import("./workflows").WorkflowDefinition;
1549
1533
  const workflowRegistry = new Map<string, WorkflowDefinition>();
1550
- const wfDir = join(process.cwd(), "workflows");
1551
- let wfFiles: string[] = [];
1552
- try {
1553
- wfFiles = readdirSync(wfDir).filter(
1554
- (f) => f.endsWith(".ts") || f.endsWith(".js"),
1555
- );
1556
- } catch {
1557
- // No workflows/ directory — the common case; declare nothing.
1558
- }
1559
- for (const file of wfFiles) {
1534
+ const wfSources = bundle
1535
+ ? Object.keys(bundle.workflows).map((name) => ({
1536
+ name,
1537
+ file: `${name} (bundled)`,
1538
+ load: bundle.workflows[name],
1539
+ }))
1540
+ : listModuleFiles(join(process.cwd(), "workflows"));
1541
+ for (const { file, load } of wfSources) {
1560
1542
  try {
1561
- const mod = await import(join(wfDir, file));
1543
+ const mod = await load();
1562
1544
  const def = mod.default;
1563
1545
  if (isWorkflowDefinition(def)) {
1564
1546
  if (workflowRegistry.has(def.name)) {
@@ -0,0 +1,89 @@
1
+ // SSR module lookups in a production server bundle.
2
+ //
3
+ // An artifact has no app/ source files, so every lookup that checks the disk
4
+ // in source mode (boundary walk, route-group dirs, layout chain, module
5
+ // import) must answer from the bundle's module registry instead. These run
6
+ // the lookups with a registry set and a cwd that holds no files, and expect
7
+ // the same answers the source-mode tests get from a real tree.
8
+
9
+ import { afterAll, afterEach, 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 { moduleKey, setServerBundle, type PylonServerBundle } from "./server-bundle";
15
+ import { findBoundaryIn, importModule, moduleExistsIn } from "./ssr-runtime";
16
+
17
+ const EMPTY_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-bundle-lookup-"));
18
+
19
+ /** Set a registry with one loader per key. Returns the module namespaces the
20
+ * loaders resolve to, and how many times each loader ran. */
21
+ function withModules(keys: string[]): { ns: Record<string, any>; loads: Record<string, number> } {
22
+ const ns: Record<string, any> = {};
23
+ const loads: Record<string, number> = {};
24
+ const modules: PylonServerBundle["modules"] = {};
25
+ for (const k of keys) {
26
+ ns[k] = { default: () => k };
27
+ loads[k] = 0;
28
+ modules[k] = async () => {
29
+ loads[k] += 1;
30
+ return ns[k];
31
+ };
32
+ }
33
+ const bundle: PylonServerBundle = {
34
+ functions: {},
35
+ workflows: {},
36
+ modules,
37
+ react: null,
38
+ reactDomServer: null,
39
+ clientDir: "client",
40
+ ogAssets: { resvgWasm: "", interRegular: "", interSemiBold: "" },
41
+ };
42
+ setServerBundle(bundle);
43
+ return { ns, loads };
44
+ }
45
+
46
+ afterEach(() => {
47
+ delete (globalThis as any).__PYLON_SERVER_BUNDLE__;
48
+ });
49
+ afterAll(() => fs.rmSync(EMPTY_DIR, { recursive: true, force: true }));
50
+
51
+ test("moduleKey normalizes separators, a leading ./, and extensions", () => {
52
+ expect(moduleKey(".\\app\\blog\\page.tsx")).toBe("app/blog/page");
53
+ expect(moduleKey("app/page")).toBe("app/page");
54
+ });
55
+
56
+ test("moduleExistsIn answers from the registry, not the disk", () => {
57
+ withModules(["app/layout", "app/blog/page"]);
58
+ expect(moduleExistsIn(fs, path, EMPTY_DIR, "app", "layout")).toBe(true);
59
+ expect(moduleExistsIn(fs, path, EMPTY_DIR, "app/blog", "page")).toBe(true);
60
+ expect(moduleExistsIn(fs, path, EMPTY_DIR, "app/blog", "layout")).toBe(false);
61
+ });
62
+
63
+ test("the boundary walk finds the nearest boundary, through route groups", () => {
64
+ withModules([
65
+ "app/not-found",
66
+ "app/(shop)/cart/not-found",
67
+ "app/(shop)/cart/items/page",
68
+ "app/(marketing)/error",
69
+ "app/blog/page",
70
+ ]);
71
+ expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/(shop)/cart/items/page", "not-found")).toBe(
72
+ "app/(shop)/cart/not-found",
73
+ );
74
+ expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "not-found")).toBe("app/not-found");
75
+ // A group directly under app/ answers for "/" too.
76
+ expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "error")).toBe(
77
+ "app/(marketing)/error",
78
+ );
79
+ expect(findBoundaryIn(fs, path, EMPTY_DIR, "app/blog/page", "loading")).toBeNull();
80
+ });
81
+
82
+ test("importModule loads the bundled module on demand and names a missing one", async () => {
83
+ const { ns, loads } = withModules(["app/page", "app/layout"]);
84
+ expect(await importModule(EMPTY_DIR, "app/page")).toBe(ns["app/page"]);
85
+ // Lookups by key never evaluate a module; only an import does.
86
+ expect(moduleExistsIn(fs, path, EMPTY_DIR, "app", "layout")).toBe(true);
87
+ expect(loads).toEqual({ "app/page": 1, "app/layout": 0 });
88
+ await expect(importModule(EMPTY_DIR, "app/missing")).rejects.toThrow("not in the server bundle");
89
+ });
@@ -0,0 +1,63 @@
1
+ // The module registry of a production server bundle (`pylon build`).
2
+ //
3
+ // In source mode the runner imports app code by path at run time: function
4
+ // files from `functions/`, workflows from `workflows/`, and page / layout /
5
+ // boundary modules from `app/`. It also resolves `react` from the app's
6
+ // `node_modules`. A production bundle has none of those files. Its generated
7
+ // entry puts a loader for every module in this registry and then starts the
8
+ // runtime. Every loader in this package asks the registry first.
9
+ //
10
+ // The registry holds loaders, not modules, so each module is evaluated when
11
+ // it is first needed, as in source mode: the runtime loads each function
12
+ // with its own try/catch at boot, and a page loads on its first render. A
13
+ // module that throws at the top level fails alone.
14
+ //
15
+ // The entry sets the registry before it imports the runtime, whose top-level
16
+ // `main()` reads it.
17
+
18
+ /** Loads one bundled module and returns its namespace. */
19
+ export type ModuleLoader = () => Promise<any>;
20
+
21
+ export interface PylonServerBundle {
22
+ /** Function name (file name without extension) → module loader. */
23
+ functions: Record<string, ModuleLoader>;
24
+ /** Workflow file name (without extension) → module loader. */
25
+ workflows: Record<string, ModuleLoader>;
26
+ /** Project-relative, extension-less, "/"-separated module path
27
+ * (`app/blog/page`) → module loader. Holds every module under the app
28
+ * dir that the SSR runtime can import: pages, layouts, boundaries,
29
+ * loading states, route handlers, OG image modules, metadata routes. */
30
+ modules: Record<string, ModuleLoader>;
31
+ /** The app's React and React DOM server, as bundled with the pages, so SSR
32
+ * renders with the same React instance the page modules import. */
33
+ react: any;
34
+ reactDomServer: any;
35
+ /** Client bundle directory, relative to the artifact root. */
36
+ clientDir: string;
37
+ /** Absolute paths of the files the OG image renderer reads at run time.
38
+ * In source mode it finds them next to its own source file and in
39
+ * `node_modules`; the bundle copies them next to the server output. */
40
+ ogAssets: {
41
+ resvgWasm: string;
42
+ interRegular: string;
43
+ interSemiBold: string;
44
+ };
45
+ }
46
+
47
+ const KEY = "__PYLON_SERVER_BUNDLE__";
48
+
49
+ export function serverBundle(): PylonServerBundle | null {
50
+ return ((globalThis as any)[KEY] as PylonServerBundle | undefined) ?? null;
51
+ }
52
+
53
+ export function setServerBundle(bundle: PylonServerBundle): void {
54
+ (globalThis as any)[KEY] = bundle;
55
+ }
56
+
57
+ /** Normalize a module path to the registry key form. */
58
+ export function moduleKey(relPath: string): string {
59
+ return relPath
60
+ .replace(/\\/g, "/")
61
+ .replace(/^\.\//, "")
62
+ .replace(/\.(tsx?|jsx?)$/, "");
63
+ }
@@ -25,6 +25,8 @@ import {
25
25
  buildClientBundle,
26
26
  buildTailwind,
27
27
  assertNotServerOnly,
28
+ componentPathFor,
29
+ _doBuildInner,
28
30
  discoverLoadingModules,
29
31
  generateLoadingRegistry,
30
32
  type PylonBundleManifest,
@@ -234,6 +236,55 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
234
236
  }
235
237
  });
236
238
 
239
+ test("Windows separators: the build succeeds and every manifest path uses /", async () => {
240
+ // node:path on Windows returns "\"-separated relative paths. They used to
241
+ // reach the generated entries as `import Page from "../app\page"`, where
242
+ // `\p` is a string escape, so Bun.build failed with "Bundle failed" and
243
+ // every page shipped without hydration (#15).
244
+ tempDir = makeFixture(
245
+ {
246
+ "page.tsx": PAGE_BODY("Home"),
247
+ "blog/page.tsx": PAGE_BODY("Blog"),
248
+ "not-found.tsx": PAGE_BODY("Missing"),
249
+ "loading.tsx": LOADING_BODY,
250
+ },
251
+ { "layout.tsx": LAYOUT_BODY },
252
+ );
253
+ const winPath = {
254
+ ...path,
255
+ relative: (from: string, to: string) =>
256
+ path.relative(from, to).replace(/\//g, "\\"),
257
+ };
258
+
259
+ const { manifestPath } = await _doBuildInner(fs, winPath, tempDir, "app");
260
+ const manifest = JSON.parse(
261
+ fs.readFileSync(manifestPath, "utf8"),
262
+ ) as PylonBundleManifest;
263
+
264
+ expect(Object.keys(manifest.routes).sort()).toEqual([
265
+ "app/blog/page",
266
+ "app/not-found",
267
+ "app/page",
268
+ ]);
269
+ expect(manifest.outdir).toBe(".pylon/client-build");
270
+ for (const route of Object.values(manifest.routes)) {
271
+ expect(route.file).not.toContain("\\");
272
+ for (const imp of route.imports) expect(imp).not.toContain("\\");
273
+ }
274
+ const entry = fs.readFileSync(
275
+ path.join(tempDir, ".pylon", "client-entry-app__blog__page.tsx"),
276
+ "utf8",
277
+ );
278
+ expect(entry).toContain('import Page from "../app/blog/page";');
279
+ expect(entry).toContain('import L0 from "../app/layout";');
280
+ });
281
+
282
+ test("componentPathFor returns a /-separated path for win32 input", () => {
283
+ expect(
284
+ componentPathFor(path.win32, "C:\\proj", "C:\\proj\\app\\(shop)\\cart\\page.tsx"),
285
+ ).toBe("app/(shop)/cart/page");
286
+ });
287
+
237
288
  test("adding a route grows the manifest by one and stays small per-entry", async () => {
238
289
  tempDir = makeFixture(
239
290
  {