@pylonsync/functions 0.8.3 → 0.9.1

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,24 @@
1
+ export declare const DESIGN_STAMP_ATTR = "data-pylon-src";
2
+ /** True when the design stamp should be active for this process. */
3
+ export declare function isDesignMode(env?: Record<string, string | undefined>): boolean;
4
+ /** Directories (relative to the project root) whose JSX gets stamped. */
5
+ export declare const DESIGN_STAMP_DIRS: readonly ["app", "components", ".design"];
6
+ /**
7
+ * Regex matching absolute paths of the `.tsx` / `.jsx` files under the scoped
8
+ * directories of `root`. Used as the `onLoad` filter so files outside the
9
+ * scope never reach the transform.
10
+ */
11
+ export declare function designStampScopePattern(root: string): RegExp;
12
+ /** Project-relative path with forward slashes, for the attribute value. */
13
+ export declare function relativeSourcePath(root: string, absPath: string): string;
14
+ /**
15
+ * Add `data-pylon-src="<relPath>:<line>:<col>"` to every DOM element in a
16
+ * JSX/TSX source. `line` and `col` are 1-based and point at the opening `<`.
17
+ * Returns the source unchanged when no element needs a stamp.
18
+ */
19
+ export declare function stampJsxSource(source: string, relPath: string): string;
20
+ /**
21
+ * Register the Bun plugin for the project rooted at `root`. Only the files
22
+ * matched by `designStampScopePattern(root)` are transformed.
23
+ */
24
+ export declare function registerDesignStampPlugin(root?: string): void;
@@ -74,6 +74,13 @@ export interface RenderRouteMessage {
74
74
  * override it via `response.setStatus`.
75
75
  */
76
76
  initial_status?: number;
77
+ /**
78
+ * Design render (dev only). No hydration tail, no dev HUD, no live-reload
79
+ * snippet; the stylesheet is inlined with no size cap and the head gets a
80
+ * `<base href>` so the HTML works inside a `srcdoc` iframe. See
81
+ * `designHeadBlob`.
82
+ */
83
+ design?: boolean;
77
84
  }
78
85
  type Send = (msg: Record<string, unknown>) => void;
79
86
  /**
@@ -403,7 +410,13 @@ export declare function buildDevHudChunk(devInfo: Record<string, unknown>): stri
403
410
  * install.
404
411
  */
405
412
  export declare function buildCloudBadgeChunk(env?: Record<string, string | undefined>): string;
