@pylonsync/functions 0.8.2 → 0.9.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/design-stamp.d.ts +24 -0
- package/dist/ssr-runtime.d.ts +14 -1
- package/package.json +3 -3
- package/src/design-stamp.test.ts +170 -0
- package/src/design-stamp.ts +160 -0
- package/src/ssr-design-render.test.ts +159 -0
- package/src/ssr-runtime.ts +94 -10
|
@@ -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;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.9.0",
|
|
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,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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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;
|