@pylonsync/functions 0.3.223 → 0.3.225

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.223",
3
+ "version": "0.3.225",
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",
package/src/runtime.ts CHANGED
@@ -230,6 +230,24 @@ function dispatch(line: string): void {
230
230
  message: err?.message || String(err),
231
231
  });
232
232
  });
233
+ } else if (msg.type === "bundle_client") {
234
+ // Hydration — build the client-side bundle once and report
235
+ // the path back. Lazy-imported for the same reason as SSR.
236
+ import("./ssr-client-bundler")
237
+ .then((mod) =>
238
+ mod.handleBundleClient(
239
+ msg as unknown as Parameters<typeof mod.handleBundleClient>[0],
240
+ send,
241
+ ),
242
+ )
243
+ .catch((err) => {
244
+ send({
245
+ type: "bundle_client_result",
246
+ call_id: (msg as unknown as { call_id: string }).call_id,
247
+ path: "",
248
+ error: err?.message || String(err),
249
+ });
250
+ });
233
251
  } else if (msg.type === "result") {
234
252
  const res = msg as unknown as ResultMessage & { op_id?: string };
235
253
  // Prefer op_id when the host sent it. Fall back to call_id for replies
@@ -0,0 +1,236 @@
1
+ // Regression tests for the Phase 1.5e SSR client bundler.
2
+ //
3
+ // These guard the shape we DEPEND on for shared chunks to actually
4
+ // save bytes:
5
+ // 1. multi-entry build with `splitting: true` produces a
6
+ // `chunks/` subdirectory.
7
+ // 2. React lands in the shared chunk, NOT in any per-route entry
8
+ // (otherwise we'd duplicate ~120KB per page).
9
+ // 3. the manifest names every discovered route, each pointing at
10
+ // exactly one entry file with its preload-list of chunks.
11
+ // 4. adding a third page expands the manifest by one entry, and
12
+ // the new entry stays small (proves splitting is doing work,
13
+ // not just renaming the monolith).
14
+ //
15
+ // We exercise the bundler against a fixture app directory created
16
+ // fresh per test. This keeps the test self-contained and lets us
17
+ // verify behavior on real Bun.build outputs — no mocking.
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import * as os from "node:os";
23
+
24
+ import {
25
+ buildClientBundle,
26
+ type PylonBundleManifest,
27
+ } from "./ssr-client-bundler";
28
+
29
+ // State that needs cleanup between tests.
30
+ let originalCwd: string | null = null;
31
+ let tempDir: string | null = null;
32
+
33
+ function makeFixture(pages: Record<string, string>, layouts: Record<string, string>): string {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-ssr-bundler-test-"));
35
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
36
+ for (const [relPath, source] of Object.entries(pages)) {
37
+ const full = path.join(dir, "app", relPath);
38
+ fs.mkdirSync(path.dirname(full), { recursive: true });
39
+ fs.writeFileSync(full, source, "utf8");
40
+ }
41
+ for (const [relPath, source] of Object.entries(layouts)) {
42
+ const full = path.join(dir, "app", relPath);
43
+ fs.mkdirSync(path.dirname(full), { recursive: true });
44
+ fs.writeFileSync(full, source, "utf8");
45
+ }
46
+ // The bundler resolves `react` and `react-dom/client` relative
47
+ // to CWD. Point at `examples/ssr-hello/node_modules` — that
48
+ // fixture installs them already and we don't want to maintain a
49
+ // second copy.
50
+ const wsRoot = path.resolve(import.meta.dir, "..", "..", "..");
51
+ const reactSource = path.join(
52
+ wsRoot,
53
+ "examples",
54
+ "ssr-hello",
55
+ "node_modules",
56
+ );
57
+ if (!fs.existsSync(reactSource)) {
58
+ throw new Error(
59
+ `react fixture not present at ${reactSource}; run \`bun install\` inside examples/ssr-hello`,
60
+ );
61
+ }
62
+ fs.symlinkSync(reactSource, path.join(dir, "node_modules"));
63
+ return dir;
64
+ }
65
+
66
+ afterEach(() => {
67
+ if (originalCwd) {
68
+ process.chdir(originalCwd);
69
+ originalCwd = null;
70
+ }
71
+ if (tempDir) {
72
+ try {
73
+ fs.rmSync(tempDir, { recursive: true, force: true });
74
+ } catch {
75
+ /* best-effort */
76
+ }
77
+ tempDir = null;
78
+ }
79
+ });
80
+
81
+ const PAGE_BODY = (name: string) => `
82
+ import React, { useState } from "react";
83
+ export default function ${name}() {
84
+ const [n] = useState(0);
85
+ return <div data-page="${name.toLowerCase()}">Hello {n}</div>;
86
+ }
87
+ `;
88
+
89
+ const LAYOUT_BODY = `
90
+ import React from "react";
91
+ export default function Layout({ children }: { children: React.ReactNode }) {
92
+ return <html><body>{children}</body></html>;
93
+ }
94
+ `;
95
+
96
+ describe("ssr-client-bundler (Phase 1.5e)", () => {
97
+ test("multi-entry build emits per-route entries + shared chunks dir", async () => {
98
+ tempDir = makeFixture(
99
+ {
100
+ "page.tsx": PAGE_BODY("Home"),
101
+ "hello/page.tsx": PAGE_BODY("Hello"),
102
+ },
103
+ { "layout.tsx": LAYOUT_BODY },
104
+ );
105
+ originalCwd = process.cwd();
106
+ process.chdir(tempDir);
107
+
108
+ const { manifestPath, outdir } = await buildClientBundle();
109
+
110
+ expect(fs.existsSync(manifestPath)).toBe(true);
111
+ expect(fs.existsSync(outdir)).toBe(true);
112
+ expect(fs.existsSync(path.join(outdir, "chunks"))).toBe(true);
113
+
114
+ const entries = fs
115
+ .readdirSync(outdir)
116
+ .filter((n) => n.startsWith("client-entry-") && n.endsWith(".js"));
117
+ expect(entries.length).toBe(2);
118
+
119
+ const chunks = fs.readdirSync(path.join(outdir, "chunks"));
120
+ expect(chunks.length).toBeGreaterThan(0);
121
+ });
122
+
123
+ test("react-dom appears in the shared chunk, not in any per-route entry", async () => {
124
+ tempDir = makeFixture(
125
+ {
126
+ "page.tsx": PAGE_BODY("Home"),
127
+ "hello/page.tsx": PAGE_BODY("Hello"),
128
+ },
129
+ { "layout.tsx": LAYOUT_BODY },
130
+ );
131
+ originalCwd = process.cwd();
132
+ process.chdir(tempDir);
133
+
134
+ const { outdir } = await buildClientBundle();
135
+
136
+ const entryFiles = fs
137
+ .readdirSync(outdir)
138
+ .filter((n) => n.startsWith("client-entry-") && n.endsWith(".js"))
139
+ .map((n) => path.join(outdir, n));
140
+ const chunkFiles = fs
141
+ .readdirSync(path.join(outdir, "chunks"))
142
+ .map((n) => path.join(outdir, "chunks", n));
143
+
144
+ // The entry files should be TINY — just an import + hydrate
145
+ // call. Anything > a few KB means react was inlined.
146
+ for (const ef of entryFiles) {
147
+ const src = fs.readFileSync(ef, "utf8");
148
+ // No copy of react's reconciler ("Fiber") symbols should be
149
+ // in the entry file.
150
+ expect(src.length).toBeLessThan(5_000);
151
+ expect(src).not.toMatch(/Fiber/);
152
+ }
153
+
154
+ // At least one chunk should contain the react-dom internals.
155
+ const chunkHasReact = chunkFiles.some((cf) => {
156
+ const src = fs.readFileSync(cf, "utf8");
157
+ return /hydrateRoot|reactDom|react-dom/i.test(src);
158
+ });
159
+ expect(chunkHasReact).toBe(true);
160
+ });
161
+
162
+ test("manifest names every route, each with a non-empty imports list", async () => {
163
+ tempDir = makeFixture(
164
+ {
165
+ "page.tsx": PAGE_BODY("Home"),
166
+ "hello/page.tsx": PAGE_BODY("Hello"),
167
+ "about/page.tsx": PAGE_BODY("About"),
168
+ },
169
+ { "layout.tsx": LAYOUT_BODY },
170
+ );
171
+ originalCwd = process.cwd();
172
+ process.chdir(tempDir);
173
+
174
+ const { manifestPath } = await buildClientBundle();
175
+ const manifest = JSON.parse(
176
+ fs.readFileSync(manifestPath, "utf8"),
177
+ ) as PylonBundleManifest;
178
+
179
+ expect(manifest.public_prefix).toBe("/_pylon/build/");
180
+ expect(Object.keys(manifest.routes).sort()).toEqual([
181
+ "app/about/page",
182
+ "app/hello/page",
183
+ "app/page",
184
+ ]);
185
+
186
+ for (const [component, route] of Object.entries(manifest.routes)) {
187
+ expect(route.file).toMatch(/^client-entry-.+\.js$/);
188
+ expect(route.imports.length).toBeGreaterThan(0);
189
+ for (const imp of route.imports) {
190
+ // Should be outdir-relative.
191
+ expect(imp).not.toMatch(/^\//);
192
+ expect(imp).not.toMatch(/^\.\.\//);
193
+ }
194
+ }
195
+ });
196
+
197
+ test("adding a route grows the manifest by one and stays small per-entry", async () => {
198
+ tempDir = makeFixture(
199
+ {
200
+ "page.tsx": PAGE_BODY("Home"),
201
+ "hello/page.tsx": PAGE_BODY("Hello"),
202
+ "about/page.tsx": PAGE_BODY("About"),
203
+ "settings/page.tsx": PAGE_BODY("Settings"),
204
+ },
205
+ { "layout.tsx": LAYOUT_BODY },
206
+ );
207
+ originalCwd = process.cwd();
208
+ process.chdir(tempDir);
209
+
210
+ const { outdir, manifestPath } = await buildClientBundle();
211
+ const manifest = JSON.parse(
212
+ fs.readFileSync(manifestPath, "utf8"),
213
+ ) as PylonBundleManifest;
214
+
215
+ expect(Object.keys(manifest.routes).length).toBe(4);
216
+
217
+ // Per-entry file size cap. Each entry is just an import +
218
+ // hydrate boilerplate; the dominant cost (React) is shared.
219
+ // We allow generous slack for minifier variance but stay
220
+ // well below the "would have been a monolith" baseline of
221
+ // ~320KB.
222
+ for (const route of Object.values(manifest.routes)) {
223
+ const full = path.join(outdir, route.file);
224
+ const size = fs.statSync(full).size;
225
+ expect(size).toBeLessThan(5_000);
226
+ }
227
+
228
+ // All routes should reference the SAME shared chunk so the
229
+ // browser caches it once and reuses across navigations.
230
+ const sharedImports = Object.values(manifest.routes).map(
231
+ (r) => r.imports.join("|"),
232
+ );
233
+ const unique = new Set(sharedImports);
234
+ expect(unique.size).toBe(1);
235
+ });
236
+ });
@@ -0,0 +1,866 @@
1
+ // Build the client-side hydration bundle for a Pylon SSR project.
2
+ //
3
+ // Phase 1.5e: code splitting with shared chunks. The bundler:
4
+ //
5
+ // 1. Discovers `app/**/page.tsx` + `app/**/layout.tsx` under cwd.
6
+ // 2. Generates one tiny `client-runtime.ts` module containing the
7
+ // hydration dispatcher + React imports (the *only* place that
8
+ // pulls in react-dom/client).
9
+ // 3. Generates one per-route entry — `client-entry-<slug>.tsx` —
10
+ // that statically imports its page + its layout chain, then
11
+ // calls into the runtime.
12
+ // 4. Hands every per-route entry to `Bun.build` with
13
+ // `splitting: true` + `metafile: true`. Bun's splitter sees
14
+ // `client-runtime` (and thus React) imported by every entry
15
+ // and extracts it into a shared chunk under `chunks/`.
16
+ // 5. Walks `metafile.outputs` to emit a `manifest.json` keyed on
17
+ // the project-relative component path the SSR side already
18
+ // uses. The manifest lists the route's entry file + every
19
+ // transitive `import` chunk so the SSR HTML head can emit the
20
+ // right `<script type=module>` + `<link rel=modulepreload>`
21
+ // pair.
22
+ //
23
+ // Result for `examples/ssr-hello` (2 routes, 1 layout): one shared
24
+ // `chunks/chunk-*.js` carrying React (~120KB gz), plus two tiny
25
+ // per-route entries (~1-2KB each). A visit to /hello loads the
26
+ // shared chunk + the /hello entry; a subsequent click to / hits the
27
+ // cache for the shared chunk and only pulls the / entry.
28
+ //
29
+ // What it doesn't do (Phase 1.5f+):
30
+ // - File-watcher invalidation. Rebuild requires pylon dev restart.
31
+ // - Source maps. Will enable in dev once the basics are solid.
32
+ // - Link prefetching. <PylonLink> with IntersectionObserver
33
+ // prefetch is a follow-up — splitting is the precondition.
34
+ // - CSS chunking. No CSS support in SSR yet.
35
+
36
+ type Send = (msg: Record<string, unknown>) => void;
37
+
38
+ interface BundleClientMessage {
39
+ type: "bundle_client";
40
+ call_id: string;
41
+ }
42
+
43
+ interface DiscoveredRoute {
44
+ /**
45
+ * Project-relative module path without extension. This is the
46
+ * key the SSR side passes in __PYLON_DATA__.component and the key
47
+ * the manifest is indexed by.
48
+ */
49
+ component: string;
50
+ /** Layout chain root → leaf, same format as `component`. */
51
+ layouts: string[];
52
+ }
53
+
54
+ /** Bun.build returns this shape (the subset we depend on). */
55
+ type BunBuildOutput = {
56
+ success: boolean;
57
+ outputs: Array<{
58
+ path: string;
59
+ kind: string;
60
+ hash?: string;
61
+ text?(): Promise<string>;
62
+ }>;
63
+ logs?: Array<{ level: string; message: string }>;
64
+ };
65
+
66
+ declare const Bun: {
67
+ build(opts: {
68
+ entrypoints: string[];
69
+ outdir?: string;
70
+ target?: "browser" | "bun" | "node";
71
+ format?: "esm" | "iife";
72
+ minify?: boolean;
73
+ sourcemap?: "none" | "inline" | "external";
74
+ external?: string[];
75
+ splitting?: boolean;
76
+ naming?:
77
+ | string
78
+ | {
79
+ entry?: string;
80
+ chunk?: string;
81
+ asset?: string;
82
+ };
83
+ publicPath?: string;
84
+ root?: string;
85
+ }): Promise<BunBuildOutput>;
86
+ file(path: string): { exists(): Promise<boolean> };
87
+ };
88
+
89
+ /**
90
+ * Synchronously walk `app/` under cwd. Returns one entry per
91
+ * discovered page, each carrying its layout chain (root → leaf).
92
+ * Mirrors the discovery logic in @pylonsync/sdk's
93
+ * `discoverAppRoutes` exactly — same sort order, same group-strip,
94
+ * so the in-browser map keys line up with the manifest's component
95
+ * field.
96
+ */
97
+ function discoverRoutes(
98
+ fs: any,
99
+ path: any,
100
+ cwd: string,
101
+ ): DiscoveredRoute[] {
102
+ const appDir = path.join(cwd, "app");
103
+ if (!fs.existsSync(appDir) || !fs.statSync(appDir).isDirectory()) {
104
+ return [];
105
+ }
106
+
107
+ type PageHit = { segments: string[]; component: string; layouts: string[] };
108
+ const pages: PageHit[] = [];
109
+
110
+ function walk(dir: string, segments: string[], layouts: string[]): void {
111
+ let entries: Array<{ name: string; isDirectory(): boolean }>;
112
+ try {
113
+ entries = fs.readdirSync(dir, { withFileTypes: true });
114
+ } catch {
115
+ return;
116
+ }
117
+ const layoutHere = ["layout.tsx", "layout.ts", "layout.jsx", "layout.js"]
118
+ .map((n: string) => path.join(dir, n))
119
+ .find((p: string) => fs.existsSync(p));
120
+ const nextLayouts = layoutHere
121
+ ? [...layouts, path.relative(cwd, layoutHere).replace(/\.(tsx?|jsx?)$/, "")]
122
+ : layouts;
123
+ const pageHere = ["page.tsx", "page.ts", "page.jsx", "page.js"]
124
+ .map((n: string) => path.join(dir, n))
125
+ .find((p: string) => fs.existsSync(p));
126
+ if (pageHere) {
127
+ pages.push({
128
+ segments: [...segments],
129
+ component: path.relative(cwd, pageHere).replace(/\.(tsx?|jsx?)$/, ""),
130
+ layouts: nextLayouts,
131
+ });
132
+ }
133
+ for (const e of entries) {
134
+ if (!e.isDirectory()) continue;
135
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
136
+ const sub = path.join(dir, e.name);
137
+ const isGroup = e.name.startsWith("(") && e.name.endsWith(")");
138
+ const newSegments = isGroup ? segments : [...segments, e.name];
139
+ walk(sub, newSegments, nextLayouts);
140
+ }
141
+ }
142
+ walk(appDir, [], []);
143
+
144
+ return pages.map((p) => ({
145
+ component: p.component,
146
+ layouts: p.layouts,
147
+ }));
148
+ }
149
+
150
+ /**
151
+ * The shared hydration dispatcher + router. ONE module, imported
152
+ * by every per-route entry. Bun's splitter sees N entries reach
153
+ * for it and pulls it (and React via the transitive imports) into
154
+ * a shared chunk.
155
+ *
156
+ * Two responsibilities:
157
+ * 1. `hydrate(component, Page, Layouts)` — called by each route
158
+ * entry. The first call (matching the SSR'd component) calls
159
+ * hydrateRoot; subsequent calls (after a client-side
160
+ * navigation) re-render the cached root with the new tree.
161
+ * Either way, the entry also registers itself in the route
162
+ * cache so future navigations don't need to refetch the chunk.
163
+ * 2. `navigate(href)` — fetches the new SSR HTML, parses out the
164
+ * `__PYLON_DATA__` payload, dynamically loads the new route's
165
+ * entry from the manifest, and re-renders into the existing
166
+ * React root. Layouts that match the previous render survive
167
+ * reconciliation (React keeps their state).
168
+ *
169
+ * Global click handler intercepts `a[data-pylon-link]` clicks for
170
+ * client-side nav. popstate handles back/forward. The runtime
171
+ * exposes `window.__pylon` for the `<Link>` component to call
172
+ * directly (prefetch, navigate).
173
+ */
174
+ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime).
175
+ // DO NOT EDIT — overwritten on every pylon dev / build.
176
+
177
+ import { createElement } from "react";
178
+ import { hydrateRoot } from "react-dom/client";
179
+
180
+ const routeCache = Object.create(null);
181
+ let activeRoot = null;
182
+ let manifestPromise = null;
183
+ const prefetchedChunks = new Set();
184
+
185
+ function buildTree(Page, Layouts, props) {
186
+ let tree = createElement(Page, props);
187
+ for (let i = Layouts.length - 1; i >= 0; i--) {
188
+ const Layout = Layouts[i];
189
+ if (!Layout) continue;
190
+ tree = createElement(Layout, props, tree);
191
+ }
192
+ return tree;
193
+ }
194
+
195
+ function readPylonData() {
196
+ const dataEl = document.getElementById("__PYLON_DATA__");
197
+ if (!dataEl) return null;
198
+ try {
199
+ return JSON.parse(dataEl.textContent || "{}");
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ async function loadManifest() {
206
+ if (!manifestPromise) {
207
+ manifestPromise = fetch("/_pylon/build/manifest.json", {
208
+ credentials: "same-origin",
209
+ })
210
+ .then((r) => (r.ok ? r.json() : null))
211
+ .catch(() => null);
212
+ }
213
+ return manifestPromise;
214
+ }
215
+
216
+ function preloadChunks(routeInfo) {
217
+ if (!routeInfo) return;
218
+ const prefix = routeInfo.public_prefix || "/_pylon/build/";
219
+ const all = [routeInfo.file, ...(routeInfo.imports || [])];
220
+ for (const c of all) {
221
+ if (prefetchedChunks.has(c)) continue;
222
+ prefetchedChunks.add(c);
223
+ const link = document.createElement("link");
224
+ link.rel = "modulepreload";
225
+ link.href = prefix + c;
226
+ document.head.appendChild(link);
227
+ }
228
+ }
229
+
230
+ async function prefetch(href) {
231
+ // HTML prefetch — primes the SSR response cache.
232
+ const url = new URL(href, location.href);
233
+ if (url.origin !== location.origin) return;
234
+ if (!document.querySelector('link[rel="prefetch"][href="' + url.pathname + '"]')) {
235
+ const html = document.createElement("link");
236
+ html.rel = "prefetch";
237
+ html.as = "document";
238
+ html.href = url.pathname + url.search;
239
+ document.head.appendChild(html);
240
+ }
241
+ // Chunk prefetch — peek at the manifest, but we don't know the
242
+ // component path from the href without server help. v1: rely on
243
+ // the shared chunk already being cached + the SSR head emitting
244
+ // the right preload tags after the user lands. So all prefetch
245
+ // does today is HTML — chunk dedup happens via the manifest.
246
+ const manifest = await loadManifest();
247
+ if (manifest) {
248
+ // Pre-warm all routes' shared chunks (one chunk in practice).
249
+ const shared = new Set();
250
+ for (const r of Object.values(manifest.routes || {})) {
251
+ for (const i of r.imports || []) shared.add(i);
252
+ }
253
+ const info = { public_prefix: manifest.public_prefix, file: "", imports: Array.from(shared) };
254
+ preloadChunks(info);
255
+ }
256
+ }
257
+
258
+ async function loadRouteEntry(component) {
259
+ if (routeCache[component]) return routeCache[component];
260
+ const manifest = await loadManifest();
261
+ if (!manifest) throw new Error("manifest unavailable");
262
+ const routeInfo = manifest.routes[component];
263
+ if (!routeInfo) throw new Error("unknown component " + component);
264
+ const prefix = manifest.public_prefix || "/_pylon/build/";
265
+ preloadChunks({ ...routeInfo, public_prefix: prefix });
266
+ // Dynamic import the entry. It will call hydrate(...) which
267
+ // populates routeCache[component], then this returns.
268
+ await import(/* @vite-ignore */ prefix + routeInfo.file);
269
+ if (!routeCache[component]) {
270
+ throw new Error("route entry did not register: " + component);
271
+ }
272
+ return routeCache[component];
273
+ }
274
+
275
+ export function hydrate(component, Page, Layouts) {
276
+ // Always cache the route for nav.
277
+ routeCache[component] = { Page, Layouts };
278
+ const data = readPylonData();
279
+ // First hydrate: the entry's component MATCHES the SSR'd page.
280
+ // Establish the root + install the click + popstate handlers
281
+ // exactly once.
282
+ if (!activeRoot) {
283
+ if (!data || data.component !== component) {
284
+ console.warn(
285
+ "[pylon ssr] entry/__PYLON_DATA__ mismatch — initial hydration skipped",
286
+ );
287
+ return;
288
+ }
289
+ const tree = buildTree(Page, Layouts, data.props);
290
+ activeRoot = hydrateRoot(document, tree);
291
+ installNavHandlers();
292
+ return;
293
+ }
294
+ // Subsequent hydrate calls fire from dynamic-loaded entries
295
+ // during navigation. The render is driven by navigate(), so we
296
+ // only need to populate the cache (already done above).
297
+ }
298
+
299
+ async function navigate(href, opts) {
300
+ const push = !opts || opts.push !== false;
301
+ const url = new URL(href, location.href);
302
+ if (url.origin !== location.origin) {
303
+ window.location.href = href;
304
+ return;
305
+ }
306
+ let html;
307
+ try {
308
+ const res = await fetch(url.pathname + url.search, {
309
+ credentials: "same-origin",
310
+ headers: { Accept: "text/html" },
311
+ });
312
+ if (!res.ok) {
313
+ window.location.href = href;
314
+ return;
315
+ }
316
+ html = await res.text();
317
+ } catch {
318
+ window.location.href = href;
319
+ return;
320
+ }
321
+ const doc = new DOMParser().parseFromString(html, "text/html");
322
+ const dataEl = doc.getElementById("__PYLON_DATA__");
323
+ if (!dataEl) {
324
+ window.location.href = href;
325
+ return;
326
+ }
327
+ let data;
328
+ try {
329
+ data = JSON.parse(dataEl.textContent || "{}");
330
+ } catch {
331
+ window.location.href = href;
332
+ return;
333
+ }
334
+ let route;
335
+ try {
336
+ route = await loadRouteEntry(data.component);
337
+ } catch (e) {
338
+ console.warn("[pylon ssr] nav fallback (entry load failed):", e);
339
+ window.location.href = href;
340
+ return;
341
+ }
342
+ document.title = doc.title || document.title;
343
+ const tree = buildTree(route.Page, route.Layouts, data.props);
344
+ activeRoot.render(tree);
345
+ if (push) {
346
+ history.pushState({ component: data.component }, "", url.pathname + url.search);
347
+ }
348
+ // After a successful nav, scroll to top (Next.js default).
349
+ window.scrollTo(0, 0);
350
+ }
351
+
352
+ function installNavHandlers() {
353
+ document.addEventListener("click", (e) => {
354
+ if (e.defaultPrevented) return;
355
+ if (e.button !== 0) return;
356
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
357
+ const target = e.target;
358
+ if (!target || !target.closest) return;
359
+ const link = target.closest("a[data-pylon-link]");
360
+ if (!link) return;
361
+ const href = link.getAttribute("href");
362
+ if (!href) return;
363
+ if (link.target && link.target !== "" && link.target !== "_self") return;
364
+ if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) {
365
+ // External — let the browser handle.
366
+ return;
367
+ }
368
+ e.preventDefault();
369
+ navigate(href);
370
+ });
371
+ window.addEventListener("popstate", () => {
372
+ navigate(location.pathname + location.search, { push: false });
373
+ });
374
+ }
375
+
376
+ // Expose for <Link> component prefetch.
377
+ const pylonGlobal = { prefetch, navigate };
378
+ if (typeof window !== "undefined") {
379
+ window.__pylon = pylonGlobal;
380
+ }
381
+ `;
382
+
383
+ /**
384
+ * Per-route entry source. Stays tiny on purpose — react /
385
+ * react-dom / client-runtime get hoisted into the shared chunk
386
+ * by the splitter, so this body ends up as roughly "load the
387
+ * shared chunk, then call hydrate(Page, [L0, L1, ...])".
388
+ *
389
+ * Each route gets its own entry file under `.pylon/`. The entry
390
+ * path for component `app/hello/page` is
391
+ * `.pylon/client-entry-app__hello__page.tsx` — flat namespace
392
+ * keyed on the slug we'd already need anyway for the manifest.
393
+ */
394
+ function generateRouteEntry(route: DiscoveredRoute): string {
395
+ const layoutImports = route.layouts
396
+ .map((l, i) => `import L${i} from "${cwd_to_import(l)}";`)
397
+ .join("\n");
398
+ const layoutArray = route.layouts.map((_, i) => `L${i}`).join(", ");
399
+ return `// Generated by Pylon SSR (Phase 2 per-route entry).
400
+ // DO NOT EDIT — overwritten on every pylon dev / build.
401
+
402
+ import { hydrate } from "./client-runtime";
403
+ import Page from "${cwd_to_import(route.component)}";
404
+ ${layoutImports}
405
+
406
+ hydrate(${JSON.stringify(route.component)}, Page, [${layoutArray}]);
407
+ `;
408
+ }
409
+
410
+ /**
411
+ * Bun's static import-path resolution runs against the source
412
+ * file's directory. Per-route entries live at
413
+ * `<cwd>/.pylon/client-entry-<slug>.tsx`, so reaching
414
+ * `<cwd>/app/page.tsx` is `../app/page`. The shared runtime stays
415
+ * at `./client-runtime` since it sits next to the entries.
416
+ */
417
+ function cwd_to_import(modulePath: string): string {
418
+ return `../${modulePath}`;
419
+ }
420
+
421
+ /**
422
+ * Project-relative component path → filename-safe slug.
423
+ * `app/hello/page` → `app__hello__page`. Used for the entry
424
+ * filename and (after Bun appends the hash) for the manifest key
425
+ * mapping back to the component path.
426
+ */
427
+ function slugForComponent(component: string): string {
428
+ return component.replace(/[^A-Za-z0-9_]/g, "__");
429
+ }
430
+
431
+ /**
432
+ * Manifest schema. One entry per route, indexed by the same
433
+ * project-relative component path the SSR side passes through.
434
+ * `file` is the entry chunk, `imports` is the transitive set of
435
+ * shared chunks the browser needs to load BEFORE the entry runs
436
+ * — that's the modulepreload list.
437
+ *
438
+ * Paths in the manifest are relative to `.pylon/client-build/` so
439
+ * the Rust host can serve them under `/_pylon/build/<path>` with
440
+ * no rewriting.
441
+ */
442
+ export interface PylonBundleManifest {
443
+ /** Build identity — bumps every successful build. */
444
+ build_id: string;
445
+ /** Output root, relative to cwd (always `.pylon/client-build`). */
446
+ outdir: string;
447
+ /** Public URL prefix the Rust host serves chunks under. */
448
+ public_prefix: string;
449
+ /** routeComponentPath → file + imports for that route. */
450
+ routes: Record<
451
+ string,
452
+ {
453
+ /** Per-route entry file, relative to outdir. */
454
+ file: string;
455
+ /** Transitive shared chunks to modulepreload, relative to outdir. */
456
+ imports: string[];
457
+ /** CSS chunks (Phase 1.5f). */
458
+ css: string[];
459
+ }
460
+ >;
461
+ }
462
+
463
+ /** Result of an in-process build — same shape the protocol returns. */
464
+ export interface BuildOutput {
465
+ manifestPath: string;
466
+ outdir: string;
467
+ }
468
+
469
+ /**
470
+ * Single-flight in-process build promise. SSR + asset-route handlers
471
+ * both reach for `buildClientBundle()` lazily, so without dedup we
472
+ * could fire two concurrent Bun.build calls under load and trample
473
+ * each other's outputs (especially the `rm -rf outdir` step). The
474
+ * Promise is kept as long as a build is in flight, then cleared so
475
+ * the next invalidation re-builds.
476
+ */
477
+ let _inflightBuild: Promise<BuildOutput> | null = null;
478
+
479
+ /**
480
+ * Run the bundler in-process and return the manifest path + outdir.
481
+ * Used from `handleBundleClient` (protocol RPC path from Rust) AND
482
+ * from `getManifest` (in-process SSR path).
483
+ */
484
+ export async function buildClientBundle(): Promise<BuildOutput> {
485
+ if (_inflightBuild) return _inflightBuild;
486
+ _inflightBuild = (async () => {
487
+ try {
488
+ return await _doBuild();
489
+ } finally {
490
+ _inflightBuild = null;
491
+ }
492
+ })();
493
+ return _inflightBuild;
494
+ }
495
+
496
+ async function _doBuild(): Promise<BuildOutput> {
497
+ // node:* are available in Bun, but `globalThis.require` is
498
+ // not defined in ESM. Use dynamic import; Bun fast-paths these.
499
+ const fsMod: any = await import("node:fs");
500
+ const pathMod: any = await import("node:path");
501
+ const fs = fsMod.default ?? fsMod;
502
+ const path = pathMod.default ?? pathMod;
503
+ const cwd = process.cwd();
504
+ return _doBuildInner(fs, path, cwd);
505
+ }
506
+
507
+ /**
508
+ * Compile `app/globals.css` through Tailwind v4 (`@tailwindcss/cli`)
509
+ * if both are present. Returns the relative output path (under
510
+ * outdir) when produced, else null. Skipped silently when the
511
+ * project hasn't opted in to Tailwind — we don't want every SSR
512
+ * project to need Tailwind installed.
513
+ */
514
+ async function buildTailwind(
515
+ fs: any,
516
+ path: any,
517
+ cwd: string,
518
+ outdir: string,
519
+ ): Promise<string | null> {
520
+ const globalsPath = path.join(cwd, "app", "globals.css");
521
+ if (!fs.existsSync(globalsPath)) return null;
522
+ // Resolve @tailwindcss/cli. The package only exports
523
+ // `./package.json` in its `exports` map (it's a binary, not a
524
+ // library), so we resolve THAT and reach for `dist/index.mjs`
525
+ // next to it. If the dep isn't installed, surface a clear hint.
526
+ let cliPath: string;
527
+ try {
528
+ const pkgPath = (Bun as any).resolveSync(
529
+ "@tailwindcss/cli/package.json",
530
+ cwd,
531
+ );
532
+ cliPath = path.join(path.dirname(pkgPath), "dist", "index.mjs");
533
+ if (!fs.existsSync(cliPath)) {
534
+ throw new Error(`tailwindcss CLI entry not found at ${cliPath}`);
535
+ }
536
+ } catch (err: any) {
537
+ throw new Error(
538
+ `app/globals.css exists but @tailwindcss/cli is not installed — run \`bun add @tailwindcss/cli tailwindcss\` (resolver said: ${err?.message ?? err})`,
539
+ );
540
+ }
541
+
542
+ // Hash the source content so we can name the output uniquely +
543
+ // serve it with long-cache immutable headers.
544
+ const src = fs.readFileSync(globalsPath, "utf8");
545
+ let hash = 0;
546
+ for (let i = 0; i < src.length; i++) {
547
+ hash = (hash * 31 + src.charCodeAt(i)) >>> 0;
548
+ }
549
+ // Mix in the discovered routes so adding/removing pages changes
550
+ // the hash (Tailwind v4 auto-discovers `@source` paths; we still
551
+ // want the cache to bust on layout changes).
552
+ const stylesName = `styles-${hash.toString(36)}.css`;
553
+ const outPath = path.join(outdir, stylesName);
554
+
555
+ // Spawn the CLI. Bun is already running; reuse it as the
556
+ // interpreter so the user doesn't need node on PATH.
557
+ const proc = (Bun as any).spawn({
558
+ cmd: [process.execPath, cliPath, "-i", globalsPath, "-o", outPath, "--minify"],
559
+ cwd,
560
+ stdout: "pipe",
561
+ stderr: "pipe",
562
+ });
563
+ const exitCode = await proc.exited;
564
+ if (exitCode !== 0) {
565
+ const err = await new Response(proc.stderr).text();
566
+ throw new Error(`tailwindcss build failed (exit ${exitCode}): ${err}`);
567
+ }
568
+ return stylesName;
569
+ }
570
+
571
+ async function _doBuildInner(fs: any, path: any, cwd: string): Promise<BuildOutput> {
572
+ const routes = discoverRoutes(fs, path, cwd);
573
+ if (routes.length === 0) {
574
+ throw new Error("no SSR routes discovered under app/ — nothing to bundle");
575
+ }
576
+
577
+ const stageDir = path.join(cwd, ".pylon");
578
+ fs.mkdirSync(stageDir, { recursive: true });
579
+
580
+ // Wipe stale per-route entries so deletions are picked up.
581
+ // Hashed chunk outputs live under client-build/ and are wiped
582
+ // by the same `rm -rf` so renamed routes don't leave orphans.
583
+ for (const name of fs.readdirSync(stageDir)) {
584
+ if (name.startsWith("client-entry-")) {
585
+ try {
586
+ fs.unlinkSync(path.join(stageDir, name));
587
+ } catch {
588
+ /* ignore */
589
+ }
590
+ }
591
+ }
592
+ const outdir = path.join(stageDir, "client-build");
593
+ try {
594
+ fs.rmSync(outdir, { recursive: true, force: true });
595
+ } catch {
596
+ /* ignore */
597
+ }
598
+ fs.mkdirSync(outdir, { recursive: true });
599
+
600
+ // The shared runtime + per-route entries. We track which file
601
+ // each entry corresponds to so we can match metafile.outputs
602
+ // back to the original route afterwards.
603
+ const runtimePath = path.join(stageDir, "client-runtime.ts");
604
+ fs.writeFileSync(runtimePath, CLIENT_RUNTIME_SOURCE, "utf8");
605
+
606
+ const entryPaths: string[] = [];
607
+ // entryPath (absolute) → component path (for manifest lookup).
608
+ const entryToComponent = new Map<string, string>();
609
+ for (const r of routes) {
610
+ const slug = slugForComponent(r.component);
611
+ const entryPath = path.join(stageDir, `client-entry-${slug}.tsx`);
612
+ fs.writeFileSync(entryPath, generateRouteEntry(r), "utf8");
613
+ entryPaths.push(entryPath);
614
+ entryToComponent.set(entryPath, r.component);
615
+ }
616
+
617
+ // splitting: true gates code-splitting. Bun 1.3.14 does NOT
618
+ // expose a `metafile` flag — its build result lists per-output
619
+ // `path` + `kind` (`entry-point` vs `chunk`) but no import
620
+ // graph. We recover the per-entry preload set by parsing the
621
+ // entry files' literal `import "./chunks/<name>.js"`
622
+ // statements after the build.
623
+ const result = await Bun.build({
624
+ entrypoints: entryPaths,
625
+ outdir,
626
+ target: "browser",
627
+ format: "esm",
628
+ minify: true,
629
+ sourcemap: "none",
630
+ splitting: true,
631
+ naming: {
632
+ entry: "[name]-[hash].js",
633
+ chunk: "chunks/[name]-[hash].js",
634
+ asset: "assets/[name]-[hash][ext]",
635
+ },
636
+ });
637
+
638
+ if (!result.success) {
639
+ const msgs = (result.logs ?? [])
640
+ .map((l) => `${l.level}: ${l.message}`)
641
+ .join("\n");
642
+ throw new Error(`Bun.build failed:\n${msgs || "(no log messages)"}`);
643
+ }
644
+
645
+ // Index outputs:
646
+ // - entries (kind === "entry-point") — matched to components
647
+ // by filename stem (`client-entry-<slug>`).
648
+ // - chunks (kind === "chunk") — looked up by basename when
649
+ // scanning entry files for static `import "./chunks/..."`
650
+ // specifiers.
651
+ const outdirRel = path.relative(cwd, outdir);
652
+ const entriesByStem = new Map<
653
+ string,
654
+ { absPath: string; relPath: string }
655
+ >();
656
+ const chunksByBasename = new Map<
657
+ string,
658
+ { absPath: string; relPath: string }
659
+ >();
660
+ for (const o of result.outputs) {
661
+ const absPath: string = o.path;
662
+ const relPath = path.relative(outdir, absPath);
663
+ const base = path.basename(absPath);
664
+ // Strip `-<hash>.js` to recover the entry source's stem
665
+ // (e.g. `client-entry-app__hello__page`). The hash is
666
+ // alphanumeric (Bun uses base36-ish), the slug we wrote is
667
+ // `[A-Za-z0-9_]+`, so splitting on the LAST `-<hash>.js` is
668
+ // unambiguous.
669
+ const stem = base.replace(/-[A-Za-z0-9]+\.(?:m?js)$/, "");
670
+ if (o.kind === "entry-point") {
671
+ entriesByStem.set(stem, { absPath, relPath });
672
+ } else if (o.kind === "chunk") {
673
+ chunksByBasename.set(base, { absPath, relPath });
674
+ }
675
+ }
676
+
677
+ // Scan a built JS file for static `import` literals pointing
678
+ // at `./chunks/<file>.js` and return them resolved to outdir-
679
+ // relative paths. Bun's minified output uses simple double
680
+ // quotes for module specifiers, so a `matchAll` covers both
681
+ // `import X from "Y"` and bare `import "Y"`.
682
+ function scanChunkImports(jsAbsPath: string): string[] {
683
+ let src: string;
684
+ try {
685
+ src = fs.readFileSync(jsAbsPath, "utf8");
686
+ } catch {
687
+ return [];
688
+ }
689
+ const found = new Set<string>();
690
+ const matches = src.matchAll(
691
+ /(?:from\s*|import\s*\(?\s*)["']([^"']+)["']/g,
692
+ );
693
+ for (const m of matches) {
694
+ const spec = m[1];
695
+ if (spec.startsWith("./chunks/") || spec.startsWith("chunks/")) {
696
+ const base = path.basename(spec);
697
+ const hit = chunksByBasename.get(base);
698
+ if (hit) found.add(hit.relPath);
699
+ }
700
+ }
701
+ return Array.from(found);
702
+ }
703
+
704
+ const manifest: PylonBundleManifest = {
705
+ build_id: makeBuildId(),
706
+ outdir: outdirRel,
707
+ public_prefix: "/_pylon/build/",
708
+ routes: {},
709
+ };
710
+ for (const r of routes) {
711
+ const slug = slugForComponent(r.component);
712
+ const stem = `client-entry-${slug}`;
713
+ const entry = entriesByStem.get(stem);
714
+ if (!entry) continue;
715
+ // Walk transitively in case Bun emits chunks that reference
716
+ // other chunks (rare in the single-level splitting we use,
717
+ // but free to compute).
718
+ const seen = new Set<string>();
719
+ const queue: string[] = scanChunkImports(entry.absPath);
720
+ for (const q of queue) seen.add(q);
721
+ while (queue.length > 0) {
722
+ const relChunk = queue.shift()!;
723
+ const absChunk = path.join(outdir, relChunk);
724
+ for (const nested of scanChunkImports(absChunk)) {
725
+ if (!seen.has(nested)) {
726
+ seen.add(nested);
727
+ queue.push(nested);
728
+ }
729
+ }
730
+ }
731
+ manifest.routes[r.component] = {
732
+ file: entry.relPath,
733
+ imports: Array.from(seen),
734
+ css: [],
735
+ };
736
+ }
737
+
738
+ // Bail loudly if discovery succeeded but the manifest came
739
+ // out empty — means our entryPoint → component matching broke
740
+ // and SSR will silently hydration-skip.
741
+ if (Object.keys(manifest.routes).length === 0) {
742
+ throw new Error(
743
+ "manifest is empty after build — entryPoint matching against metafile failed",
744
+ );
745
+ }
746
+ if (Object.keys(manifest.routes).length !== routes.length) {
747
+ const missing = routes
748
+ .filter((r) => !(r.component in manifest.routes))
749
+ .map((r) => r.component);
750
+ throw new Error(
751
+ `manifest missing entries for routes: ${missing.join(", ")}`,
752
+ );
753
+ }
754
+
755
+ // Tailwind v4 compile. Optional — only fires if the project has
756
+ // `app/globals.css`. Adds the stylesheet to every route's css
757
+ // array so SSR head injection emits `<link rel="stylesheet">`.
758
+ try {
759
+ const styles = await buildTailwind(fs, path, cwd, outdir);
760
+ if (styles) {
761
+ for (const r of Object.values(manifest.routes)) {
762
+ r.css = [styles];
763
+ }
764
+ }
765
+ } catch (twErr: any) {
766
+ // Tailwind failure shouldn't kill the SSR build — log a loud
767
+ // warning + ship the bundle without styles so devs can iterate.
768
+ // eslint-disable-next-line no-console
769
+ console.warn(`[pylon ssr] tailwind compile failed: ${twErr?.message ?? twErr}`);
770
+ }
771
+
772
+ const manifestPath = path.join(outdir, "manifest.json");
773
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
774
+
775
+ // Bump our in-process manifest cache so SSR re-reads on next request.
776
+ _manifestCache = null;
777
+
778
+ return { manifestPath, outdir };
779
+ }
780
+
781
+ /**
782
+ * Cached parsed manifest for the SSR head-injection path. Keyed on
783
+ * mtime so an external `pylon build` that overwrites manifest.json
784
+ * gets picked up by the next SSR request without a process restart.
785
+ */
786
+ let _manifestCache: { mtimeMs: number; data: PylonBundleManifest } | null = null;
787
+
788
+ /**
789
+ * Return the bundle manifest. If a fresh manifest exists on disk,
790
+ * use it (caching parse output across requests). Otherwise build
791
+ * the bundle in-process (deduped by `buildClientBundle`) and read.
792
+ *
793
+ * Called from `ssr-runtime.ts` per-request, so the disk-stat fast
794
+ * path matters. Bun's `fs.statSync` is a ~5µs syscall; cheap enough
795
+ * that we don't gate it on a flag.
796
+ */
797
+ export async function getManifest(): Promise<PylonBundleManifest> {
798
+ const fsMod: any = await import("node:fs");
799
+ const pathMod: any = await import("node:path");
800
+ const fs = fsMod.default ?? fsMod;
801
+ const path = pathMod.default ?? pathMod;
802
+ const cwd = process.cwd();
803
+ const manifestPath = path.join(cwd, ".pylon", "client-build", "manifest.json");
804
+
805
+ if (fs.existsSync(manifestPath)) {
806
+ const stat = fs.statSync(manifestPath);
807
+ if (_manifestCache && _manifestCache.mtimeMs === stat.mtimeMs) {
808
+ return _manifestCache.data;
809
+ }
810
+ const raw = fs.readFileSync(manifestPath, "utf8");
811
+ const data = JSON.parse(raw) as PylonBundleManifest;
812
+ _manifestCache = { mtimeMs: stat.mtimeMs, data };
813
+ return data;
814
+ }
815
+
816
+ // Manifest missing → build in-process, then read.
817
+ const { manifestPath: built } = await buildClientBundle();
818
+ const raw = fs.readFileSync(built, "utf8");
819
+ const data = JSON.parse(raw) as PylonBundleManifest;
820
+ const stat = fs.statSync(built);
821
+ _manifestCache = { mtimeMs: stat.mtimeMs, data };
822
+ return data;
823
+ }
824
+
825
+ /**
826
+ * Protocol entry. Builds + responds. Rust calls this via the
827
+ * `bundle_client` RPC; on success the response carries both
828
+ * the manifest path (so Rust can load it if it wants, today it
829
+ * doesn't) and the outdir (so `/_pylon/build/<rel>` serves the
830
+ * right tree).
831
+ */
832
+ export async function handleBundleClient(
833
+ msg: BundleClientMessage,
834
+ send: Send,
835
+ ): Promise<void> {
836
+ try {
837
+ const { manifestPath, outdir } = await buildClientBundle();
838
+ send({
839
+ type: "bundle_client_result",
840
+ call_id: msg.call_id,
841
+ path: manifestPath,
842
+ outdir,
843
+ });
844
+ } catch (err: any) {
845
+ send({
846
+ type: "bundle_client_result",
847
+ call_id: msg.call_id,
848
+ path: "",
849
+ outdir: "",
850
+ error: err?.message || String(err),
851
+ });
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Stable-ish build id. We don't have Date.now() in workflow scripts
857
+ * but Bun's runtime is fine — performance.now() + a counter would
858
+ * also do. Falling back to a randomish hex string keyed on the
859
+ * process pid + a monotonic counter is good enough for telling
860
+ * "did the bundle change" without claiming to be cryptographic.
861
+ */
862
+ let _buildCounter = 0;
863
+ function makeBuildId(): string {
864
+ _buildCounter += 1;
865
+ return `${process.pid.toString(36)}-${Date.now().toString(36)}-${_buildCounter}`;
866
+ }
@@ -19,6 +19,23 @@ export interface RenderRouteMessage {
19
19
  * adapter joins cwd + this + the right extension (.tsx → .ts).
20
20
  */
21
21
  component: string;
22
+ /**
23
+ * Project-relative module paths for the layout chain, walked
24
+ * root → leaf. Each layout's default export wraps the next as
25
+ * `children`. Absent / empty when no layouts apply.
26
+ *
27
+ * Example:
28
+ * layouts: ["app/layout", "app/blog/layout"]
29
+ * component: "app/blog/[slug]/page"
30
+ *
31
+ * Resolves to:
32
+ * <RootLayout>
33
+ * <BlogLayout>
34
+ * <Page {...props} />
35
+ * </BlogLayout>
36
+ * </RootLayout>
37
+ */
38
+ layouts?: string[];
22
39
  /** The matched route pattern (e.g. `/blog/:slug`). */
23
40
  route_path: string;
24
41
  /** The incoming URL path (e.g. `/blog/hello-world`). */
@@ -133,7 +150,51 @@ export async function handleRenderRoute(
133
150
  auth: msg.auth,
134
151
  };
135
152
 
136
- const element = React.createElement(Component, props);
153
+ // Resolve the layout chain. Each layout module exports a default
154
+ // function that accepts the same props + `children`. Walk leaf →
155
+ // root: start with the page component as `tree`, then for each
156
+ // layout (innermost first) wrap it as the new tree. Result is
157
+ // the outermost layout containing all nested layouts down to
158
+ // the page.
159
+ let tree: any = React.createElement(Component, props);
160
+ const layouts = msg.layouts ?? [];
161
+ if (layouts.length > 0) {
162
+ // Resolve all layouts first so we fail fast on a missing one
163
+ // BEFORE we start emitting headers / chunks.
164
+ const layoutMods: any[] = [];
165
+ for (const layoutPath of layouts) {
166
+ const lBase = `${cwd}/${layoutPath}`;
167
+ let lMod: any = null;
168
+ for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
169
+ try {
170
+ lMod = await import(`${lBase}${ext}`);
171
+ break;
172
+ } catch {
173
+ // try next extension
174
+ }
175
+ }
176
+ if (!lMod) {
177
+ throw new Error(
178
+ `could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
179
+ );
180
+ }
181
+ const LayoutComp =
182
+ lMod.default ?? lMod.Layout ?? lMod.layout;
183
+ if (typeof LayoutComp !== "function") {
184
+ throw new Error(
185
+ `layout "${layoutPath}" has no default export (or named export "Layout")`,
186
+ );
187
+ }
188
+ layoutMods.push(LayoutComp);
189
+ }
190
+ // Walk LEAF → ROOT (reverse iteration on the layouts array).
191
+ // The innermost layout wraps the page first; each outer layout
192
+ // wraps the result.
193
+ for (let i = layoutMods.length - 1; i >= 0; i--) {
194
+ tree = React.createElement(layoutMods[i], props, tree);
195
+ }
196
+ }
197
+ const element = tree;
137
198
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
138
199
  element,
139
200
  {
@@ -157,20 +218,144 @@ export async function handleRenderRoute(
157
218
  headers: { "content-type": "text/html; charset=utf-8" },
158
219
  });
159
220
 
221
+ // Pre-load the manifest BEFORE the React stream starts emitting
222
+ // so we know which `<link rel="stylesheet">` and
223
+ // `<link rel="modulepreload">` tags to inject into the HEAD.
224
+ // We splice them in before `</head>` so the browser starts
225
+ // fetching CSS + chunks concurrently with parsing the body —
226
+ // no FOUC, no waterfall.
227
+ let preloadManifestRoute:
228
+ | { file: string; imports: string[]; css: string[] }
229
+ | null = null;
230
+ let preloadManifestErr: string | null = null;
231
+ let preloadPublicPrefix = "/_pylon/build/";
232
+ try {
233
+ const { getManifest } = await import("./ssr-client-bundler");
234
+ const manifest = await getManifest();
235
+ preloadPublicPrefix = manifest.public_prefix || preloadPublicPrefix;
236
+ preloadManifestRoute = manifest.routes[msg.component] ?? null;
237
+ if (!preloadManifestRoute) {
238
+ preloadManifestErr = `manifest has no entry for "${msg.component}"`;
239
+ }
240
+ } catch (e: any) {
241
+ preloadManifestErr = e?.message || String(e);
242
+ }
243
+
244
+ // Build the head-injection blob — stylesheet first, then
245
+ // modulepreloads. The entry script tag stays in the body-tail
246
+ // (it needs the inline __PYLON_DATA__ to have been parsed first).
247
+ let headBlob = "";
248
+ if (preloadManifestRoute) {
249
+ for (const css of preloadManifestRoute.css) {
250
+ headBlob += `<link rel="stylesheet" href="${preloadPublicPrefix}${css}">`;
251
+ }
252
+ for (const chunk of preloadManifestRoute.imports) {
253
+ headBlob += `<link rel="modulepreload" href="${preloadPublicPrefix}${chunk}">`;
254
+ }
255
+ }
256
+
257
+ // Stream-rewrite: watch for `</head>` and inject `headBlob`
258
+ // before it. `</head>` may straddle chunk boundaries so we
259
+ // keep a small carry buffer (7 bytes — len("</head>")) at the
260
+ // tail of each chunk.
160
261
  const reader = stream.getReader();
161
- while (true) {
162
- const { value, done } = await reader.read();
163
- if (done) break;
164
- if (!value || value.byteLength === 0) continue;
165
- // base64 in pure JS via Buffer (Bun ships it). For large
166
- // pages this is O(n) per chunk; fine for Phase 1.
167
- const b64 = Buffer.from(value).toString("base64");
262
+ let headInjected = headBlob.length === 0;
263
+ let carry = ""; // utf8 tail from previous chunk for boundary detection
264
+ const HEAD_CLOSE = "</head>";
265
+ const sendChunk = (text: string) => {
266
+ if (!text) return;
168
267
  send({
169
268
  type: "render_chunk",
170
269
  call_id: msg.call_id,
171
- data: b64,
270
+ data: Buffer.from(text, "utf8").toString("base64"),
172
271
  });
272
+ };
273
+ while (true) {
274
+ const { value, done } = await reader.read();
275
+ if (done) break;
276
+ if (!value || value.byteLength === 0) continue;
277
+ let text = Buffer.from(value).toString("utf8");
278
+ if (!headInjected) {
279
+ const combined = carry + text;
280
+ const idx = combined.indexOf(HEAD_CLOSE);
281
+ if (idx >= 0) {
282
+ // Send everything up to the </head> position, then the
283
+ // headBlob, then </head>, then the remainder.
284
+ const before = combined.slice(0, idx);
285
+ const after = combined.slice(idx + HEAD_CLOSE.length);
286
+ // Drop the carry portion from `before` that we already
287
+ // emitted as part of the previous chunk's send. But since
288
+ // we DIDN'T emit `carry` previously (it was withheld), we
289
+ // can send the full `before` here.
290
+ sendChunk(before);
291
+ sendChunk(headBlob);
292
+ sendChunk(HEAD_CLOSE);
293
+ if (after) sendChunk(after);
294
+ headInjected = true;
295
+ carry = "";
296
+ } else {
297
+ // No </head> yet — emit everything except the last
298
+ // (HEAD_CLOSE.length - 1) bytes so a tag split across
299
+ // chunk boundaries still gets caught next pass.
300
+ const keep = HEAD_CLOSE.length - 1;
301
+ if (combined.length > keep) {
302
+ sendChunk(combined.slice(0, combined.length - keep));
303
+ carry = combined.slice(combined.length - keep);
304
+ } else {
305
+ carry = combined;
306
+ }
307
+ }
308
+ } else {
309
+ // base64 in pure JS via Buffer (Bun ships it). For large
310
+ // pages this is O(n) per chunk; fine for Phase 1.
311
+ sendChunk(text);
312
+ }
173
313
  }
314
+ // Flush any residual carry (head close never seen — page
315
+ // didn't have a </head>, which is fine for fragment renders).
316
+ if (carry) sendChunk(carry);
317
+
318
+ // Hydration tail. After React's stream EOFs we append the
319
+ // hydration markers so the browser can hydrate:
320
+ // 1. `__PYLON_DATA__` — JSON-typed script with the props the
321
+ // page was rendered with. The per-route bundle reads this
322
+ // to seed hydrateRoot.
323
+ // 2. `<link rel="modulepreload">` for every transitive shared
324
+ // chunk (react, react-dom, client-runtime). These preload
325
+ // tags fire as soon as the parser sees them; the browser
326
+ // can start fetching while it's still parsing the body.
327
+ // 3. `<script type="module" src="<route-entry>.js">` — the
328
+ // per-route entry. It imports the shared chunks, which
329
+ // were already in the cache from step 2.
330
+ //
331
+ // Per-route entry + chunk paths come from
332
+ // `.pylon/client-build/manifest.json`, which the bundler writes
333
+ // and `getManifest` parses with mtime-keyed caching. Falls back
334
+ // to a no-hydration warning if the manifest can't be loaded
335
+ // (rare — usually means the bundler crashed).
336
+ const hydrationPayload = {
337
+ component: msg.component,
338
+ layouts: msg.layouts ?? [],
339
+ props,
340
+ };
341
+ const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
342
+
343
+ let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
344
+ if (preloadManifestRoute) {
345
+ // Per-route entry script comes last — it needs the inline
346
+ // `__PYLON_DATA__` above to have been parsed before it runs.
347
+ // CSS + modulepreload links were already injected into `<head>`
348
+ // above so they could start fetching as early as possible.
349
+ tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
350
+ } else {
351
+ tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
352
+ }
353
+ send({
354
+ type: "render_chunk",
355
+ call_id: msg.call_id,
356
+ data: Buffer.from(tail, "utf8").toString("base64"),
357
+ });
358
+
174
359
  send({ type: "render_done", call_id: msg.call_id });
175
360
  } catch (err: any) {
176
361
  // Pre-first-chunk error → host returns 500.