@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.
Files changed (54) hide show
  1. package/LICENSE +339 -0
  2. package/dist/boundary.d.ts +15 -0
  3. package/dist/boundary.d.ts.map +1 -0
  4. package/dist/boundary.js +193 -0
  5. package/dist/boundary.js.map +1 -0
  6. package/dist/build-entries.d.ts +81 -0
  7. package/dist/build-entries.d.ts.map +1 -0
  8. package/dist/build-entries.js +1139 -0
  9. package/dist/build-entries.js.map +1 -0
  10. package/dist/cors-utils.d.ts +22 -0
  11. package/dist/cors-utils.d.ts.map +1 -0
  12. package/dist/cors-utils.js +36 -0
  13. package/dist/cors-utils.js.map +1 -0
  14. package/dist/dev-server.d.ts +34 -0
  15. package/dist/dev-server.d.ts.map +1 -0
  16. package/dist/dev-server.js +1683 -0
  17. package/dist/dev-server.js.map +1 -0
  18. package/dist/env-transform.d.ts +16 -0
  19. package/dist/env-transform.d.ts.map +1 -0
  20. package/dist/env-transform.js +125 -0
  21. package/dist/env-transform.js.map +1 -0
  22. package/dist/error-page.d.ts +19 -0
  23. package/dist/error-page.d.ts.map +1 -0
  24. package/dist/error-page.js +152 -0
  25. package/dist/error-page.js.map +1 -0
  26. package/dist/index.d.ts +92 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +100 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/mdx-transform.d.ts +33 -0
  31. package/dist/mdx-transform.d.ts.map +1 -0
  32. package/dist/mdx-transform.js +86 -0
  33. package/dist/mdx-transform.js.map +1 -0
  34. package/dist/middleware.d.ts +13 -0
  35. package/dist/middleware.d.ts.map +1 -0
  36. package/dist/middleware.js +155 -0
  37. package/dist/middleware.js.map +1 -0
  38. package/dist/resolve-utils.d.ts +17 -0
  39. package/dist/resolve-utils.d.ts.map +1 -0
  40. package/dist/resolve-utils.js +51 -0
  41. package/dist/resolve-utils.js.map +1 -0
  42. package/dist/route-gen.d.ts +12 -0
  43. package/dist/route-gen.d.ts.map +1 -0
  44. package/dist/route-gen.js +221 -0
  45. package/dist/route-gen.js.map +1 -0
  46. package/dist/server-fn-transform.d.ts +12 -0
  47. package/dist/server-fn-transform.d.ts.map +1 -0
  48. package/dist/server-fn-transform.js +394 -0
  49. package/dist/server-fn-transform.js.map +1 -0
  50. package/dist/utils.d.ts +56 -0
  51. package/dist/utils.d.ts.map +1 -0
  52. package/dist/utils.js +198 -0
  53. package/dist/utils.js.map +1 -0
  54. 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, "&amp;")
198
+ .replace(/"/g, "&quot;")
199
+ .replace(/</g, "&lt;")
200
+ .replace(/>/g, "&gt;");
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