@pylonsync/functions 0.3.222 → 0.3.224
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 +1 -1
- package/src/runtime.ts +18 -0
- package/src/ssr-client-bundler.test.ts +236 -0
- package/src/ssr-client-bundler.ts +866 -0
- package/src/ssr-runtime.ts +194 -9
package/package.json
CHANGED
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
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
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.
|