@catmint/vite 0.0.0-prealpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +339 -0
- package/dist/boundary.d.ts +15 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +193 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-entries.d.ts +81 -0
- package/dist/build-entries.d.ts.map +1 -0
- package/dist/build-entries.js +1139 -0
- package/dist/build-entries.js.map +1 -0
- package/dist/cors-utils.d.ts +22 -0
- package/dist/cors-utils.d.ts.map +1 -0
- package/dist/cors-utils.js +36 -0
- package/dist/cors-utils.js.map +1 -0
- package/dist/dev-server.d.ts +34 -0
- package/dist/dev-server.d.ts.map +1 -0
- package/dist/dev-server.js +1683 -0
- package/dist/dev-server.js.map +1 -0
- package/dist/env-transform.d.ts +16 -0
- package/dist/env-transform.d.ts.map +1 -0
- package/dist/env-transform.js +125 -0
- package/dist/env-transform.js.map +1 -0
- package/dist/error-page.d.ts +19 -0
- package/dist/error-page.d.ts.map +1 -0
- package/dist/error-page.js +152 -0
- package/dist/error-page.js.map +1 -0
- package/dist/index.d.ts +92 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/mdx-transform.d.ts +33 -0
- package/dist/mdx-transform.d.ts.map +1 -0
- package/dist/mdx-transform.js +86 -0
- package/dist/mdx-transform.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +155 -0
- package/dist/middleware.js.map +1 -0
- package/dist/resolve-utils.d.ts +17 -0
- package/dist/resolve-utils.d.ts.map +1 -0
- package/dist/resolve-utils.js +51 -0
- package/dist/resolve-utils.js.map +1 -0
- package/dist/route-gen.d.ts +12 -0
- package/dist/route-gen.d.ts.map +1 -0
- package/dist/route-gen.js +221 -0
- package/dist/route-gen.js.map +1 -0
- package/dist/server-fn-transform.d.ts +12 -0
- package/dist/server-fn-transform.d.ts.map +1 -0
- package/dist/server-fn-transform.js +394 -0
- package/dist/server-fn-transform.js.map +1 -0
- package/dist/utils.d.ts +56 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +198 -0
- package/dist/utils.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,1683 @@
|
|
|
1
|
+
// @catmint/vite/dev-server — RSC dev server middleware
|
|
2
|
+
//
|
|
3
|
+
// Vite plugin that intercepts page route requests during development,
|
|
4
|
+
// renders them via the RSC → SSR → HTML three-phase pipeline, and
|
|
5
|
+
// injects the RSC payload into the HTML for client hydration.
|
|
6
|
+
//
|
|
7
|
+
// The rendering pipeline:
|
|
8
|
+
// 1. RSC phase: Load page+layouts in RSC environment, call
|
|
9
|
+
// renderToReadableStream → flight stream
|
|
10
|
+
// 2. SSR phase: Tee the flight stream. One fork → createFromReadableStream
|
|
11
|
+
// → React VDOM → renderToReadableStream → HTML. Other fork
|
|
12
|
+
// → injectRSCPayload → embedded in HTML as <script> tags
|
|
13
|
+
// 3. Client: Browser reads embedded payload via rsc-html-stream/client,
|
|
14
|
+
// calls createFromReadableStream → React VDOM → hydrateRoot
|
|
15
|
+
import { join, dirname, relative } from "node:path";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { deterministicHash, toPosixPath, CLIENT_NAVIGATION_RUNTIME, } from "./utils.js";
|
|
19
|
+
import { errorPageHtml } from "./error-page.js";
|
|
20
|
+
import { scanStatusPages } from "./build-entries.js";
|
|
21
|
+
import { buildPreflightHeaders } from "./cors-utils.js";
|
|
22
|
+
import { resolveErrorBoundary, resolveLoadingComponent, } from "./resolve-utils.js";
|
|
23
|
+
/**
|
|
24
|
+
* File extensions that indicate a static asset request (skip SSR).
|
|
25
|
+
*/
|
|
26
|
+
const ASSET_EXTENSIONS = new Set([
|
|
27
|
+
".js",
|
|
28
|
+
".ts",
|
|
29
|
+
".tsx",
|
|
30
|
+
".jsx",
|
|
31
|
+
".mjs",
|
|
32
|
+
".cjs",
|
|
33
|
+
".css",
|
|
34
|
+
".scss",
|
|
35
|
+
".sass",
|
|
36
|
+
".less",
|
|
37
|
+
".json",
|
|
38
|
+
".wasm",
|
|
39
|
+
".png",
|
|
40
|
+
".jpg",
|
|
41
|
+
".jpeg",
|
|
42
|
+
".gif",
|
|
43
|
+
".svg",
|
|
44
|
+
".ico",
|
|
45
|
+
".webp",
|
|
46
|
+
".avif",
|
|
47
|
+
".woff",
|
|
48
|
+
".woff2",
|
|
49
|
+
".ttf",
|
|
50
|
+
".eot",
|
|
51
|
+
".otf",
|
|
52
|
+
".mp4",
|
|
53
|
+
".webm",
|
|
54
|
+
".ogg",
|
|
55
|
+
".mp3",
|
|
56
|
+
".wav",
|
|
57
|
+
".map",
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Paths that Vite handles internally — never intercept these.
|
|
61
|
+
* NOTE: /__catmint/fn/ is NOT included here because the dev server
|
|
62
|
+
* must handle server function RPC calls itself.
|
|
63
|
+
*/
|
|
64
|
+
const VITE_INTERNAL_PREFIXES = [
|
|
65
|
+
"/@vite/",
|
|
66
|
+
"/@fs/",
|
|
67
|
+
"/@id/",
|
|
68
|
+
"/__vite_",
|
|
69
|
+
"/node_modules/",
|
|
70
|
+
"/__vite_rsc",
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Check if a URL should be skipped by the SSR middleware.
|
|
74
|
+
*/
|
|
75
|
+
function shouldSkip(url) {
|
|
76
|
+
const pathname = url.split("?")[0];
|
|
77
|
+
for (const prefix of VITE_INTERNAL_PREFIXES) {
|
|
78
|
+
if (pathname.startsWith(prefix))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const lastDot = pathname.lastIndexOf(".");
|
|
82
|
+
if (lastDot !== -1) {
|
|
83
|
+
const ext = pathname.slice(lastDot);
|
|
84
|
+
if (ASSET_EXTENSIONS.has(ext))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Walk up from a page file's directory to the app root, collecting
|
|
91
|
+
* layout.tsx files in order from outermost (root) to innermost.
|
|
92
|
+
*/
|
|
93
|
+
function collectLayouts(pageFilePath, appDir) {
|
|
94
|
+
const layouts = [];
|
|
95
|
+
let dir = dirname(pageFilePath);
|
|
96
|
+
while (dir.startsWith(appDir)) {
|
|
97
|
+
for (const name of ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]) {
|
|
98
|
+
const layoutPath = join(dir, name);
|
|
99
|
+
if (existsSync(layoutPath)) {
|
|
100
|
+
layouts.unshift(layoutPath);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (dir === appDir)
|
|
105
|
+
break;
|
|
106
|
+
dir = dirname(dir);
|
|
107
|
+
}
|
|
108
|
+
return layouts;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* CSS file extensions that Vite processes.
|
|
112
|
+
*/
|
|
113
|
+
const CSS_EXTENSIONS = [".css", ".scss", ".sass", ".less", ".styl", ".stylus"];
|
|
114
|
+
/**
|
|
115
|
+
* Check if a module URL represents a CSS file.
|
|
116
|
+
*/
|
|
117
|
+
function isCssModule(url) {
|
|
118
|
+
const clean = url.split("?")[0];
|
|
119
|
+
return CSS_EXTENSIONS.some((ext) => clean.endsWith(ext));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Collect all CSS modules imported (directly or transitively) by the given
|
|
123
|
+
* entry module IDs. Walks the Vite module graph breadth-first to find every
|
|
124
|
+
* CSS dependency, then returns their transformed source as `<style>` tags.
|
|
125
|
+
*/
|
|
126
|
+
async function collectCss(server, entryIds) {
|
|
127
|
+
const visited = new Set();
|
|
128
|
+
const cssModules = [];
|
|
129
|
+
const queue = [];
|
|
130
|
+
for (const id of entryIds) {
|
|
131
|
+
// Try the SSR module graph first (RSC env populates this), then client
|
|
132
|
+
const mod = server.moduleGraph.getModuleById(id);
|
|
133
|
+
if (mod)
|
|
134
|
+
queue.push(mod);
|
|
135
|
+
const urlMod = await server.moduleGraph.getModuleByUrl(id);
|
|
136
|
+
if (urlMod && urlMod !== mod)
|
|
137
|
+
queue.push(urlMod);
|
|
138
|
+
}
|
|
139
|
+
while (queue.length > 0) {
|
|
140
|
+
const mod = queue.shift();
|
|
141
|
+
const key = mod.id ?? mod.url;
|
|
142
|
+
if (visited.has(key))
|
|
143
|
+
continue;
|
|
144
|
+
visited.add(key);
|
|
145
|
+
if (isCssModule(mod.url)) {
|
|
146
|
+
cssModules.push(mod);
|
|
147
|
+
}
|
|
148
|
+
for (const imported of mod.importedModules) {
|
|
149
|
+
const importedKey = imported.id ?? imported.url;
|
|
150
|
+
if (!visited.has(importedKey)) {
|
|
151
|
+
queue.push(imported);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (cssModules.length === 0)
|
|
156
|
+
return "";
|
|
157
|
+
const styleTags = [];
|
|
158
|
+
for (const mod of cssModules) {
|
|
159
|
+
try {
|
|
160
|
+
const result = await server.transformRequest(mod.url);
|
|
161
|
+
if (result?.code) {
|
|
162
|
+
const directResult = await server.transformRequest(mod.url + (mod.url.includes("?") ? "&direct" : "?direct"));
|
|
163
|
+
if (directResult?.code) {
|
|
164
|
+
styleTags.push(`<style data-vite-dev-id="${mod.url}">${directResult.code}</style>`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const cssMatch = result.code.match(/const __vite__css\s*=\s*"((?:[^"\\]|\\.)*)"/);
|
|
168
|
+
if (cssMatch) {
|
|
169
|
+
const css = cssMatch[1]
|
|
170
|
+
.replace(/\\n/g, "\n")
|
|
171
|
+
.replace(/\\"/g, '"')
|
|
172
|
+
.replace(/\\\\/g, "\\");
|
|
173
|
+
styleTags.push(`<style data-vite-dev-id="${mod.url}">${css}</style>`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Skip — client will load via normal pipeline
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return styleTags.join("\n");
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Read the full request body as a buffer.
|
|
185
|
+
*/
|
|
186
|
+
function readBody(req) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
190
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
191
|
+
req.on("error", reject);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** Escape a string for safe insertion into an HTML attribute. */
|
|
195
|
+
function escapeHtmlAttr(str) {
|
|
196
|
+
return str
|
|
197
|
+
.replace(/&/g, "&")
|
|
198
|
+
.replace(/"/g, """)
|
|
199
|
+
.replace(/</g, "<")
|
|
200
|
+
.replace(/>/g, ">");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Merge multiple HeadConfig objects (last wins for title and meta keys).
|
|
204
|
+
* Mirrors mergeHeadConfigs from catmint/head/HeadContext.
|
|
205
|
+
*/
|
|
206
|
+
function mergeHeadConfigs(configs) {
|
|
207
|
+
const merged = {};
|
|
208
|
+
const metaMap = new Map();
|
|
209
|
+
const links = [];
|
|
210
|
+
for (const config of configs) {
|
|
211
|
+
if (config.title !== undefined)
|
|
212
|
+
merged.title = config.title;
|
|
213
|
+
if (config.meta) {
|
|
214
|
+
for (const meta of config.meta) {
|
|
215
|
+
const key = meta.name
|
|
216
|
+
? `name:${meta.name}`
|
|
217
|
+
: meta.property
|
|
218
|
+
? `property:${meta.property}`
|
|
219
|
+
: `content:${meta.content}`;
|
|
220
|
+
metaMap.set(key, meta);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (config.link)
|
|
224
|
+
links.push(...config.link);
|
|
225
|
+
}
|
|
226
|
+
if (metaMap.size > 0)
|
|
227
|
+
merged.meta = Array.from(metaMap.values());
|
|
228
|
+
if (links.length > 0)
|
|
229
|
+
merged.link = links;
|
|
230
|
+
return merged;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Serialize a HeadConfig into HTML tags for injection into `<head>`.
|
|
234
|
+
*/
|
|
235
|
+
function renderHeadToString(config) {
|
|
236
|
+
const parts = [];
|
|
237
|
+
if (config.title !== undefined) {
|
|
238
|
+
parts.push(`<title>${escapeHtmlAttr(config.title)}</title>`);
|
|
239
|
+
}
|
|
240
|
+
if (config.meta) {
|
|
241
|
+
for (const meta of config.meta) {
|
|
242
|
+
const attrs = [];
|
|
243
|
+
if (meta.name)
|
|
244
|
+
attrs.push(`name="${escapeHtmlAttr(meta.name)}"`);
|
|
245
|
+
if (meta.property)
|
|
246
|
+
attrs.push(`property="${escapeHtmlAttr(meta.property)}"`);
|
|
247
|
+
attrs.push(`content="${escapeHtmlAttr(meta.content)}"`);
|
|
248
|
+
parts.push(`<meta ${attrs.join(" ")}>`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (config.link) {
|
|
252
|
+
for (const linkDef of config.link) {
|
|
253
|
+
const attrs = [
|
|
254
|
+
`rel="${escapeHtmlAttr(linkDef.rel)}"`,
|
|
255
|
+
`href="${escapeHtmlAttr(linkDef.href)}"`,
|
|
256
|
+
];
|
|
257
|
+
for (const [key, value] of Object.entries(linkDef)) {
|
|
258
|
+
if (key !== "rel" && key !== "href") {
|
|
259
|
+
attrs.push(`${key}="${escapeHtmlAttr(value)}"`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
parts.push(`<link ${attrs.join(" ")}>`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return parts.join("\n");
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Strip existing `<title>` and duplicate `<meta>` tags from SSR-rendered HTML
|
|
269
|
+
* so the framework-injected head tags from `generateMetadata` take precedence.
|
|
270
|
+
*
|
|
271
|
+
* Without this, the layout's `<title>` in JSX and the `generateMetadata` title
|
|
272
|
+
* both appear in `<head>`, and the browser uses the first one — ignoring the
|
|
273
|
+
* page-specific title.
|
|
274
|
+
*/
|
|
275
|
+
function stripExistingHeadTags(html, config) {
|
|
276
|
+
let result = html;
|
|
277
|
+
// Strip existing <title>...</title> when generateMetadata provides one
|
|
278
|
+
if (config.title !== undefined) {
|
|
279
|
+
result = result.replace(/<title>[^<]*<\/title>/i, "");
|
|
280
|
+
}
|
|
281
|
+
// Strip existing <meta> tags that will be replaced by generateMetadata
|
|
282
|
+
if (config.meta) {
|
|
283
|
+
for (const meta of config.meta) {
|
|
284
|
+
if (meta.name) {
|
|
285
|
+
const re = new RegExp(`<meta\\s+[^>]*name=["']${escapeRegExp(meta.name)}["'][^>]*>`, "i");
|
|
286
|
+
result = result.replace(re, "");
|
|
287
|
+
}
|
|
288
|
+
else if (meta.property) {
|
|
289
|
+
const re = new RegExp(`<meta\\s+[^>]*property=["']${escapeRegExp(meta.property)}["'][^>]*>`, "i");
|
|
290
|
+
result = result.replace(re, "");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
/** Escape special regex characters in a string. */
|
|
297
|
+
function escapeRegExp(str) {
|
|
298
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve generateMetadata exports from page and layout modules.
|
|
302
|
+
*
|
|
303
|
+
* Calls `generateMetadata({ params, search })` on each module that exports it,
|
|
304
|
+
* then merges the results (layout configs first, page config last — page wins).
|
|
305
|
+
*
|
|
306
|
+
* @returns Merged HeadConfig, or undefined if no modules export generateMetadata.
|
|
307
|
+
*/
|
|
308
|
+
async function resolveGenerateMetadata(pageMod, layoutMods, params, url) {
|
|
309
|
+
const urlObj = new URL(url, "http://localhost");
|
|
310
|
+
const search = {};
|
|
311
|
+
for (const [key, value] of urlObj.searchParams.entries()) {
|
|
312
|
+
search[key] = value;
|
|
313
|
+
}
|
|
314
|
+
const metadataArgs = { params: params ?? {}, search };
|
|
315
|
+
const configs = [];
|
|
316
|
+
// Layouts first (outermost to innermost) — page is last so it wins on conflict
|
|
317
|
+
for (const layoutMod of layoutMods) {
|
|
318
|
+
if (typeof layoutMod.generateMetadata === "function") {
|
|
319
|
+
try {
|
|
320
|
+
const config = await layoutMod.generateMetadata(metadataArgs);
|
|
321
|
+
if (config && typeof config === "object")
|
|
322
|
+
configs.push(config);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Skip — layout generateMetadata failed
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Page module last
|
|
330
|
+
if (typeof pageMod.generateMetadata === "function") {
|
|
331
|
+
try {
|
|
332
|
+
const config = await pageMod.generateMetadata(metadataArgs);
|
|
333
|
+
if (config && typeof config === "object")
|
|
334
|
+
configs.push(config);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// Skip — page generateMetadata failed
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (configs.length === 0)
|
|
341
|
+
return undefined;
|
|
342
|
+
return mergeHeadConfigs(configs);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Server function RPC prefix.
|
|
346
|
+
*/
|
|
347
|
+
const SERVER_FN_PREFIX = "/__catmint/fn/";
|
|
348
|
+
/**
|
|
349
|
+
* RSC flight stream prefix for client-side navigation.
|
|
350
|
+
*/
|
|
351
|
+
const RSC_NAVIGATION_PREFIX = "/__catmint/rsc";
|
|
352
|
+
/**
|
|
353
|
+
* Handle a server function RPC call.
|
|
354
|
+
*
|
|
355
|
+
* The URL format is `/__catmint/fn/<basePath>/<hash>` where:
|
|
356
|
+
* - `basePath` is the relative file path without the `.fn.ts` extension
|
|
357
|
+
* - `hash` is `deterministicHash(relativePath + ":" + fnName)`
|
|
358
|
+
*
|
|
359
|
+
* We resolve the `.fn.ts` file, load it via Vite's SSR module loader,
|
|
360
|
+
* iterate its exports to find the one matching the hash, and call it.
|
|
361
|
+
*/
|
|
362
|
+
async function handleServerFn(server, req, res, pathname) {
|
|
363
|
+
const root = server.config.root;
|
|
364
|
+
// Parse the URL: /__catmint/fn/<basePath>/<hash>
|
|
365
|
+
const fnPath = pathname.slice(SERVER_FN_PREFIX.length); // e.g. "app/examples/server-fn/actions/c875b945"
|
|
366
|
+
const lastSlash = fnPath.lastIndexOf("/");
|
|
367
|
+
if (lastSlash === -1) {
|
|
368
|
+
res.statusCode = 400;
|
|
369
|
+
res.setHeader("Content-Type", "application/json");
|
|
370
|
+
res.end(JSON.stringify({ error: "Invalid server function path" }));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const basePath = fnPath.slice(0, lastSlash); // e.g. "app/examples/server-fn/actions"
|
|
374
|
+
const hash = fnPath.slice(lastSlash + 1); // e.g. "c875b945"
|
|
375
|
+
// Try to find the .fn.ts file — try common extensions
|
|
376
|
+
const extensions = [".fn.ts", ".fn.tsx"];
|
|
377
|
+
let resolvedFilePath = null;
|
|
378
|
+
for (const ext of extensions) {
|
|
379
|
+
const candidate = join(root, basePath + ext);
|
|
380
|
+
if (existsSync(candidate)) {
|
|
381
|
+
resolvedFilePath = candidate;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!resolvedFilePath) {
|
|
386
|
+
res.statusCode = 404;
|
|
387
|
+
res.setHeader("Content-Type", "application/json");
|
|
388
|
+
res.end(JSON.stringify({
|
|
389
|
+
error: `Server function module not found: ${basePath}.fn.ts`,
|
|
390
|
+
}));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// Load the module via Vite's SSR module loader (server-side, no RPC transform)
|
|
394
|
+
let mod;
|
|
395
|
+
try {
|
|
396
|
+
mod = await server.ssrLoadModule(resolvedFilePath);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
server.config.logger.error(`[catmint] Failed to load server function module ${resolvedFilePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
400
|
+
res.statusCode = 500;
|
|
401
|
+
res.setHeader("Content-Type", "application/json");
|
|
402
|
+
res.end(JSON.stringify({ error: "Failed to load server function module" }));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Find the export whose hash matches
|
|
406
|
+
const relativePath = toPosixPath(resolvedFilePath.startsWith(root)
|
|
407
|
+
? resolvedFilePath.slice(root.length + 1)
|
|
408
|
+
: resolvedFilePath);
|
|
409
|
+
let matchedFn = null;
|
|
410
|
+
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
411
|
+
if (exportName === "__esModule")
|
|
412
|
+
continue;
|
|
413
|
+
const candidateHash = deterministicHash(`${relativePath}:${exportName}`);
|
|
414
|
+
if (candidateHash === hash) {
|
|
415
|
+
if (typeof exportValue === "function") {
|
|
416
|
+
matchedFn = exportValue;
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!matchedFn) {
|
|
422
|
+
res.statusCode = 404;
|
|
423
|
+
res.setHeader("Content-Type", "application/json");
|
|
424
|
+
res.end(JSON.stringify({
|
|
425
|
+
error: `Server function with hash "${hash}" not found in ${relativePath}`,
|
|
426
|
+
}));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// Read request body
|
|
430
|
+
const body = await readBody(req);
|
|
431
|
+
let input;
|
|
432
|
+
try {
|
|
433
|
+
input = body.length > 0 ? JSON.parse(body.toString("utf-8")) : undefined;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
res.statusCode = 400;
|
|
437
|
+
res.setHeader("Content-Type", "application/json");
|
|
438
|
+
res.end(JSON.stringify({ error: "Invalid JSON body" }));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// Call the server function
|
|
442
|
+
try {
|
|
443
|
+
const result = await matchedFn(input);
|
|
444
|
+
res.statusCode = 200;
|
|
445
|
+
res.setHeader("Content-Type", "application/json");
|
|
446
|
+
res.end(JSON.stringify(result));
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
server.config.logger.error(`[catmint] Server function error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
450
|
+
res.statusCode = 500;
|
|
451
|
+
res.setHeader("Content-Type", "application/json");
|
|
452
|
+
res.end(JSON.stringify({
|
|
453
|
+
error: err instanceof Error ? err.message : "Server function error",
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Handle an API endpoint request.
|
|
459
|
+
*/
|
|
460
|
+
async function handleEndpoint(server, req, res, match, method) {
|
|
461
|
+
const mod = await server.ssrLoadModule(match.route.filePath);
|
|
462
|
+
let handler;
|
|
463
|
+
if (mod[method]) {
|
|
464
|
+
handler = mod[method];
|
|
465
|
+
}
|
|
466
|
+
else if (mod.ANY) {
|
|
467
|
+
handler = mod.ANY;
|
|
468
|
+
}
|
|
469
|
+
else if (mod.default) {
|
|
470
|
+
handler = mod.default;
|
|
471
|
+
}
|
|
472
|
+
if (!handler) {
|
|
473
|
+
res.statusCode = 405;
|
|
474
|
+
res.setHeader("Content-Type", "text/plain");
|
|
475
|
+
res.end(`Method ${method} not allowed`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const protocol = "http";
|
|
479
|
+
const host = req.headers.host ?? "localhost";
|
|
480
|
+
const reqUrl = req.originalUrl ?? req.url ?? "/";
|
|
481
|
+
const fullUrl = `${protocol}://${host}${reqUrl}`;
|
|
482
|
+
const requestInit = {
|
|
483
|
+
method,
|
|
484
|
+
headers: Object.entries(req.headers).reduce((acc, [key, val]) => {
|
|
485
|
+
if (val)
|
|
486
|
+
acc[key] = Array.isArray(val) ? val.join(", ") : val;
|
|
487
|
+
return acc;
|
|
488
|
+
}, {}),
|
|
489
|
+
};
|
|
490
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
491
|
+
const buf = await readBody(req);
|
|
492
|
+
requestInit.body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
493
|
+
}
|
|
494
|
+
const webRequest = new Request(fullUrl, requestInit);
|
|
495
|
+
const ctx = { params: match.params };
|
|
496
|
+
const response = await handler(webRequest, ctx);
|
|
497
|
+
res.statusCode = response.status;
|
|
498
|
+
response.headers.forEach((value, key) => {
|
|
499
|
+
res.setHeader(key, value);
|
|
500
|
+
});
|
|
501
|
+
if (response.body) {
|
|
502
|
+
const reader = response.body.getReader();
|
|
503
|
+
try {
|
|
504
|
+
while (true) {
|
|
505
|
+
const { done, value } = await reader.read();
|
|
506
|
+
if (done)
|
|
507
|
+
break;
|
|
508
|
+
res.write(value);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
res.end();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
res.end();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// --------------------------------------------------------------------------
|
|
520
|
+
// Virtual module IDs for RSC/SSR rendering entries
|
|
521
|
+
// --------------------------------------------------------------------------
|
|
522
|
+
/** Virtual module loaded in the RSC environment to render pages */
|
|
523
|
+
const RSC_RENDERER_ID = "virtual:catmint/rsc-renderer";
|
|
524
|
+
const RESOLVED_RSC_RENDERER_ID = "\0" + RSC_RENDERER_ID;
|
|
525
|
+
/** Virtual module loaded in the SSR environment to convert RSC→HTML */
|
|
526
|
+
const SSR_RENDERER_ID = "virtual:catmint/ssr-renderer";
|
|
527
|
+
const RESOLVED_SSR_RENDERER_ID = "\0" + SSR_RENDERER_ID;
|
|
528
|
+
/** Virtual module for browser hydration entry */
|
|
529
|
+
const CLIENT_HYDRATE_ID = "virtual:catmint/client-hydrate";
|
|
530
|
+
const RESOLVED_CLIENT_HYDRATE_ID = "\0" + CLIENT_HYDRATE_ID;
|
|
531
|
+
/**
|
|
532
|
+
* Create the SSR dev server Vite plugin.
|
|
533
|
+
*
|
|
534
|
+
* With RSC enabled, this plugin implements a three-phase rendering pipeline:
|
|
535
|
+
* 1. RSC: renders page components in the RSC environment → flight stream
|
|
536
|
+
* 2. SSR: converts flight stream to HTML via react-dom SSR
|
|
537
|
+
* 3. Client: hydrates from embedded RSC payload
|
|
538
|
+
*
|
|
539
|
+
* The RSC and SSR rendering code runs inside Vite's environment runners
|
|
540
|
+
* (server.environments.rsc.runner and server.environments.ssr) so that the
|
|
541
|
+
* correct resolve conditions and module transforms apply.
|
|
542
|
+
*/
|
|
543
|
+
export function devServerPlugin(options) {
|
|
544
|
+
const softNav = options?.softNavigation ?? true;
|
|
545
|
+
const i18nConfig = options?.i18n ?? null;
|
|
546
|
+
let server;
|
|
547
|
+
let appDir;
|
|
548
|
+
let pageMatcher = null;
|
|
549
|
+
let endpointMatcher = null;
|
|
550
|
+
// Pre-scanned status pages sorted by most-specific (longest prefix) first.
|
|
551
|
+
// Populated in configureServer, rebuilt on HMR when status page files change.
|
|
552
|
+
let statusPages = [];
|
|
553
|
+
/**
|
|
554
|
+
* Find the best-matching status page file for the given status code and URL.
|
|
555
|
+
* Uses prefix matching (same logic as the production RSC entry) against the
|
|
556
|
+
* pre-scanned status page list. No filesystem calls per request.
|
|
557
|
+
*/
|
|
558
|
+
function findStatusPage(statusCode, url) {
|
|
559
|
+
const pathname = url.split("?")[0].split("#")[0];
|
|
560
|
+
for (const sp of statusPages) {
|
|
561
|
+
if (sp.statusCode !== statusCode)
|
|
562
|
+
continue;
|
|
563
|
+
if (sp.urlPrefix === "/" ||
|
|
564
|
+
pathname === sp.urlPrefix ||
|
|
565
|
+
pathname.startsWith(sp.urlPrefix + "/")) {
|
|
566
|
+
return sp.filePath;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Lazily initialize route matchers.
|
|
573
|
+
*/
|
|
574
|
+
async function ensureMatchers() {
|
|
575
|
+
if (pageMatcher && endpointMatcher)
|
|
576
|
+
return;
|
|
577
|
+
const routingMod = await server.ssrLoadModule("catmint/routing");
|
|
578
|
+
const routes = await routingMod.scanRoutes(appDir);
|
|
579
|
+
const pageRoutes = routes.filter((r) => r.type === "page");
|
|
580
|
+
pageMatcher = routingMod.createRouteMatcher(pageRoutes);
|
|
581
|
+
const epRoutes = routes.filter((r) => r.type === "endpoint");
|
|
582
|
+
endpointMatcher = routingMod.createRouteMatcher(epRoutes);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Check if an error is a StatusError (duck-typing to avoid cross-env
|
|
586
|
+
* instanceof issues — the error may come from the RSC environment).
|
|
587
|
+
*/
|
|
588
|
+
function isStatusError(error) {
|
|
589
|
+
return (error instanceof Error &&
|
|
590
|
+
error.name === "StatusError" &&
|
|
591
|
+
typeof error.statusCode === "number");
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Try to render a user-defined status page (e.g. 404.tsx) through the
|
|
595
|
+
* full RSC → SSR → HTML pipeline. Returns the rendered HTML string if
|
|
596
|
+
* a user status page was found, or null to signal fallback to the
|
|
597
|
+
* built-in error page.
|
|
598
|
+
*
|
|
599
|
+
* @param statusCode - HTTP status code (e.g. 404, 500)
|
|
600
|
+
* @param url - The request URL (for prefix matching and AppProviders pathname)
|
|
601
|
+
* @param statusData - Optional data payload to pass as props to the status page
|
|
602
|
+
*/
|
|
603
|
+
async function tryRenderStatusPage(statusCode, url, statusData) {
|
|
604
|
+
try {
|
|
605
|
+
const statusPagePath = findStatusPage(statusCode, url);
|
|
606
|
+
if (!statusPagePath)
|
|
607
|
+
return null;
|
|
608
|
+
// Only render through RSC pipeline if environments are available
|
|
609
|
+
if (!hasRscEnvironments())
|
|
610
|
+
return null;
|
|
611
|
+
const root = server.config.root;
|
|
612
|
+
const rscEnv = server.environments.rsc;
|
|
613
|
+
const ssrEnv = server.environments.ssr;
|
|
614
|
+
if (!rscEnv?.runner || !ssrEnv?.runner)
|
|
615
|
+
return null;
|
|
616
|
+
// Import rendering utilities
|
|
617
|
+
const rscMod = await rscEnv.runner.import("@vitejs/plugin-rsc/rsc");
|
|
618
|
+
const { renderToReadableStream: rscRender } = rscMod;
|
|
619
|
+
// Import the status page component in RSC environment
|
|
620
|
+
const statusPageImportPath = "/" + relative(root, statusPagePath);
|
|
621
|
+
const statusPageMod = await rscEnv.runner.import(statusPageImportPath);
|
|
622
|
+
const StatusPageComponent = statusPageMod.default;
|
|
623
|
+
if (!StatusPageComponent)
|
|
624
|
+
return null;
|
|
625
|
+
// Collect layouts for the status page (same ancestor layout chain)
|
|
626
|
+
const layoutPaths = collectLayouts(statusPagePath, appDir);
|
|
627
|
+
const layoutImportPaths = layoutPaths.map((lp) => "/" + relative(root, lp));
|
|
628
|
+
const layoutComponents = [];
|
|
629
|
+
const layoutMods = [];
|
|
630
|
+
for (const lp of layoutImportPaths) {
|
|
631
|
+
const layoutMod = await rscEnv.runner.import(lp);
|
|
632
|
+
layoutMods.push(layoutMod);
|
|
633
|
+
if (layoutMod.default) {
|
|
634
|
+
layoutComponents.push(layoutMod.default);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Resolve generateMetadata exports from status page + layout modules
|
|
638
|
+
const headConfig = await resolveGenerateMetadata(statusPageMod, layoutMods, {}, url);
|
|
639
|
+
// Get React from the RSC environment
|
|
640
|
+
const reactMod = await rscEnv.runner.import("react");
|
|
641
|
+
const createElement = reactMod.createElement ?? reactMod.default?.createElement;
|
|
642
|
+
if (!createElement)
|
|
643
|
+
return null;
|
|
644
|
+
// Build component tree with status + message props
|
|
645
|
+
const statusProps = {
|
|
646
|
+
status: statusCode,
|
|
647
|
+
...statusData,
|
|
648
|
+
};
|
|
649
|
+
let element = createElement(StatusPageComponent, statusProps);
|
|
650
|
+
// Wrap with layouts (outermost first)
|
|
651
|
+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
652
|
+
element = createElement(layoutComponents[i], null, element);
|
|
653
|
+
}
|
|
654
|
+
// Wrap with AppProviders
|
|
655
|
+
const appProvidersMod = await rscEnv.runner.import("catmint/runtime/app-providers");
|
|
656
|
+
const AppProviders = appProvidersMod.AppProviders ?? appProvidersMod.default?.AppProviders;
|
|
657
|
+
if (AppProviders) {
|
|
658
|
+
const urlObj = new URL(url, "http://localhost");
|
|
659
|
+
const providerProps = {
|
|
660
|
+
pathname: urlObj.pathname,
|
|
661
|
+
params: {},
|
|
662
|
+
};
|
|
663
|
+
if (i18nConfig) {
|
|
664
|
+
providerProps.i18nConfig = i18nConfig;
|
|
665
|
+
}
|
|
666
|
+
if (headConfig) {
|
|
667
|
+
providerProps.headConfig = headConfig;
|
|
668
|
+
}
|
|
669
|
+
element = createElement(AppProviders, providerProps, element);
|
|
670
|
+
}
|
|
671
|
+
// RSC render → flight stream
|
|
672
|
+
const rscStream = rscRender(element);
|
|
673
|
+
// Tee: one for SSR, one for browser injection
|
|
674
|
+
const [rscForSsr, rscForBrowser] = rscStream.tee();
|
|
675
|
+
// SSR: flight stream → HTML
|
|
676
|
+
const ssrMod = await ssrEnv.runner.import("@vitejs/plugin-rsc/ssr");
|
|
677
|
+
const { createFromReadableStream: ssrCreate } = ssrMod;
|
|
678
|
+
const reactDomServer = await ssrEnv.runner.import("react-dom/server");
|
|
679
|
+
const { renderToReadableStream: htmlRender } = reactDomServer;
|
|
680
|
+
const ssrRoot = ssrCreate(rscForSsr);
|
|
681
|
+
const bootstrapScriptContent = `import("/@id/__x00__virtual:catmint/client-hydrate")`;
|
|
682
|
+
const htmlStream = await htmlRender(ssrRoot, {
|
|
683
|
+
bootstrapScriptContent,
|
|
684
|
+
});
|
|
685
|
+
// Inject RSC payload into HTML
|
|
686
|
+
const rscHtmlStream = await ssrEnv.runner.import("rsc-html-stream/server");
|
|
687
|
+
const { injectRSCPayload } = rscHtmlStream;
|
|
688
|
+
const injectionTransform = injectRSCPayload(rscForBrowser);
|
|
689
|
+
const finalStream = htmlStream.pipeThrough(injectionTransform);
|
|
690
|
+
// Collect CSS from the status page + layouts
|
|
691
|
+
const cssEntryIds = [statusPagePath, ...layoutPaths];
|
|
692
|
+
const cssStyles = await collectCss(server, cssEntryIds);
|
|
693
|
+
const headParts = [
|
|
694
|
+
`<script>window.__catmintSoftNav=false</script>`,
|
|
695
|
+
'<script type="module" src="/@vite/client"></script>',
|
|
696
|
+
cssStyles,
|
|
697
|
+
];
|
|
698
|
+
if (headConfig) {
|
|
699
|
+
headParts.unshift(renderHeadToString(headConfig));
|
|
700
|
+
}
|
|
701
|
+
const headInjection = headParts.join("\n");
|
|
702
|
+
// Read the stream into a string, injecting head content
|
|
703
|
+
const reader = finalStream.getReader();
|
|
704
|
+
const decoder = new TextDecoder();
|
|
705
|
+
let injected = false;
|
|
706
|
+
let buffer = "";
|
|
707
|
+
let result = "";
|
|
708
|
+
while (true) {
|
|
709
|
+
const { done, value } = await reader.read();
|
|
710
|
+
if (done)
|
|
711
|
+
break;
|
|
712
|
+
const chunk = decoder.decode(value, {
|
|
713
|
+
stream: true,
|
|
714
|
+
});
|
|
715
|
+
if (injected) {
|
|
716
|
+
result += chunk;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
buffer += chunk;
|
|
720
|
+
const headCloseIdx = buffer.indexOf("</head>");
|
|
721
|
+
if (headCloseIdx !== -1) {
|
|
722
|
+
injected = true;
|
|
723
|
+
let before = buffer.slice(0, headCloseIdx);
|
|
724
|
+
const after = buffer.slice(headCloseIdx);
|
|
725
|
+
if (headConfig) {
|
|
726
|
+
before = stripExistingHeadTags(before, headConfig);
|
|
727
|
+
}
|
|
728
|
+
result += before + headInjection + after;
|
|
729
|
+
buffer = "";
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (!injected && buffer) {
|
|
733
|
+
result += buffer + headInjection;
|
|
734
|
+
}
|
|
735
|
+
else if (buffer) {
|
|
736
|
+
result += buffer;
|
|
737
|
+
}
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
// If rendering the status page itself fails, log and fall back
|
|
742
|
+
server.config.logger.error(`[catmint] Failed to render status page for ${statusCode}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Send an error response for a page request. Tries to render a user-defined
|
|
748
|
+
* status page first, falling back to the built-in error page HTML.
|
|
749
|
+
*
|
|
750
|
+
* @param res - The HTTP response
|
|
751
|
+
* @param statusCode - HTTP status code
|
|
752
|
+
* @param url - The request URL (for prefix matching and AppProviders pathname)
|
|
753
|
+
* @param options - Options for the built-in fallback (e.g. detail for dev errors)
|
|
754
|
+
* @param statusData - Optional data to pass as props to user status page
|
|
755
|
+
*/
|
|
756
|
+
async function sendStatusPage(res, statusCode, url, options, statusData) {
|
|
757
|
+
const html = await tryRenderStatusPage(statusCode, url, statusData);
|
|
758
|
+
res.statusCode = statusCode;
|
|
759
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
760
|
+
res.end(html ?? errorPageHtml(statusCode, options));
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Check if RSC environments are available (plugin-rsc is active).
|
|
764
|
+
*/
|
|
765
|
+
function hasRscEnvironments() {
|
|
766
|
+
return !!(server.environments &&
|
|
767
|
+
server.environments.rsc &&
|
|
768
|
+
server.environments.ssr);
|
|
769
|
+
}
|
|
770
|
+
// Hydration virtual module support (fallback for non-RSC mode)
|
|
771
|
+
const hydrationScripts = new Map();
|
|
772
|
+
let hydrationCounter = 0;
|
|
773
|
+
const HYDRATE_VIRTUAL_PREFIX = "/@catmint/hydrate/";
|
|
774
|
+
// Resolve RSC-related packages from @catmint/vite's own node_modules.
|
|
775
|
+
// This is necessary because pnpm's strict isolation means these packages
|
|
776
|
+
// aren't accessible from the user's project root.
|
|
777
|
+
const catmintViteRequire = createRequire(import.meta.url);
|
|
778
|
+
/** Resolve a package specifier to its absolute file path, or null if not found. */
|
|
779
|
+
function resolveFromCatmintVite(specifier) {
|
|
780
|
+
try {
|
|
781
|
+
return catmintViteRequire.resolve(specifier);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Build resolve.alias entries so that all Vite environments can find
|
|
789
|
+
* @vitejs/plugin-rsc subpaths and rsc-html-stream subpaths regardless
|
|
790
|
+
* of pnpm's strict isolation.
|
|
791
|
+
*/
|
|
792
|
+
function buildRscAliases() {
|
|
793
|
+
const aliases = [];
|
|
794
|
+
const rscMainPath = resolveFromCatmintVite("@vitejs/plugin-rsc");
|
|
795
|
+
if (rscMainPath) {
|
|
796
|
+
// Individual subpath aliases for the specific imports we need
|
|
797
|
+
const subpaths = [
|
|
798
|
+
"rsc",
|
|
799
|
+
"ssr",
|
|
800
|
+
"browser",
|
|
801
|
+
"react/rsc",
|
|
802
|
+
"react/ssr",
|
|
803
|
+
"react/browser",
|
|
804
|
+
"vendor/react-server-dom/server.edge",
|
|
805
|
+
"vendor/react-server-dom/client.edge",
|
|
806
|
+
"vendor/react-server-dom/client.browser",
|
|
807
|
+
"vendor/react-server-dom/server",
|
|
808
|
+
"vendor/react-server-dom/client",
|
|
809
|
+
"utils/encryption-runtime",
|
|
810
|
+
];
|
|
811
|
+
for (const sub of subpaths) {
|
|
812
|
+
const resolved = resolveFromCatmintVite(`@vitejs/plugin-rsc/${sub}`);
|
|
813
|
+
if (resolved) {
|
|
814
|
+
aliases.push({
|
|
815
|
+
find: `@vitejs/plugin-rsc/${sub}`,
|
|
816
|
+
replacement: resolved,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Also alias the main package
|
|
821
|
+
aliases.push({
|
|
822
|
+
find: "@vitejs/plugin-rsc",
|
|
823
|
+
replacement: rscMainPath,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
// Map rsc-html-stream subpaths
|
|
827
|
+
for (const sub of ["server", "client"]) {
|
|
828
|
+
const resolved = resolveFromCatmintVite(`rsc-html-stream/${sub}`);
|
|
829
|
+
if (resolved) {
|
|
830
|
+
aliases.push({
|
|
831
|
+
find: `rsc-html-stream/${sub}`,
|
|
832
|
+
replacement: resolved,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return aliases;
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
name: "catmint:dev-server",
|
|
840
|
+
enforce: "post",
|
|
841
|
+
config() {
|
|
842
|
+
const aliases = buildRscAliases();
|
|
843
|
+
return {
|
|
844
|
+
resolve: {
|
|
845
|
+
alias: aliases,
|
|
846
|
+
},
|
|
847
|
+
ssr: {
|
|
848
|
+
external: ["catmint/routing", "catmint/status"],
|
|
849
|
+
},
|
|
850
|
+
optimizeDeps: {
|
|
851
|
+
include: [
|
|
852
|
+
"react",
|
|
853
|
+
"react/jsx-runtime",
|
|
854
|
+
"react/jsx-dev-runtime",
|
|
855
|
+
"react-dom/client",
|
|
856
|
+
],
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
},
|
|
860
|
+
resolveId(id) {
|
|
861
|
+
if (id === RSC_RENDERER_ID || id === RESOLVED_RSC_RENDERER_ID) {
|
|
862
|
+
return RESOLVED_RSC_RENDERER_ID;
|
|
863
|
+
}
|
|
864
|
+
if (id === SSR_RENDERER_ID || id === RESOLVED_SSR_RENDERER_ID) {
|
|
865
|
+
return RESOLVED_SSR_RENDERER_ID;
|
|
866
|
+
}
|
|
867
|
+
if (id === CLIENT_HYDRATE_ID || id === RESOLVED_CLIENT_HYDRATE_ID) {
|
|
868
|
+
return RESOLVED_CLIENT_HYDRATE_ID;
|
|
869
|
+
}
|
|
870
|
+
// Legacy hydration support
|
|
871
|
+
if (id.startsWith(HYDRATE_VIRTUAL_PREFIX)) {
|
|
872
|
+
return id;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
},
|
|
876
|
+
load(id) {
|
|
877
|
+
if (id === RESOLVED_RSC_RENDERER_ID) {
|
|
878
|
+
return generateRscRendererModule();
|
|
879
|
+
}
|
|
880
|
+
if (id === RESOLVED_SSR_RENDERER_ID) {
|
|
881
|
+
return generateSsrRendererModule();
|
|
882
|
+
}
|
|
883
|
+
if (id === RESOLVED_CLIENT_HYDRATE_ID) {
|
|
884
|
+
return generateClientHydrateModule();
|
|
885
|
+
}
|
|
886
|
+
// Legacy hydration support
|
|
887
|
+
if (id.startsWith(HYDRATE_VIRTUAL_PREFIX)) {
|
|
888
|
+
const key = id.slice(HYDRATE_VIRTUAL_PREFIX.length);
|
|
889
|
+
return hydrationScripts.get(key) ?? null;
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
},
|
|
893
|
+
configureServer(viteServer) {
|
|
894
|
+
server = viteServer;
|
|
895
|
+
appDir = join(server.config.root, "app");
|
|
896
|
+
statusPages = scanStatusPages(appDir);
|
|
897
|
+
return () => {
|
|
898
|
+
server.middlewares.use(async (req, res, next) => {
|
|
899
|
+
const url = req.originalUrl ?? req.url ?? "/";
|
|
900
|
+
// Legacy hydration module requests
|
|
901
|
+
if (url.startsWith(HYDRATE_VIRTUAL_PREFIX)) {
|
|
902
|
+
try {
|
|
903
|
+
const result = await server.transformRequest(url);
|
|
904
|
+
if (result) {
|
|
905
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
906
|
+
res.statusCode = 200;
|
|
907
|
+
res.end(result.code);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch (e) {
|
|
912
|
+
server.config.logger.error(`[catmint] Failed to transform hydration module ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
|
913
|
+
}
|
|
914
|
+
res.statusCode = 404;
|
|
915
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
916
|
+
res.end(errorPageHtml(404));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (shouldSkip(url)) {
|
|
920
|
+
return next();
|
|
921
|
+
}
|
|
922
|
+
// --- Server function RPC handling ---
|
|
923
|
+
const pathname = url.split("?")[0];
|
|
924
|
+
if (pathname.startsWith(SERVER_FN_PREFIX)) {
|
|
925
|
+
try {
|
|
926
|
+
await handleServerFn(server, req, res, pathname);
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
server.ssrFixStacktrace(error);
|
|
930
|
+
server.config.logger.error(`[catmint] Server function error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
931
|
+
if (!res.headersSent) {
|
|
932
|
+
res.statusCode = 500;
|
|
933
|
+
res.setHeader("Content-Type", "application/json");
|
|
934
|
+
res.end(JSON.stringify({ error: "Internal server function error" }));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
// --- RSC flight stream for client-side navigation ---
|
|
940
|
+
if (pathname === RSC_NAVIGATION_PREFIX) {
|
|
941
|
+
try {
|
|
942
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
943
|
+
const targetPath = parsedUrl.searchParams.get("path");
|
|
944
|
+
if (!targetPath) {
|
|
945
|
+
res.statusCode = 400;
|
|
946
|
+
res.setHeader("Content-Type", "application/json");
|
|
947
|
+
res.end(JSON.stringify({ error: "Missing ?path= parameter" }));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
await ensureMatchers();
|
|
951
|
+
const match = pageMatcher.matchRoute(targetPath);
|
|
952
|
+
if (!match) {
|
|
953
|
+
res.statusCode = 404;
|
|
954
|
+
res.setHeader("Content-Type", "application/json");
|
|
955
|
+
res.end(JSON.stringify({ error: "No matching route" }));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!hasRscEnvironments()) {
|
|
959
|
+
// Without RSC, fall back to a full page reload signal
|
|
960
|
+
res.statusCode = 406;
|
|
961
|
+
res.setHeader("Content-Type", "application/json");
|
|
962
|
+
res.end(JSON.stringify({ error: "RSC not available" }));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
await handleRscNavigation(server, req, res, match, appDir, i18nConfig);
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
server.ssrFixStacktrace(error);
|
|
969
|
+
server.config.logger.error(`[catmint] RSC navigation error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
970
|
+
if (!res.headersSent) {
|
|
971
|
+
res.statusCode = 500;
|
|
972
|
+
res.setHeader("Content-Type", "application/json");
|
|
973
|
+
res.end(JSON.stringify({ error: "RSC navigation error" }));
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
await ensureMatchers();
|
|
980
|
+
// --- API endpoint handling ---
|
|
981
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
982
|
+
const endpointMatch = endpointMatcher.matchRoute(url);
|
|
983
|
+
if (endpointMatch) {
|
|
984
|
+
const epMethods = endpointMatch.route.methods ?? [];
|
|
985
|
+
const hasHandler = epMethods.includes(method) ||
|
|
986
|
+
epMethods.includes("ANY") ||
|
|
987
|
+
epMethods.includes("default");
|
|
988
|
+
// CORS auto-preflight: when OPTIONS is requested but no
|
|
989
|
+
// OPTIONS handler is exported, generate a sensible preflight
|
|
990
|
+
// response (PRD §26.3). Safe by default — no Allow-Origin.
|
|
991
|
+
if (method === "OPTIONS" && !hasHandler) {
|
|
992
|
+
const headers = buildPreflightHeaders(epMethods);
|
|
993
|
+
res.statusCode = 204;
|
|
994
|
+
res.setHeader("Allow", headers.allow);
|
|
995
|
+
res.setHeader("Access-Control-Allow-Methods", headers.accessControlAllowMethods);
|
|
996
|
+
res.setHeader("Access-Control-Allow-Headers", headers.accessControlAllowHeaders);
|
|
997
|
+
res.setHeader("Access-Control-Max-Age", headers.accessControlMaxAge);
|
|
998
|
+
res.end();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (hasHandler) {
|
|
1002
|
+
return await handleEndpoint(server, req, res, endpointMatch, method);
|
|
1003
|
+
}
|
|
1004
|
+
res.statusCode = 405;
|
|
1005
|
+
res.setHeader("Content-Type", "text/plain");
|
|
1006
|
+
res.setHeader("Allow", epMethods.filter((m) => m !== "default").join(", ") || "GET");
|
|
1007
|
+
res.end(`Method ${method} not allowed`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
// --- Page handling (GET only) ---
|
|
1011
|
+
if (method !== "GET") {
|
|
1012
|
+
return next();
|
|
1013
|
+
}
|
|
1014
|
+
const match = pageMatcher.matchRoute(url);
|
|
1015
|
+
if (!match) {
|
|
1016
|
+
await sendStatusPage(res, 404, url);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
// Check if RSC environments are available
|
|
1020
|
+
if (hasRscEnvironments()) {
|
|
1021
|
+
await handlePageWithRsc(server, req, res, match, appDir, softNav, i18nConfig, sendStatusPage);
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
await handlePageLegacy(server, req, res, match, appDir, hydrationScripts, hydrationCounter++, HYDRATE_VIRTUAL_PREFIX, i18nConfig);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
server.ssrFixStacktrace(error);
|
|
1029
|
+
if (isStatusError(error)) {
|
|
1030
|
+
// User code threw statusResponse() — render the corresponding status page
|
|
1031
|
+
server.config.logger.info(`[catmint] Status ${error.statusCode} for ${url}`);
|
|
1032
|
+
if (!res.headersSent) {
|
|
1033
|
+
await sendStatusPage(res, error.statusCode, url, undefined, error.data);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
// Unexpected error — render 500 page
|
|
1038
|
+
server.config.logger.error(`[catmint] SSR error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1039
|
+
if (!res.headersSent) {
|
|
1040
|
+
await sendStatusPage(res, 500, url, {
|
|
1041
|
+
detail: error instanceof Error
|
|
1042
|
+
? (error.stack ?? error.message)
|
|
1043
|
+
: String(error),
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
};
|
|
1050
|
+
},
|
|
1051
|
+
handleHotUpdate({ file, server: hmrServer }) {
|
|
1052
|
+
if (file.includes("/app/") &&
|
|
1053
|
+
(file.endsWith("page.tsx") ||
|
|
1054
|
+
file.endsWith("page.jsx") ||
|
|
1055
|
+
file.endsWith("page.mdx") ||
|
|
1056
|
+
file.endsWith("endpoint.ts") ||
|
|
1057
|
+
file.endsWith("endpoint.js"))) {
|
|
1058
|
+
pageMatcher = null;
|
|
1059
|
+
endpointMatcher = null;
|
|
1060
|
+
}
|
|
1061
|
+
// Status page files (e.g. 404.tsx, 500.tsx) — trigger full page reload.
|
|
1062
|
+
// These are rendered on-demand during error handling and aren't part of
|
|
1063
|
+
// the React component HMR tree, so a full reload ensures the updated
|
|
1064
|
+
// status page is picked up on the next error.
|
|
1065
|
+
if (file.includes("/app/")) {
|
|
1066
|
+
const basename = file.split("/").pop() ?? "";
|
|
1067
|
+
if (/^\d{3}\.(tsx|jsx|ts|js)$/.test(basename)) {
|
|
1068
|
+
statusPages = scanStatusPages(appDir);
|
|
1069
|
+
hmrServer.config.logger.info(`[catmint] Status page changed: ${basename} — triggering reload`);
|
|
1070
|
+
hmrServer.hot.send({ type: "full-reload", path: "*" });
|
|
1071
|
+
return [];
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
// --------------------------------------------------------------------------
|
|
1078
|
+
// RSC rendering pipeline (three-phase)
|
|
1079
|
+
// --------------------------------------------------------------------------
|
|
1080
|
+
/**
|
|
1081
|
+
* Handle a page request using the RSC three-phase pipeline.
|
|
1082
|
+
*
|
|
1083
|
+
* Phase 1: RSC environment renders the page → flight stream
|
|
1084
|
+
* Phase 2: SSR environment converts flight stream → HTML stream
|
|
1085
|
+
* Phase 3: Inject RSC payload into HTML for client hydration
|
|
1086
|
+
*/
|
|
1087
|
+
async function handlePageWithRsc(server, req, res, match, appDir, softNavigation = true, i18nConfig = null, sendStatusPage) {
|
|
1088
|
+
const url = req.originalUrl ?? req.url ?? "/";
|
|
1089
|
+
const root = server.config.root;
|
|
1090
|
+
// Collect layout chain for this page
|
|
1091
|
+
const layoutPaths = collectLayouts(match.route.filePath, appDir);
|
|
1092
|
+
// Convert paths to Vite-resolvable import paths (relative to root)
|
|
1093
|
+
const pageImportPath = "/" + relative(root, match.route.filePath);
|
|
1094
|
+
const layoutImportPaths = layoutPaths.map((lp) => "/" + relative(root, lp));
|
|
1095
|
+
// Collect CSS from the module graph
|
|
1096
|
+
const cssEntryIds = [match.route.filePath, ...layoutPaths];
|
|
1097
|
+
const cssStyles = await collectCss(server, cssEntryIds);
|
|
1098
|
+
const rscEnv = server.environments.rsc;
|
|
1099
|
+
const ssrEnv = server.environments.ssr;
|
|
1100
|
+
if (!rscEnv?.runner || !ssrEnv?.runner) {
|
|
1101
|
+
throw new Error("[catmint] RSC/SSR environment runners not available. " +
|
|
1102
|
+
"Make sure @vitejs/plugin-rsc is configured.");
|
|
1103
|
+
}
|
|
1104
|
+
// Phase 1: RSC rendering — produce the flight stream
|
|
1105
|
+
// We dynamically construct and evaluate code in the RSC environment
|
|
1106
|
+
// that imports the page + layouts and calls renderToReadableStream
|
|
1107
|
+
const rscMod = await rscEnv.runner.import("@vitejs/plugin-rsc/rsc");
|
|
1108
|
+
const { renderToReadableStream: rscRender } = rscMod;
|
|
1109
|
+
// Import page and layout modules in the RSC environment
|
|
1110
|
+
const pageMod = await rscEnv.runner.import(pageImportPath);
|
|
1111
|
+
const PageComponent = pageMod.default;
|
|
1112
|
+
if (!PageComponent) {
|
|
1113
|
+
server.config.logger.error(`[catmint] Page ${match.route.filePath} does not have a default export`);
|
|
1114
|
+
if (sendStatusPage) {
|
|
1115
|
+
await sendStatusPage(res, 404, url);
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
res.statusCode = 404;
|
|
1119
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1120
|
+
res.end(errorPageHtml(404));
|
|
1121
|
+
}
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const layoutComponents = [];
|
|
1125
|
+
const layoutMods = [];
|
|
1126
|
+
for (const lp of layoutImportPaths) {
|
|
1127
|
+
const layoutMod = await rscEnv.runner.import(lp);
|
|
1128
|
+
layoutMods.push(layoutMod);
|
|
1129
|
+
if (layoutMod.default) {
|
|
1130
|
+
layoutComponents.push(layoutMod.default);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
// Resolve generateMetadata exports from page + layout modules
|
|
1134
|
+
const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
|
|
1135
|
+
// We need React from the RSC environment (with react-server condition)
|
|
1136
|
+
const reactMod = await rscEnv.runner.import("react");
|
|
1137
|
+
const createElement = reactMod.createElement ?? reactMod.default?.createElement;
|
|
1138
|
+
if (!createElement) {
|
|
1139
|
+
server.config.logger.error(`[catmint] React module from RSC environment has no createElement. ` +
|
|
1140
|
+
`Available exports: ${Object.keys(reactMod).join(", ")}`);
|
|
1141
|
+
throw new Error("[catmint] Cannot find React.createElement in RSC environment");
|
|
1142
|
+
}
|
|
1143
|
+
// Build component tree: Layout0(Layout1(ErrorBoundary(Suspense(Page))))
|
|
1144
|
+
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1145
|
+
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1146
|
+
// with the error boundary component inside the nearest layout.
|
|
1147
|
+
let element = createElement(PageComponent, null);
|
|
1148
|
+
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1149
|
+
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1150
|
+
if (loadingPath) {
|
|
1151
|
+
const loadingImportPath = "/" + relative(root, loadingPath);
|
|
1152
|
+
try {
|
|
1153
|
+
const loadingMod = await rscEnv.runner.import(loadingImportPath);
|
|
1154
|
+
const LoadingComponent = loadingMod.default;
|
|
1155
|
+
if (LoadingComponent) {
|
|
1156
|
+
const Suspense = reactMod.Suspense ?? reactMod.default?.Suspense;
|
|
1157
|
+
if (Suspense) {
|
|
1158
|
+
element = createElement(Suspense, {
|
|
1159
|
+
fallback: createElement(LoadingComponent, null),
|
|
1160
|
+
children: element,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
server.config.logger.warn(`[catmint] Failed to load loading component ${loadingPath}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// Resolve error boundary via walk-up from page directory to app root
|
|
1170
|
+
const errorBoundaryPath = resolveErrorBoundary(match.route.filePath, appDir);
|
|
1171
|
+
if (errorBoundaryPath) {
|
|
1172
|
+
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1173
|
+
try {
|
|
1174
|
+
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1175
|
+
const ErrorBoundaryComponent = errorMod.default;
|
|
1176
|
+
if (ErrorBoundaryComponent) {
|
|
1177
|
+
// Wrap page with error boundary — it renders inside the nearest layout
|
|
1178
|
+
element = createElement(ErrorBoundaryComponent, { children: element });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
// If error boundary fails to load, skip it gracefully
|
|
1183
|
+
server.config.logger.warn(`[catmint] Failed to load error boundary ${errorBoundaryPath}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1187
|
+
element = createElement(layoutComponents[i], null, element);
|
|
1188
|
+
}
|
|
1189
|
+
// Wrap with AppProviders (client component — RSC sees it as a client reference)
|
|
1190
|
+
const appProvidersMod = await rscEnv.runner.import("catmint/runtime/app-providers");
|
|
1191
|
+
const AppProviders = appProvidersMod.AppProviders ?? appProvidersMod.default?.AppProviders;
|
|
1192
|
+
if (AppProviders) {
|
|
1193
|
+
const urlObj = new URL(url, "http://localhost");
|
|
1194
|
+
const providerProps = {
|
|
1195
|
+
pathname: urlObj.pathname,
|
|
1196
|
+
params: match.params ?? {},
|
|
1197
|
+
};
|
|
1198
|
+
if (i18nConfig) {
|
|
1199
|
+
providerProps.i18nConfig = i18nConfig;
|
|
1200
|
+
}
|
|
1201
|
+
if (headConfig) {
|
|
1202
|
+
providerProps.headConfig = headConfig;
|
|
1203
|
+
}
|
|
1204
|
+
element = createElement(AppProviders, providerProps, element);
|
|
1205
|
+
}
|
|
1206
|
+
// Render to RSC flight stream
|
|
1207
|
+
const rscStream = rscRender(element);
|
|
1208
|
+
// Phase 2: Tee the RSC stream — one for SSR, one for browser injection
|
|
1209
|
+
const [rscForSsr, rscForBrowser] = rscStream.tee();
|
|
1210
|
+
// SSR: convert RSC flight stream → React VDOM → HTML
|
|
1211
|
+
const ssrMod = await ssrEnv.runner.import("@vitejs/plugin-rsc/ssr");
|
|
1212
|
+
const { createFromReadableStream: ssrCreate } = ssrMod;
|
|
1213
|
+
const reactDomServer = await ssrEnv.runner.import("react-dom/server");
|
|
1214
|
+
const { renderToReadableStream: htmlRender } = reactDomServer;
|
|
1215
|
+
// createFromReadableStream returns a "thenable" that resolves to the React tree
|
|
1216
|
+
const ssrRoot = ssrCreate(rscForSsr);
|
|
1217
|
+
// Bootstrap script that loads the client hydration entry.
|
|
1218
|
+
// This is passed to renderToReadableStream which injects it as a <script>
|
|
1219
|
+
// tag in <body>, ensuring it runs after the HTML is parsed.
|
|
1220
|
+
// The virtual module imports rsc-html-stream/client to read embedded RSC
|
|
1221
|
+
// payload, @vitejs/plugin-rsc/browser to deserialize, and react-dom/client
|
|
1222
|
+
// to hydrate.
|
|
1223
|
+
const bootstrapScriptContent = `import("/@id/__x00__virtual:catmint/client-hydrate")`;
|
|
1224
|
+
// Render the React tree to an HTML stream
|
|
1225
|
+
// The SSR root is a thenable — React's renderToReadableStream handles it
|
|
1226
|
+
const htmlStream = await htmlRender(ssrRoot, {
|
|
1227
|
+
bootstrapScriptContent,
|
|
1228
|
+
});
|
|
1229
|
+
// Phase 3: Inject RSC payload into the HTML stream
|
|
1230
|
+
const rscHtmlStream = await ssrEnv.runner.import("rsc-html-stream/server");
|
|
1231
|
+
const { injectRSCPayload } = rscHtmlStream;
|
|
1232
|
+
const injectionTransform = injectRSCPayload(rscForBrowser);
|
|
1233
|
+
const finalStream = htmlStream.pipeThrough(injectionTransform);
|
|
1234
|
+
// Build head injection (Vite client, CSS, soft nav config, metadata)
|
|
1235
|
+
const headParts = [
|
|
1236
|
+
`<script>window.__catmintSoftNav=${softNavigation ? "true" : "false"}</script>`,
|
|
1237
|
+
'<script type="module" src="/@vite/client"></script>',
|
|
1238
|
+
cssStyles,
|
|
1239
|
+
];
|
|
1240
|
+
if (headConfig) {
|
|
1241
|
+
headParts.unshift(renderHeadToString(headConfig));
|
|
1242
|
+
}
|
|
1243
|
+
const headInjection = headParts.join("\n");
|
|
1244
|
+
// Stream the final HTML to the response, injecting our head content
|
|
1245
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1246
|
+
res.statusCode = 200;
|
|
1247
|
+
// We need to inject our scripts into <head>. Since we're dealing with a
|
|
1248
|
+
// Web ReadableStream, we'll buffer and inject.
|
|
1249
|
+
const reader = finalStream.getReader();
|
|
1250
|
+
const decoder = new TextDecoder();
|
|
1251
|
+
let injected = false;
|
|
1252
|
+
let buffer = "";
|
|
1253
|
+
try {
|
|
1254
|
+
while (true) {
|
|
1255
|
+
const { done, value } = await reader.read();
|
|
1256
|
+
if (done)
|
|
1257
|
+
break;
|
|
1258
|
+
const chunk = decoder.decode(value, {
|
|
1259
|
+
stream: true,
|
|
1260
|
+
});
|
|
1261
|
+
if (injected) {
|
|
1262
|
+
res.write(chunk);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
buffer += chunk;
|
|
1266
|
+
const headCloseIdx = buffer.indexOf("</head>");
|
|
1267
|
+
if (headCloseIdx !== -1) {
|
|
1268
|
+
injected = true;
|
|
1269
|
+
let before = buffer.slice(0, headCloseIdx);
|
|
1270
|
+
const after = buffer.slice(headCloseIdx);
|
|
1271
|
+
if (headConfig) {
|
|
1272
|
+
before = stripExistingHeadTags(before, headConfig);
|
|
1273
|
+
}
|
|
1274
|
+
res.write(before + headInjection + after);
|
|
1275
|
+
buffer = "";
|
|
1276
|
+
}
|
|
1277
|
+
else if (buffer.length > 7) {
|
|
1278
|
+
const safe = buffer.slice(0, -7);
|
|
1279
|
+
buffer = buffer.slice(-7);
|
|
1280
|
+
res.write(safe);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// Flush remaining buffer
|
|
1284
|
+
if (!injected && buffer) {
|
|
1285
|
+
res.write(buffer + headInjection);
|
|
1286
|
+
}
|
|
1287
|
+
else if (buffer) {
|
|
1288
|
+
res.write(buffer);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
finally {
|
|
1292
|
+
res.end();
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// --------------------------------------------------------------------------
|
|
1296
|
+
// RSC flight stream for client-side navigation
|
|
1297
|
+
// --------------------------------------------------------------------------
|
|
1298
|
+
/**
|
|
1299
|
+
* Handle an RSC navigation request.
|
|
1300
|
+
*
|
|
1301
|
+
* Returns the raw RSC flight stream for a page, without SSR-ing to HTML.
|
|
1302
|
+
* This is used by the client-side navigation runtime to perform SPA-style
|
|
1303
|
+
* page transitions: the browser fetches the flight stream, deserializes it
|
|
1304
|
+
* with createFromReadableStream, and React reconciles the new tree.
|
|
1305
|
+
*/
|
|
1306
|
+
async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig = null) {
|
|
1307
|
+
const url = _req.originalUrl ?? _req.url ?? "/";
|
|
1308
|
+
const root = server.config.root;
|
|
1309
|
+
// Collect layout chain for this page
|
|
1310
|
+
const layoutPaths = collectLayouts(match.route.filePath, appDir);
|
|
1311
|
+
// Convert paths to Vite-resolvable import paths
|
|
1312
|
+
const pageImportPath = "/" + relative(root, match.route.filePath);
|
|
1313
|
+
const layoutImportPaths = layoutPaths.map((lp) => "/" + relative(root, lp));
|
|
1314
|
+
const rscEnv = server.environments.rsc;
|
|
1315
|
+
if (!rscEnv?.runner) {
|
|
1316
|
+
throw new Error("[catmint] RSC environment runner not available for navigation.");
|
|
1317
|
+
}
|
|
1318
|
+
// Import RSC rendering utilities
|
|
1319
|
+
const rscMod = await rscEnv.runner.import("@vitejs/plugin-rsc/rsc");
|
|
1320
|
+
const { renderToReadableStream: rscRender } = rscMod;
|
|
1321
|
+
// Import page and layout modules in the RSC environment
|
|
1322
|
+
const pageMod = await rscEnv.runner.import(pageImportPath);
|
|
1323
|
+
const PageComponent = pageMod.default;
|
|
1324
|
+
if (!PageComponent) {
|
|
1325
|
+
res.statusCode = 404;
|
|
1326
|
+
res.setHeader("Content-Type", "application/json");
|
|
1327
|
+
res.end(JSON.stringify({ error: "Page has no default export" }));
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const layoutComponents = [];
|
|
1331
|
+
const layoutMods = [];
|
|
1332
|
+
for (const lp of layoutImportPaths) {
|
|
1333
|
+
const layoutMod = await rscEnv.runner.import(lp);
|
|
1334
|
+
layoutMods.push(layoutMod);
|
|
1335
|
+
if (layoutMod.default) {
|
|
1336
|
+
layoutComponents.push(layoutMod.default);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// Resolve generateMetadata exports from page + layout modules
|
|
1340
|
+
const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
|
|
1341
|
+
// Get React from the RSC environment
|
|
1342
|
+
const reactMod = await rscEnv.runner.import("react");
|
|
1343
|
+
const createElement = reactMod.createElement ?? reactMod.default?.createElement;
|
|
1344
|
+
if (!createElement) {
|
|
1345
|
+
throw new Error("[catmint] Cannot find React.createElement in RSC environment");
|
|
1346
|
+
}
|
|
1347
|
+
// Build component tree: Layout0(Layout1(ErrorBoundary(Suspense(Page))))
|
|
1348
|
+
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1349
|
+
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1350
|
+
// with the error boundary component inside the nearest layout.
|
|
1351
|
+
let element = createElement(PageComponent, null);
|
|
1352
|
+
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1353
|
+
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1354
|
+
if (loadingPath) {
|
|
1355
|
+
const loadingImportPath = "/" + relative(root, loadingPath);
|
|
1356
|
+
try {
|
|
1357
|
+
const loadingMod = await rscEnv.runner.import(loadingImportPath);
|
|
1358
|
+
const LoadingComponent = loadingMod.default;
|
|
1359
|
+
if (LoadingComponent) {
|
|
1360
|
+
const Suspense = reactMod.Suspense ?? reactMod.default?.Suspense;
|
|
1361
|
+
if (Suspense) {
|
|
1362
|
+
element = createElement(Suspense, {
|
|
1363
|
+
fallback: createElement(LoadingComponent, null),
|
|
1364
|
+
children: element,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
server.config.logger.warn(`[catmint] Failed to load loading component ${loadingPath}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// Resolve error boundary via walk-up from page directory to app root
|
|
1374
|
+
const errorBoundaryPath = resolveErrorBoundary(match.route.filePath, appDir);
|
|
1375
|
+
if (errorBoundaryPath) {
|
|
1376
|
+
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1377
|
+
try {
|
|
1378
|
+
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1379
|
+
const ErrorBoundaryComponent = errorMod.default;
|
|
1380
|
+
if (ErrorBoundaryComponent) {
|
|
1381
|
+
// Wrap page with error boundary — it renders inside the nearest layout
|
|
1382
|
+
element = createElement(ErrorBoundaryComponent, { children: element });
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
// If error boundary fails to load, skip it gracefully
|
|
1387
|
+
server.config.logger.warn(`[catmint] Failed to load error boundary ${errorBoundaryPath}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1391
|
+
element = createElement(layoutComponents[i], null, element);
|
|
1392
|
+
}
|
|
1393
|
+
// Wrap with AppProviders (client component — RSC sees it as a client reference)
|
|
1394
|
+
const appProvidersMod = await rscEnv.runner.import("catmint/runtime/app-providers");
|
|
1395
|
+
const AppProviders = appProvidersMod.AppProviders ?? appProvidersMod.default?.AppProviders;
|
|
1396
|
+
if (AppProviders) {
|
|
1397
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
1398
|
+
const targetPath = parsedUrl.searchParams.get("path") ?? parsedUrl.pathname;
|
|
1399
|
+
const providerProps = {
|
|
1400
|
+
pathname: targetPath,
|
|
1401
|
+
params: match.params ?? {},
|
|
1402
|
+
};
|
|
1403
|
+
if (i18nConfig) {
|
|
1404
|
+
providerProps.i18nConfig = i18nConfig;
|
|
1405
|
+
}
|
|
1406
|
+
if (headConfig) {
|
|
1407
|
+
providerProps.headConfig = headConfig;
|
|
1408
|
+
}
|
|
1409
|
+
element = createElement(AppProviders, providerProps, element);
|
|
1410
|
+
}
|
|
1411
|
+
// Render to RSC flight stream
|
|
1412
|
+
const rscStream = rscRender(element);
|
|
1413
|
+
// Stream the raw flight data to the client
|
|
1414
|
+
res.setHeader("Content-Type", "text/x-component; charset=utf-8");
|
|
1415
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
1416
|
+
res.statusCode = 200;
|
|
1417
|
+
const reader = rscStream.getReader();
|
|
1418
|
+
try {
|
|
1419
|
+
while (true) {
|
|
1420
|
+
const { done, value } = await reader.read();
|
|
1421
|
+
if (done)
|
|
1422
|
+
break;
|
|
1423
|
+
res.write(value);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
finally {
|
|
1427
|
+
res.end();
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
// --------------------------------------------------------------------------
|
|
1431
|
+
// Legacy SSR rendering (fallback when RSC plugin is not active)
|
|
1432
|
+
// --------------------------------------------------------------------------
|
|
1433
|
+
/**
|
|
1434
|
+
* Handle a page request using traditional SSR (no RSC).
|
|
1435
|
+
* Used as fallback when @vitejs/plugin-rsc is not configured.
|
|
1436
|
+
*/
|
|
1437
|
+
async function handlePageLegacy(server, _req, res, match, appDir, hydrScripts, hydrCounter, hydrPrefix, i18nConfig = null) {
|
|
1438
|
+
const url = _req.originalUrl ?? _req.url ?? "/";
|
|
1439
|
+
const { createRequire } = await import("node:module");
|
|
1440
|
+
const { Transform } = await import("node:stream");
|
|
1441
|
+
const userRequire = createRequire(join(server.config.root, "package.json"));
|
|
1442
|
+
const React = userRequire("react");
|
|
1443
|
+
const { renderToPipeableStream } = userRequire("react-dom/server");
|
|
1444
|
+
const pageMod = await server.ssrLoadModule(match.route.filePath);
|
|
1445
|
+
const PageComponent = pageMod.default;
|
|
1446
|
+
if (!PageComponent) {
|
|
1447
|
+
server.config.logger.error(`[catmint] Page ${match.route.filePath} does not have a default export`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const layoutPaths = collectLayouts(match.route.filePath, appDir);
|
|
1451
|
+
const layoutComponents = [];
|
|
1452
|
+
const layoutMods = [];
|
|
1453
|
+
for (const layoutPath of layoutPaths) {
|
|
1454
|
+
const layoutMod = await server.ssrLoadModule(layoutPath);
|
|
1455
|
+
layoutMods.push(layoutMod);
|
|
1456
|
+
if (layoutMod.default) {
|
|
1457
|
+
layoutComponents.push(layoutMod.default);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
// Resolve generateMetadata exports from page + layout modules
|
|
1461
|
+
const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
|
|
1462
|
+
let element = React.createElement(PageComponent, null);
|
|
1463
|
+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1464
|
+
element = React.createElement(layoutComponents[i], null, element);
|
|
1465
|
+
}
|
|
1466
|
+
// Wrap with AppProviders (legacy mode: import directly via SSR)
|
|
1467
|
+
const appProvidersMod = await server.ssrLoadModule("catmint/runtime/app-providers");
|
|
1468
|
+
const AppProviders = appProvidersMod.AppProviders;
|
|
1469
|
+
if (AppProviders) {
|
|
1470
|
+
const urlObj = new URL(url, "http://localhost");
|
|
1471
|
+
const providerProps = {
|
|
1472
|
+
pathname: urlObj.pathname,
|
|
1473
|
+
params: match.params ?? {},
|
|
1474
|
+
};
|
|
1475
|
+
if (i18nConfig) {
|
|
1476
|
+
providerProps.i18nConfig = i18nConfig;
|
|
1477
|
+
}
|
|
1478
|
+
if (headConfig) {
|
|
1479
|
+
providerProps.headConfig = headConfig;
|
|
1480
|
+
}
|
|
1481
|
+
element = React.createElement(AppProviders, providerProps, element);
|
|
1482
|
+
}
|
|
1483
|
+
const pageImportPath = "/" + relative(server.config.root, match.route.filePath);
|
|
1484
|
+
const layoutImports = layoutPaths.map((lp, i) => {
|
|
1485
|
+
const importPath = "/" + relative(server.config.root, lp);
|
|
1486
|
+
return `import Layout${i} from '${importPath}'`;
|
|
1487
|
+
});
|
|
1488
|
+
const layoutVarNames = layoutPaths.map((_, i) => `Layout${i}`);
|
|
1489
|
+
let hydrateTree = "createElement(Page, null)";
|
|
1490
|
+
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1491
|
+
hydrateTree = `createElement(${layoutVarNames[i]}, null, ${hydrateTree})`;
|
|
1492
|
+
}
|
|
1493
|
+
// Build AppProviders props as a JSON-safe string for the hydration script
|
|
1494
|
+
const providerPropsJson = JSON.stringify({
|
|
1495
|
+
pathname: new URL(url, "http://localhost").pathname,
|
|
1496
|
+
params: match.params ?? {},
|
|
1497
|
+
...(i18nConfig ? { i18nConfig } : {}),
|
|
1498
|
+
...(headConfig ? { headConfig } : {}),
|
|
1499
|
+
});
|
|
1500
|
+
const hydrationScript = [
|
|
1501
|
+
`import { hydrateRoot } from 'react-dom/client'`,
|
|
1502
|
+
`import { createElement } from 'react'`,
|
|
1503
|
+
`import { AppProviders } from 'catmint/runtime/app-providers'`,
|
|
1504
|
+
...layoutImports,
|
|
1505
|
+
`import Page from '${pageImportPath}'`,
|
|
1506
|
+
`var __providerProps = ${providerPropsJson}`,
|
|
1507
|
+
`hydrateRoot(document, createElement(AppProviders, __providerProps, ${hydrateTree}))`,
|
|
1508
|
+
].join("\n");
|
|
1509
|
+
const hydrateId = `entry-${hydrCounter}.js`;
|
|
1510
|
+
hydrScripts.set(hydrateId, hydrationScript);
|
|
1511
|
+
const cssEntryIds = [match.route.filePath, ...layoutPaths];
|
|
1512
|
+
const cssStyles = await collectCss(server, cssEntryIds);
|
|
1513
|
+
const headParts = [
|
|
1514
|
+
'<script type="module" src="/@vite/client"></script>',
|
|
1515
|
+
cssStyles,
|
|
1516
|
+
`<script type="module" src="${hydrPrefix}${hydrateId}"></script>`,
|
|
1517
|
+
];
|
|
1518
|
+
if (headConfig) {
|
|
1519
|
+
headParts.unshift(renderHeadToString(headConfig));
|
|
1520
|
+
}
|
|
1521
|
+
const headInjection = headParts.join("\n");
|
|
1522
|
+
const { pipe } = renderToPipeableStream(element, {
|
|
1523
|
+
onShellReady() {
|
|
1524
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1525
|
+
res.statusCode = 200;
|
|
1526
|
+
const transform = createInjectionTransform(headInjection, headConfig);
|
|
1527
|
+
pipe(transform);
|
|
1528
|
+
transform.pipe(res);
|
|
1529
|
+
},
|
|
1530
|
+
onShellError(error) {
|
|
1531
|
+
server.config.logger.error(`[catmint] SSR shell error for ${url}:`);
|
|
1532
|
+
server.config.logger.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
1533
|
+
res.statusCode = 500;
|
|
1534
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1535
|
+
res.end(errorPageHtml(500, { detail: String(error) }));
|
|
1536
|
+
},
|
|
1537
|
+
onError(error) {
|
|
1538
|
+
server.config.logger.error(`[catmint] SSR streaming error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1539
|
+
},
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
// --------------------------------------------------------------------------
|
|
1543
|
+
// Virtual module generators
|
|
1544
|
+
// --------------------------------------------------------------------------
|
|
1545
|
+
/**
|
|
1546
|
+
* Generate the RSC renderer virtual module.
|
|
1547
|
+
* This module is loaded in the RSC environment via environment.runner.import().
|
|
1548
|
+
* It exports a `renderPage(pagePath, layoutPaths)` function that:
|
|
1549
|
+
* - Imports the page and layout modules
|
|
1550
|
+
* - Builds the React element tree
|
|
1551
|
+
* - Calls renderToReadableStream to produce an RSC flight stream
|
|
1552
|
+
*/
|
|
1553
|
+
function generateRscRendererModule() {
|
|
1554
|
+
return `
|
|
1555
|
+
import { renderToReadableStream } from "@vitejs/plugin-rsc/rsc";
|
|
1556
|
+
import { createElement } from "react";
|
|
1557
|
+
import { AppProviders } from "catmint/runtime/app-providers";
|
|
1558
|
+
|
|
1559
|
+
export async function renderPage(pagePath, layoutPaths, providerProps) {
|
|
1560
|
+
const pageMod = await import(/* @vite-ignore */ pagePath);
|
|
1561
|
+
const Page = pageMod.default;
|
|
1562
|
+
if (!Page) throw new Error("Page " + pagePath + " has no default export");
|
|
1563
|
+
|
|
1564
|
+
const layouts = [];
|
|
1565
|
+
for (const lp of layoutPaths) {
|
|
1566
|
+
const mod = await import(/* @vite-ignore */ lp);
|
|
1567
|
+
if (mod.default) layouts.push(mod.default);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
let element = createElement(Page, null);
|
|
1571
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
1572
|
+
element = createElement(layouts[i], null, element);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Wrap with framework providers
|
|
1576
|
+
element = createElement(AppProviders, providerProps || {}, element);
|
|
1577
|
+
|
|
1578
|
+
return renderToReadableStream(element);
|
|
1579
|
+
}
|
|
1580
|
+
`;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Generate the SSR renderer virtual module.
|
|
1584
|
+
* This module is loaded in the SSR environment. It exports a
|
|
1585
|
+
* `renderToHtml(rscStream)` function that:
|
|
1586
|
+
* - Calls createFromReadableStream to deserialize the RSC flight stream
|
|
1587
|
+
* - Passes the result to react-dom/server.renderToReadableStream
|
|
1588
|
+
* - Returns an HTML ReadableStream
|
|
1589
|
+
*/
|
|
1590
|
+
function generateSsrRendererModule() {
|
|
1591
|
+
return `
|
|
1592
|
+
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
|
|
1593
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
1594
|
+
import { injectRSCPayload } from "rsc-html-stream/server";
|
|
1595
|
+
|
|
1596
|
+
export async function renderToHtml(rscStream) {
|
|
1597
|
+
const [rscForSsr, rscForBrowser] = rscStream.tee();
|
|
1598
|
+
const root = createFromReadableStream(rscForSsr);
|
|
1599
|
+
const htmlStream = await renderToReadableStream(root);
|
|
1600
|
+
return htmlStream.pipeThrough(injectRSCPayload(rscForBrowser));
|
|
1601
|
+
}
|
|
1602
|
+
`;
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Generate the client hydration virtual module.
|
|
1606
|
+
* This module runs in the browser. It:
|
|
1607
|
+
* - Reads the embedded RSC payload from the HTML
|
|
1608
|
+
* - Deserializes it via createFromReadableStream
|
|
1609
|
+
* - Hydrates the document via hydrateRoot
|
|
1610
|
+
* - Installs client-side navigation for relative links (when soft nav is enabled)
|
|
1611
|
+
*/
|
|
1612
|
+
function generateClientHydrateModule() {
|
|
1613
|
+
return `
|
|
1614
|
+
import { rscStream } from "rsc-html-stream/client";
|
|
1615
|
+
import { createFromReadableStream } from "@vitejs/plugin-rsc/browser";
|
|
1616
|
+
import { hydrateRoot } from "react-dom/client";
|
|
1617
|
+
|
|
1618
|
+
let root = null;
|
|
1619
|
+
|
|
1620
|
+
async function hydrate() {
|
|
1621
|
+
const initialRoot = await createFromReadableStream(rscStream);
|
|
1622
|
+
root = hydrateRoot(document, initialRoot);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
${CLIENT_NAVIGATION_RUNTIME}
|
|
1626
|
+
|
|
1627
|
+
hydrate().then(() => {
|
|
1628
|
+
if (window.__catmintSoftNav) {
|
|
1629
|
+
setupClientNavigation(root);
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
`;
|
|
1633
|
+
}
|
|
1634
|
+
// --------------------------------------------------------------------------
|
|
1635
|
+
// Utilities
|
|
1636
|
+
// --------------------------------------------------------------------------
|
|
1637
|
+
function createInjectionTransform(injection, headConfig) {
|
|
1638
|
+
const { Transform } = require("node:stream");
|
|
1639
|
+
let injected = false;
|
|
1640
|
+
let buffer = "";
|
|
1641
|
+
return new Transform({
|
|
1642
|
+
transform(chunk, _encoding, callback) {
|
|
1643
|
+
if (injected) {
|
|
1644
|
+
callback(null, chunk);
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
buffer += chunk.toString();
|
|
1648
|
+
const headCloseIdx = buffer.indexOf("</head>");
|
|
1649
|
+
if (headCloseIdx !== -1) {
|
|
1650
|
+
injected = true;
|
|
1651
|
+
let before = buffer.slice(0, headCloseIdx);
|
|
1652
|
+
const after = buffer.slice(headCloseIdx);
|
|
1653
|
+
if (headConfig) {
|
|
1654
|
+
before = stripExistingHeadTags(before, headConfig);
|
|
1655
|
+
}
|
|
1656
|
+
callback(null, before + injection + after);
|
|
1657
|
+
buffer = "";
|
|
1658
|
+
}
|
|
1659
|
+
else {
|
|
1660
|
+
if (buffer.length > 7) {
|
|
1661
|
+
const safe = buffer.slice(0, -7);
|
|
1662
|
+
buffer = buffer.slice(-7);
|
|
1663
|
+
callback(null, safe);
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
callback();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
flush(callback) {
|
|
1671
|
+
if (!injected && buffer) {
|
|
1672
|
+
callback(null, buffer + injection);
|
|
1673
|
+
}
|
|
1674
|
+
else if (buffer) {
|
|
1675
|
+
callback(null, buffer);
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
callback();
|
|
1679
|
+
}
|
|
1680
|
+
},
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
//# sourceMappingURL=dev-server.js.map
|