406
- export declare function cssHeadTag(css: string, prefix: string, routeOverride?: boolean): Promise<string>;
413
+ export declare function cssHeadTag(css: string, prefix: string, routeOverride?: boolean, maxBytes?: number): Promise<string>;
414
+ /**
415
+ * `<base href>` for a design render. The HTML is shown in a `srcdoc` iframe,
416
+ * where relative URLs have no origin to resolve against; the base points them
417
+ * at the dev server. Uses the request's Host, else `127.0.0.1:<PYLON_PORT>`.
418
+ */
419
+ export declare function designBaseHref(headers: Record<string, string | undefined>, env?: Record<string, string | undefined>): string;
407
420
  /**
408
421
  * Build the hydration tail appended after React's stream EOFs: the
409
422
  * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -48,10 +48,10 @@
48
48
  "license": "MIT OR Apache-2.0",
49
49
  "dependencies": {
50
50
  "satori": "^0.26.0",
51
- "@resvg/resvg-wasm": "^2.6.2"
51
+ "@resvg/resvg-wasm": "^2.6.2",
52
+ "typescript": "^5.5"
52
53
  },
53
54
  "devDependencies": {
54
- "typescript": "^5.5",
55
55
  "react": "^19.0.0",
56
56
  "react-dom": "^19.0.0",
57
57
  "@types/react": "^19.0.0",
@@ -0,0 +1,170 @@
1
+ // Design-mode JSX stamp: every DOM element under app/, components/ and
2
+ // .design/ gets `data-pylon-src="<rel>:<line>:<col>"`. Components are left
3
+ // alone, the transform is idempotent, and files outside the scoped dirs are
4
+ // never touched. The last case runs the real preload in a child Bun process
5
+ // so the plugin wiring (filter + loader) is covered, not only the transform.
6
+
7
+ import { describe, 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
+ import {
12
+ DESIGN_STAMP_ATTR,
13
+ designStampScopePattern,
14
+ relativeSourcePath,
15
+ stampJsxSource,
16
+ } from "./design-stamp";
17
+
18
+ const STAMP = /data-pylon-src="([^"]+)"/g;
19
+
20
+ function stamps(src: string): string[] {
21
+ return [...src.matchAll(STAMP)].map((m) => m[1]);
22
+ }
23
+
24
+ describe("stampJsxSource", () => {
25
+ test("stamps DOM elements with 1-based line and column of the opening <", () => {
26
+ const src = [
27
+ "export default function Page() {",
28
+ " return (",
29
+ " <main className=\"p-4\">",
30
+ " <h1>Hello</h1>",
31
+ " <img src=\"/a.png\" />",
32
+ " </main>",
33
+ " );",
34
+ "}",
35
+ "",
36
+ ].join("\n");
37
+ const out = stampJsxSource(src, "app/page.tsx");
38
+ expect(stamps(out)).toEqual([
39
+ "app/page.tsx:3:5",
40
+ "app/page.tsx:4:7",
41
+ "app/page.tsx:5:7",
42
+ ]);
43
+ // Existing attributes survive.
44
+ expect(out).toContain('className="p-4"');
45
+ expect(out).toContain('src="/a.png"');
46
+ });
47
+
48
+ test("components and member-expression tags are not stamped", () => {
49
+ const src = [
50
+ "import { Card } from \"../components/card\";",
51
+ "const UI = { Box: (p: any) => <div {...p} /> };",
52
+ "export default function Page() {",
53
+ " return (",
54
+ " <Card title=\"x\">",
55
+ " <UI.Box>",
56
+ " <span>inner</span>",
57
+ " </UI.Box>",
58
+ " <Card.Footer />",
59
+ " </Card>",
60
+ " );",
61
+ "}",
62
+ "",
63
+ ].join("\n");
64
+ const out = stampJsxSource(src, "app/page.tsx");
65
+ // Only the <div> in the inline component and the <span> are DOM.
66
+ expect(stamps(out)).toEqual(["app/page.tsx:2:31", "app/page.tsx:7:9"]);
67
+ expect(out).not.toMatch(/<Card[^>]*data-pylon-src/);
68
+ expect(out).not.toMatch(/<UI\.Box[^>]*data-pylon-src/);
69
+ expect(out).not.toMatch(/<Card\.Footer[^>]*data-pylon-src/);
70
+ });
71
+
72
+ test("is idempotent and keeps a hand-written attribute", () => {
73
+ const src = [
74
+ "export const A = () => (",
75
+ " <section data-pylon-src=\"custom:1:1\">",
76
+ " <p>x</p>",
77
+ " </section>",
78
+ ");",
79
+ "",
80
+ ].join("\n");
81
+ const once = stampJsxSource(src, "components/a.tsx");
82
+ expect(stamps(once)).toEqual(["custom:1:1", "components/a.tsx:3:5"]);
83
+ const twice = stampJsxSource(once, "components/a.tsx");
84
+ expect(twice).toBe(once);
85
+ });
86
+
87
+ test("returns the source unchanged when there is nothing to stamp", () => {
88
+ const src = "export const n = 1;\nexport const C = () => <Comp />;\n";
89
+ expect(stampJsxSource(src, "app/x.tsx")).toBe(src);
90
+ });
91
+
92
+ test("handles fragments, conditionals, and .jsx files", () => {
93
+ const src = [
94
+ "export default function P({ ok }) {",
95
+ " return (",
96
+ " <>",
97
+ " {ok ? <b>yes</b> : <i>no</i>}",
98
+ " {[1, 2].map((n) => <li key={n}>{n}</li>)}",
99
+ " </>",
100
+ " );",
101
+ "}",
102
+ "",
103
+ ].join("\n");
104
+ const out = stampJsxSource(src, "app/p.jsx");
105
+ expect(stamps(out)).toEqual(["app/p.jsx:4:13", "app/p.jsx:4:26", "app/p.jsx:5:26"]);
106
+ expect(out).toContain("key={n}");
107
+ });
108
+ });
109
+
110
+ describe("scope", () => {
111
+ test("pattern matches only tsx/jsx under app, components and .design", () => {
112
+ const root = "/proj/my app";
113
+ const re = designStampScopePattern(root);
114
+ expect(re.test("/proj/my app/app/page.tsx")).toBe(true);
115
+ expect(re.test("/proj/my app/app/blog/[slug]/page.tsx")).toBe(true);
116
+ expect(re.test("/proj/my app/components/card.jsx")).toBe(true);
117
+ expect(re.test("/proj/my app/.design/variants/v1.tsx")).toBe(true);
118
+ expect(re.test("/proj/my app/lib/ui.tsx")).toBe(false);
119
+ expect(re.test("/proj/my app/app/data.ts")).toBe(false);
120
+ expect(re.test("/proj/my app/node_modules/x/app/page.tsx")).toBe(false);
121
+ expect(re.test("/proj/my apple/app/page.tsx")).toBe(false);
122
+ });
123
+
124
+ test("relativeSourcePath strips the root", () => {
125
+ expect(relativeSourcePath("/proj", "/proj/app/page.tsx")).toBe("app/page.tsx");
126
+ expect(relativeSourcePath("/proj/", "/proj/.design/v.tsx")).toBe(".design/v.tsx");
127
+ });
128
+ });
129
+
130
+ // The preload itself: a child Bun process with PYLON_DESIGN_MODE=1 and
131
+ // `--preload design-stamp.ts` renders two modules to static markup. The one
132
+ // under app/ is stamped; the one under lib/ is not.
133
+ test("preload stamps app/ modules and leaves lib/ modules alone", async () => {
134
+ const dir = mkdtempSync(join(tmpdir(), "pylon-design-stamp-"));
135
+ mkdirSync(join(dir, "app"), { recursive: true });
136
+ mkdirSync(join(dir, "lib"), { recursive: true });
137
+ const body =
138
+ "export default function C() { return <div className=\"c\"><span>t</span></div>; }\n";
139
+ writeFileSync(join(dir, "app", "page.tsx"), body);
140
+ writeFileSync(join(dir, "lib", "widget.tsx"), body);
141
+ // Resolve react from this package's node_modules (the temp dir has none).
142
+ const reactDir = join(import.meta.dir, "..");
143
+ const probe = `
144
+ import React from ${JSON.stringify(join(reactDir, "node_modules/react"))};
145
+ import { renderToStaticMarkup } from ${JSON.stringify(join(reactDir, "node_modules/react-dom/server"))};
146
+ const a = (await import(${JSON.stringify(join(dir, "app/page.tsx"))})).default;
147
+ const b = (await import(${JSON.stringify(join(dir, "lib/widget.tsx"))})).default;
148
+ console.log("APP " + renderToStaticMarkup(React.createElement(a)));
149
+ console.log("LIB " + renderToStaticMarkup(React.createElement(b)));
150
+ `;
151
+ writeFileSync(join(dir, "probe.ts"), probe);
152
+ const preload = join(import.meta.dir, "design-stamp.ts");
153
+ const proc = Bun.spawn(["bun", "run", "--preload", preload, join(dir, "probe.ts")], {
154
+ cwd: dir,
155
+ env: { ...process.env, PYLON_DESIGN_MODE: "1" },
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ });
159
+ const [out, err] = await Promise.all([
160
+ new Response(proc.stdout).text(),
161
+ new Response(proc.stderr).text(),
162
+ ]);
163
+ expect(await proc.exited).toBe(0);
164
+ if (err.trim()) console.error(err);
165
+ const app = out.split("\n").find((l) => l.startsWith("APP "))!;
166
+ const lib = out.split("\n").find((l) => l.startsWith("LIB "))!;
167
+ expect(app).toContain(`${DESIGN_STAMP_ATTR}="app/page.tsx:1:38"`);
168
+ expect(app).toContain(`${DESIGN_STAMP_ATTR}="app/page.tsx:1:57"`);
169
+ expect(lib).not.toContain(DESIGN_STAMP_ATTR);
170
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Design-mode JSX source stamp.
3
+ *
4
+ * A Bun preload plugin. When `PYLON_DESIGN_MODE=1`, every `.tsx` / `.jsx`
5
+ * module under the app's `app/`, `components/`, and `.design/` directories is
6
+ * rewritten on load so each DOM element carries
7
+ * `data-pylon-src="<relative path>:<line>:<col>"`. The design canvas reads the
8
+ * attribute off the rendered HTML to map a clicked element back to its source.
9
+ *
10
+ * The rewrite runs on the SSR runner only. The client bundler does not load
11
+ * this plugin, so browser bundles are unchanged.
12
+ *
13
+ * The transform uses the TypeScript compiler API. Components (tags that start
14
+ * with an uppercase letter, or member expressions) are left alone; their own
15
+ * DOM elements are stamped where they are written. An element that already has
16
+ * the attribute keeps it, so the transform is idempotent.
17
+ *
18
+ * Load with `bun run --preload <this file> ...`. The plugin registers itself
19
+ * on import; `stampJsxSource` and `designStampScopePattern` are exported for
20
+ * tests and for the runtime that wires the preload.
21
+ */
22
+ import ts from "typescript";
23
+
24
+ export const DESIGN_STAMP_ATTR = "data-pylon-src";
25
+
26
+ /** True when the design stamp should be active for this process. */
27
+ export function isDesignMode(env: Record<string, string | undefined> = process.env): boolean {
28
+ const v = env.PYLON_DESIGN_MODE;
29
+ return v === "1" || v?.toLowerCase() === "true";
30
+ }
31
+
32
+ /** Directories (relative to the project root) whose JSX gets stamped. */
33
+ export const DESIGN_STAMP_DIRS = ["app", "components", ".design"] as const;
34
+
35
+ function escapeRegExp(s: string): string {
36
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ /**
40
+ * Regex matching absolute paths of the `.tsx` / `.jsx` files under the scoped
41
+ * directories of `root`. Used as the `onLoad` filter so files outside the
42
+ * scope never reach the transform.
43
+ */
44
+ export function designStampScopePattern(root: string): RegExp {
45
+ const base = escapeRegExp(root.replace(/[\\/]+$/, ""));
46
+ const dirs = DESIGN_STAMP_DIRS.map(escapeRegExp).join("|");
47
+ return new RegExp(`^${base}[\\\\/](?:${dirs})[\\\\/].*\\.(?:tsx|jsx)$`);
48
+ }
49
+
50
+ /** Project-relative path with forward slashes, for the attribute value. */
51
+ export function relativeSourcePath(root: string, absPath: string): string {
52
+ const base = root.replace(/[\\/]+$/, "");
53
+ let rel = absPath.startsWith(base) ? absPath.slice(base.length) : absPath;
54
+ rel = rel.replace(/^[\\/]+/, "").replace(/\\/g, "/");
55
+ return rel;
56
+ }
57
+
58
+ function isDomTag(tagName: ts.JsxTagNameExpression): boolean {
59
+ return ts.isIdentifier(tagName) && /^[a-z]/.test(tagName.text);
60
+ }
61
+
62
+ function hasStampAttr(attrs: ts.JsxAttributes): boolean {
63
+ return attrs.properties.some(
64
+ (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === DESIGN_STAMP_ATTR,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Add `data-pylon-src="<relPath>:<line>:<col>"` to every DOM element in a
70
+ * JSX/TSX source. `line` and `col` are 1-based and point at the opening `<`.
71
+ * Returns the source unchanged when no element needs a stamp.
72
+ */
73
+ export function stampJsxSource(source: string, relPath: string): string {
74
+ const kind = relPath.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
75
+ const sourceFile = ts.createSourceFile(relPath, source, ts.ScriptTarget.Latest, true, kind);
76
+ let stamped = 0;
77
+
78
+ const transformer: ts.TransformerFactory<ts.SourceFile> = (ctx) => {
79
+ const f = ctx.factory;
80
+ const stampFor = (node: ts.Node): ts.JsxAttribute => {
81
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(
82
+ node.getStart(sourceFile),
83
+ );
84
+ const value = `${relPath}:${line + 1}:${character + 1}`;
85
+ return f.createJsxAttribute(
86
+ f.createIdentifier(DESIGN_STAMP_ATTR),
87
+ f.createStringLiteral(value),
88
+ );
89
+ };
90
+ const withStamp = (attrs: ts.JsxAttributes, attr: ts.JsxAttribute): ts.JsxAttributes =>
91
+ f.updateJsxAttributes(attrs, [...attrs.properties, attr]);
92
+
93
+ const visit: ts.Visitor = (node) => {
94
+ if (ts.isJsxOpeningElement(node)) {
95
+ const shouldStamp = isDomTag(node.tagName) && !hasStampAttr(node.attributes);
96
+ const attr = shouldStamp ? stampFor(node) : null;
97
+ const visited = ts.visitEachChild(node, visit, ctx);
98
+ if (!attr) return visited;
99
+ stamped++;
100
+ return f.updateJsxOpeningElement(
101
+ visited,
102
+ visited.tagName,
103
+ visited.typeArguments,
104
+ withStamp(visited.attributes, attr),
105
+ );
106
+ }
107
+ if (ts.isJsxSelfClosingElement(node)) {
108
+ const shouldStamp = isDomTag(node.tagName) && !hasStampAttr(node.attributes);
109
+ const attr = shouldStamp ? stampFor(node) : null;
110
+ const visited = ts.visitEachChild(node, visit, ctx);
111
+ if (!attr) return visited;
112
+ stamped++;
113
+ return f.updateJsxSelfClosingElement(
114
+ visited,
115
+ visited.tagName,
116
+ visited.typeArguments,
117
+ withStamp(visited.attributes, attr),
118
+ );
119
+ }
120
+ return ts.visitEachChild(node, visit, ctx);
121
+ };
122
+ return (root) => ts.visitNode(root, visit) as ts.SourceFile;
123
+ };
124
+
125
+ const result = ts.transform(sourceFile, [transformer], { jsx: ts.JsxEmit.Preserve });
126
+ try {
127
+ if (stamped === 0) return source;
128
+ const printer = ts.createPrinter({
129
+ newLine: ts.NewLineKind.LineFeed,
130
+ removeComments: false,
131
+ });
132
+ return printer.printFile(result.transformed[0]);
133
+ } finally {
134
+ result.dispose();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Register the Bun plugin for the project rooted at `root`. Only the files
140
+ * matched by `designStampScopePattern(root)` are transformed.
141
+ */
142
+ export function registerDesignStampPlugin(root: string = process.cwd()): void {
143
+ const filter = designStampScopePattern(root);
144
+ Bun.plugin({
145
+ name: "pylon-design-stamp",
146
+ setup(build) {
147
+ build.onLoad({ filter }, async (args) => {
148
+ const source = await Bun.file(args.path).text();
149
+ const rel = relativeSourcePath(root, args.path);
150
+ return { contents: stampJsxSource(source, rel), loader: "tsx" };
151
+ });
152
+ },
153
+ });
154
+ }
155
+
156
+ // Preload entry: `bun run --preload design-stamp.ts`. Registration is gated on
157
+ // the env so importing this module from a test without the flag is a no-op.
158
+ if (isDesignMode()) {
159
+ registerDesignStampPlugin(process.cwd());
160
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A handler that RETURNS `ctx.error(...)` instead of throwing it must
3
+ * still fail the call. An Error has no JSON shape, so a returned one
4
+ * used to reach the client as HTTP 200 with `{}` — a forged proof
5
+ * "passed" because the author wrote `return` where `throw` belonged.
6
+ *
7
+ * Same child-process harness as runtime-db.test.ts: runtime.ts runs
8
+ * main() on import, loads the functions dir from argv[2], and speaks
9
+ * NDJSON on stdin/stdout like the Rust host.
10
+ */
11
+ import { expect, test } from "bun:test";
12
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const RUNTIME = join(import.meta.dir, "runtime.ts");
17
+ const DEFINE = join(import.meta.dir, "index.ts");
18
+
19
+ function frames(text: string): Record<string, unknown>[] {
20
+ return text
21
+ .split("\n")
22
+ .filter((l) => l.trim().startsWith("{"))
23
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
24
+ }
25
+
26
+ test("a returned ctx.error rejects the call with its code", async () => {
27
+ const dir = mkdtempSync(join(tmpdir(), "pylon-fn-returned-error-"));
28
+ mkdirSync(join(dir, "functions"));
29
+ writeFileSync(
30
+ join(dir, "functions", "checkProof.ts"),
31
+ `import { query } from ${JSON.stringify(DEFINE)};
32
+ export default query({
33
+ args: {},
34
+ auth: "public",
35
+ async handler(ctx) {
36
+ // Wrong on purpose: return instead of throw.
37
+ return ctx.error("INVALID_PROOF", "proof rejected") as unknown as { ok: boolean };
38
+ },
39
+ });
40
+ `,
41
+ );
42
+ writeFileSync(
43
+ join(dir, "functions", "plain.ts"),
44
+ `import { query } from ${JSON.stringify(DEFINE)};
45
+ export default query({
46
+ args: {},
47
+ auth: "public",
48
+ async handler() {
49
+ return { ok: true };
50
+ },
51
+ });
52
+ `,
53
+ );
54
+
55
+ const proc = Bun.spawn([process.execPath, RUNTIME, "functions"], {
56
+ cwd: dir,
57
+ stdin: "pipe",
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ });
61
+
62
+ const reader = proc.stdout.getReader();
63
+ const decoder = new TextDecoder();
64
+ let buffered = "";
65
+ let seen: Record<string, unknown>[] = [];
66
+ const until = async (pred: (f: Record<string, unknown>[]) => boolean) => {
67
+ while (!pred(seen)) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ buffered += decoder.decode(value, { stream: true });
71
+ seen = frames(buffered);
72
+ }
73
+ };
74
+
75
+ await until((f) => f.some((m) => m.type === "ready"));
76
+ const send = (msg: Record<string, unknown>) =>
77
+ proc.stdin.write(JSON.stringify(msg) + "\n");
78
+ const auth = { user_id: null, is_admin: false };
79
+ send({ type: "call", call_id: "c_bad", fn_name: "checkProof", fn_type: "query", args: {}, auth });
80
+ send({ type: "call", call_id: "c_ok", fn_name: "plain", fn_type: "query", args: {}, auth });
81
+ await proc.stdin.flush();
82
+ await until((f) => f.some((m) => m.call_id === "c_bad") && f.some((m) => m.call_id === "c_ok"));
83
+ proc.kill();
84
+
85
+ const bad = seen.find((m) => m.call_id === "c_bad");
86
+ expect(bad).toMatchObject({ type: "error", code: "INVALID_PROOF", message: "proof rejected" });
87
+ const ok = seen.find((m) => m.call_id === "c_ok");
88
+ expect(ok).toMatchObject({ type: "return", value: { ok: true } });
89
+ });
package/src/runtime.ts CHANGED
@@ -1394,6 +1394,11 @@ async function handleCall(msg: CallMessage): Promise<void> {
1394
1394
  try {
1395
1395
  const result = await def.handler(ctx, msg.args);
1396
1396
  if (cancelledCalls.has(msg.call_id)) return;
1397
+ // `return ctx.error(...)` instead of `throw ctx.error(...)` is a
1398
+ // common slip. An Error has no JSON shape, so a returned one can
1399
+ // only mean the handler meant to fail: treat it as thrown so the
1400
+ // caller gets the error code, not a 200 with `{}`.
1401
+ if (result instanceof Error) throw result;
1397
1402
  send({
1398
1403
  type: "return",
1399
1404
  call_id: msg.call_id,
@@ -0,0 +1,159 @@
1
+ // Design render (`RenderRouteMessage.design: true`): the document the design
2
+ // canvas loads into a `srcdoc` iframe. Compared with the same page rendered
3
+ // normally in dev mode, it must have
4
+ // - a `<base href>` first in the injected head blob,
5
+ // - the stylesheet inlined even when it is over PYLON_SSR_INLINE_CSS_MAX,
6
+ // - no modulepreload links, no `__PYLON_DATA__`, no module script,
7
+ // - no live-reload snippet and no dev HUD.
8
+ //
9
+ // ssr-runtime.ts pulls in runtime.ts (the bun runner entrypoint, which runs
10
+ // main() on import), so — like ssr-raw-get.test.ts — the render runs in a child
11
+ // process with a kept-open stdin pipe and reports its frames over stderr.
12
+
13
+ import { expect, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { designBaseHref } from "./ssr-runtime";
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(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "pylon-design-render-"));
31
+ mkdirSync(join(dir, "app"), { recursive: true });
32
+ mkdirSync(join(dir, ".pylon", "client-build"), { recursive: true });
33
+ // react / react-dom resolve from this package's install.
34
+ symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
35
+ writeFileSync(
36
+ join(dir, "app", "layout.tsx"),
37
+ `export default function Layout({ children }: any) {
38
+ return <html><head><title>t</title></head><body><main id="root">{children}</main></body></html>;
39
+ }
40
+ `,
41
+ );
42
+ writeFileSync(
43
+ join(dir, "app", "page.tsx"),
44
+ `export default function Page() {
45
+ return <section className="hero"><h1>Hello</h1><img src="/logo.png" /></section>;
46
+ }
47
+ `,
48
+ );
49
+ // A stylesheet larger than the default 32KB inline cap.
50
+ const css = ".hero{padding:1rem}\n" + "/* pad */\n".repeat(5000);
51
+ writeFileSync(join(dir, ".pylon", "client-build", "app.css"), css);
52
+ writeFileSync(join(dir, ".pylon", "client-build", "entry.js"), "");
53
+ writeFileSync(
54
+ join(dir, ".pylon", "client-build", "manifest.json"),
55
+ JSON.stringify({
56
+ outdir: ".pylon/client-build",
57
+ public_prefix: "/_pylon/build/",
58
+ routes: {
59
+ "app/page": { file: "entry.js", imports: ["chunks/shared.js"], css: ["app.css"] },
60
+ },
61
+ }),
62
+ );
63
+ return dir;
64
+ }
65
+
66
+ async function render(dir: string, design: boolean): Promise<{ frames: Frame[]; html: string }> {
67
+ const msg = {
68
+ type: "render_route",
69
+ call_id: "c1",
70
+ component: "app/page",
71
+ layouts: ["app/layout"],
72
+ route_path: "/",
73
+ url: "/",
74
+ params: {},
75
+ search_params: {},
76
+ headers: { host: "127.0.0.1:4599" },
77
+ cookies: {},
78
+ auth: { user_id: null, is_admin: false, tenant_id: null, roles: [] },
79
+ design,
80
+ };
81
+ const probe = `
82
+ import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
83
+ const frames = [];
84
+ await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
85
+ console.error("FRAMES " + JSON.stringify(frames));
86
+ setTimeout(() => process.exit(0), 50);
87
+ `;
88
+ const probePath = join(dir, design ? "probe-design.ts" : "probe-plain.ts");
89
+ writeFileSync(probePath, probe);
90
+ const proc = Bun.spawn(["bun", "run", probePath], {
91
+ cwd: dir,
92
+ env: { ...process.env, PYLON_DEV_MODE: "1" },
93
+ stdin: "pipe",
94
+ stdout: "pipe",
95
+ stderr: "pipe",
96
+ });
97
+ const [out, err] = await Promise.all([
98
+ new Response(proc.stdout).text(),
99
+ new Response(proc.stderr).text(),
100
+ ]);
101
+ const code = await proc.exited;
102
+ // runtime.ts prefixes console.error lines with "[error] ".
103
+ const line = err.split("\n").find((l) => l.includes("FRAMES "));
104
+ if (code !== 0 || !line) {
105
+ throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
106
+ }
107
+ const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + "FRAMES ".length)) as Frame[];
108
+ const html = frames
109
+ .filter((f) => f.type === "render_chunk")
110
+ .map((f) => Buffer.from(f.data!, "base64").toString("utf8"))
111
+ .join("");
112
+ return { frames, html };
113
+ }
114
+
115
+ test("designBaseHref uses the request host, else 127.0.0.1:PYLON_PORT", () => {
116
+ expect(designBaseHref({ host: "localhost:4321" })).toBe('<base href="http://localhost:4321/">');
117
+ expect(designBaseHref({}, { PYLON_PORT: "5000" })).toBe('<base href="http://127.0.0.1:5000/">');
118
+ expect(designBaseHref({}, {})).toBe('<base href="http://127.0.0.1:4321/">');
119
+ });
120
+
121
+ test("design render: base href, inlined CSS, no hydration, no dev chrome", async () => {
122
+ const dir = makeApp();
123
+ const { frames, html } = await render(dir, true);
124
+ expect(frames[0].type).toBe("response_start");
125
+ expect(frames[0].status).toBe(200);
126
+ expect(frames[frames.length - 1].type).toBe("render_done");
127
+
128
+ // Page DOM is intact.
129
+ expect(html).toContain('<section class="hero"');
130
+ expect(html).toContain('<img src="/logo.png"');
131
+ // <base href> is the first injected head tag.
132
+ const headEnd = html.indexOf("</head>");
133
+ const base = html.indexOf('<base href="http://127.0.0.1:4599/">');
134
+ expect(base).toBeGreaterThan(-1);
135
+ expect(base).toBeLessThan(headEnd);
136
+ const style = html.indexOf('<style data-pylon-css="app.css">');
137
+ expect(style).toBeGreaterThan(base);
138
+ expect(html).toContain(".hero{padding:1rem}");
139
+ expect(html).not.toContain('<link rel="stylesheet"');
140
+ expect(html).not.toContain("modulepreload");
141
+ // No hydration, no dev chrome.
142
+ expect(html).not.toContain("__PYLON_DATA__");
143
+ expect(html).not.toContain('<script type="module"');
144
+ expect(html).not.toContain("/_pylon/dev/live");
145
+ expect(html).not.toContain("__pylon_hud");
146
+ expect(html).not.toMatch(/<script/);
147
+ });
148
+
149
+ test("the same page rendered normally in dev mode keeps hydration and dev chrome", async () => {
150
+ const dir = makeApp();
151
+ const { html } = await render(dir, false);
152
+ expect(html).not.toContain("<base href");
153
+ // Over the 32KB cap → the link, not an inline sheet.
154
+ expect(html).toContain('<link rel="stylesheet" href="/_pylon/build/app.css">');
155
+ expect(html).toContain("modulepreload");
156
+ expect(html).toContain("__PYLON_DATA__");
157
+ expect(html).toContain('<script type="module"');
158
+ expect(html).toContain("/_pylon/dev/live");
159
+ });
@@ -93,6 +93,13 @@ export interface RenderRouteMessage {
93
93
  * override it via `response.setStatus`.
94
94
  */
95
95
  initial_status?: number;
96
+ /**
97
+ * Design render (dev only). No hydration tail, no dev HUD, no live-reload
98
+ * snippet; the stylesheet is inlined with no size cap and the head gets a
99
+ * `<base href>` so the HTML works inside a `srcdoc` iframe. See
100
+ * `designHeadBlob`.
101
+ */
102
+ design?: boolean;
96
103
  }
97
104
 
98
105
  type Send = (msg: Record<string, unknown>) => void;
@@ -1923,6 +1930,9 @@ export async function cssHeadTag(
1923
1930
  css: string,
1924
1931
  prefix: string,
1925
1932
  routeOverride?: boolean,
1933
+ // Size cap override. A design render passes `Infinity`: the whole sheet is
1934
+ // inlined so the HTML is self-contained.
1935
+ maxBytes?: number,
1926
1936
  ): Promise<string> {
1927
1937
  const link = `<link rel="stylesheet" href="${prefix}${css}">`;
1928
1938
  // Per-route `export const inlineCss = true|false` beats the env
@@ -1932,7 +1942,10 @@ export async function cssHeadTag(
1932
1942
  routeOverride ??
1933
1943
  /^(1|true)$/i.test(process.env.PYLON_SSR_INLINE_CSS ?? "");
1934
1944
  if (!enabled) return link;
1935
- let cached = inlineCssCache.get(css);
1945
+ const max =
1946
+ maxBytes ?? (Number(process.env.PYLON_SSR_INLINE_CSS_MAX ?? "") || 32768);
1947
+ const cacheKey = `${max}:${css}`;
1948
+ let cached = inlineCssCache.get(cacheKey);
1936
1949
  if (cached === undefined) {
1937
1950
  cached = null;
1938
1951
  try {
@@ -1940,7 +1953,6 @@ export async function cssHeadTag(
1940
1953
  const manifest: any = await getManifest();
1941
1954
  const fs = await import("fs");
1942
1955
  const path = await import("path");
1943
- const max = Number(process.env.PYLON_SSR_INLINE_CSS_MAX ?? "") || 32768;
1944
1956
  const file = path.join(
1945
1957
  process.cwd(),
1946
1958
  manifest.outdir || ".pylon/client-build",
@@ -1955,13 +1967,66 @@ export async function cssHeadTag(
1955
1967
  cached = null;
1956
1968
  }
1957
1969
  if (inlineCssCache.size > 64) inlineCssCache.clear();
1958
- inlineCssCache.set(css, cached);
1970
+ inlineCssCache.set(cacheKey, cached);
1959
1971
  }
1960
1972
  return cached === null
1961
1973
  ? link
1962
1974
  : `<style data-pylon-css="${css}">${cached}</style>`;
1963
1975
  }
1964
1976
 
1977
+ /**
1978
+ * `<base href>` for a design render. The HTML is shown in a `srcdoc` iframe,
1979
+ * where relative URLs have no origin to resolve against; the base points them
1980
+ * at the dev server. Uses the request's Host, else `127.0.0.1:<PYLON_PORT>`.
1981
+ */
1982
+ export function designBaseHref(
1983
+ headers: Record<string, string | undefined>,
1984
+ env: Record<string, string | undefined> = process.env,
1985
+ ): string {
1986
+ const host = (headers.host ?? "").trim();
1987
+ const origin = host
1988
+ ? `http://${host}`
1989
+ : `http://127.0.0.1:${env.PYLON_PORT || "4321"}`;
1990
+ return `<base href="${origin.replace(/"/g, "%22")}/">`;
1991
+ }
1992
+
1993
+ /**
1994
+ * Head blob for a design render: `<base href>` first, then fonts, then every
1995
+ * route stylesheet inlined with no size cap. No modulepreloads: the page does
1996
+ * not hydrate, so no client chunk is fetched.
1997
+ */
1998
+ async function designHeadBlob(
1999
+ headers: Record<string, string | undefined>,
2000
+ manifestRoute: { css: string[] } | null,
2001
+ publicPrefix: string,
2002
+ ): Promise<string> {
2003
+ let blob = designBaseHref(headers);
2004
+ blob += await buildFontHeadBlob();
2005
+ if (manifestRoute) {
2006
+ for (const css of manifestRoute.css) {
2007
+ blob += await cssHeadTag(css, publicPrefix, true, Infinity);
2008
+ }
2009
+ return blob;
2010
+ }
2011
+ // No per-route entry: inline the stylesheet union like the boundary path.
2012
+ try {
2013
+ const { getManifest } = await import("./ssr-client-bundler");
2014
+ const manifest = await getManifest();
2015
+ const prefix = manifest.public_prefix || "/_pylon/build/";
2016
+ const seen = new Set<string>();
2017
+ for (const route of Object.values(manifest.routes || {}) as any[]) {
2018
+ for (const css of (route.css || []) as string[]) {
2019
+ if (seen.has(css)) continue;
2020
+ seen.add(css);
2021
+ blob += await cssHeadTag(css, prefix, true, Infinity);
2022
+ }
2023
+ }
2024
+ } catch {
2025
+ // Unstyled design render; the canvas still shows the DOM.
2026
+ }
2027
+ return blob;
2028
+ }
2029
+
1965
2030
  async function collectBoundaryHeadBlob(): Promise<string> {
1966
2031
  try {
1967
2032
  const { getManifest } = await import("./ssr-client-bundler");
@@ -2240,6 +2305,8 @@ async function renderBoundaryToClient(
2240
2305
  kind: "error" | "not-found";
2241
2306
  errorForClient?: { message: string; digest?: string };
2242
2307
  },
2308
+ // Design render: no hydration tail, inlined CSS, `<base href>`.
2309
+ design?: { headers: Record<string, string | undefined> },
2243
2310
  ): Promise<void> {
2244
2311
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
2245
2312
  onError(e: unknown) {
@@ -2264,7 +2331,9 @@ async function renderBoundaryToClient(
2264
2331
  manifestRoute = null;
2265
2332
  }
2266
2333
  }
2267
- if (manifestRoute) {
2334
+ if (design) {
2335
+ headBlob = await designHeadBlob(design.headers, manifestRoute, publicPrefix);
2336
+ } else if (manifestRoute) {
2268
2337
  headBlob += await buildFontHeadBlob();
2269
2338
  const co = /^https?:\/\//i.test(publicPrefix) ? " crossorigin" : "";
2270
2339
  for (const css of manifestRoute.css) {
@@ -2293,9 +2362,9 @@ async function renderBoundaryToClient(
2293
2362
  stream.getReader(),
2294
2363
  headBlob,
2295
2364
  sendChunk,
2296
- buildCloudBadgeChunk(),
2365
+ design ? "" : buildCloudBadgeChunk(),
2297
2366
  );
2298
- if (tail && manifestRoute) {
2367
+ if (tail && manifestRoute && !design) {
2299
2368
  const tailHtml = buildHydrationTail({
2300
2369
  component: tail.component,
2301
2370
  layouts: tail.layouts,
@@ -2329,6 +2398,7 @@ async function tryRenderBoundary(
2329
2398
  callId: string;
2330
2399
  status: number;
2331
2400
  headers: Record<string, string>;
2401
+ design?: { headers: Record<string, string | undefined> };
2332
2402
  },
2333
2403
  ): Promise<boolean> {
2334
2404
  const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
@@ -2417,6 +2487,7 @@ async function tryRenderBoundary(
2417
2487
  kind: fileName === "error" ? "error" : "not-found",
2418
2488
  errorForClient,
2419
2489
  },
2490
+ opts.design,
2420
2491
  );
2421
2492
  return true;
2422
2493
  } catch (e) {
@@ -3323,6 +3394,9 @@ export async function handleRenderRoute(
3323
3394
  // render still runs (it is what resolves serverData), but the response
3324
3395
  // carries only the head metadata and the data.
3325
3396
  const navRender = isNavRequest(msg.headers as Record<string, unknown>);
3397
+ // Design render (dev only, set by the host for `X-Pylon-Design: 1` and
3398
+ // `/_pylon/dev/render`): static HTML for the design canvas.
3399
+ const designRender = msg.design === true && !navRender;
3326
3400
 
3327
3401
  // loading.tsx (#278): the nearest `loading` module — walked up from the
3328
3402
  // page dir, like not-found/error — becomes ONE route-level Suspense
@@ -3641,7 +3715,13 @@ export async function handleRenderRoute(
3641
3715
  // modulepreloads. The entry script tag stays in the body-tail
3642
3716
  // (it needs the inline __PYLON_DATA__ to have been parsed first).
3643
3717
  let headBlob = "";
3644
- if (preloadManifestRoute) {
3718
+ if (designRender) {
3719
+ headBlob = await designHeadBlob(
3720
+ msg.headers,
3721
+ preloadManifestRoute,
3722
+ preloadPublicPrefix,
3723
+ );
3724
+ } else if (preloadManifestRoute) {
3645
3725
  headBlob += await buildFontHeadBlob();
3646
3726
  const co = /^https?:\/\//i.test(preloadPublicPrefix) ? " crossorigin" : "";
3647
3727
  const inlineOverride =
@@ -3704,7 +3784,7 @@ export async function handleRenderRoute(
3704
3784
  stream.getReader(),
3705
3785
  headBlob,
3706
3786
  sendChunk,
3707
- buildCloudBadgeChunk(),
3787
+ designRender ? "" : buildCloudBadgeChunk(),
3708
3788
  );
3709
3789
  }
3710
3790
 
@@ -3773,7 +3853,9 @@ export async function handleRenderRoute(
3773
3853
  // load. `buildHydrationTail` does the props strip (serverData/response +
3774
3854
  // the security headers/cookies strip) + the </script> + U+2028/2029
3775
3855
  // escaping. The CSS/modulepreload links were already injected into <head>.
3776
- const wantsHydration = !isBoundaryComponent || !!preloadManifestRoute;
3856
+ // A design render never hydrates: the canvas owns the DOM.
3857
+ const wantsHydration =
3858
+ !designRender && (!isBoundaryComponent || !!preloadManifestRoute);
3777
3859
  if (wantsHydration) {
3778
3860
  const tail = buildHydrationTail({
3779
3861
  component: msg.component,
@@ -3808,7 +3890,7 @@ export async function handleRenderRoute(
3808
3890
  //
3809
3891
  // Never on a navigation response: there is no document for the overlay to
3810
3892
  // attach to, and its blob would dwarf the payload it rode in on.
3811
- if (devVerdict && !navRender) {
3893
+ if (devVerdict && !navRender && !designRender) {
3812
3894
  const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
3813
3895
  // Build-level failures that degraded this page (currently: the
3814
3896
  // Tailwind compile). Dev-only and unmissable — the HUD paints a
@@ -3887,6 +3969,7 @@ export async function handleRenderRoute(
3887
3969
  callId: msg.call_id,
3888
3970
  status: 404,
3889
3971
  headers: finalizeHeaders(responseState),
3972
+ design: msg.design === true ? { headers: msg.headers } : undefined,
3890
3973
  })
3891
3974
  ) {
3892
3975
  return;
@@ -3924,6 +4007,7 @@ export async function handleRenderRoute(
3924
4007
  callId: msg.call_id,
3925
4008
  status: 500,
3926
4009
  headers: finalizeHeaders(responseState),
4010
+ design: msg.design === true ? { headers: msg.headers } : undefined,
3927
4011
  })
3928
4012
  ) {
3929
4013
  return;