@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,1139 @@
|
|
|
1
|
+
// @catmint/vite/build-entries — Virtual entry modules for production builds
|
|
2
|
+
//
|
|
3
|
+
// Provides virtual modules consumed by catmint build:
|
|
4
|
+
//
|
|
5
|
+
// Legacy (non-RSC) entries:
|
|
6
|
+
// 1. virtual:catmint/client-entry
|
|
7
|
+
// Client-side hydration entry with per-route code splitting.
|
|
8
|
+
// 2. virtual:catmint/server-entry
|
|
9
|
+
// Server-side render function using renderToPipeableStream.
|
|
10
|
+
//
|
|
11
|
+
// RSC entries (three-environment build):
|
|
12
|
+
// 3. virtual:catmint/rsc-entry
|
|
13
|
+
// RSC environment entry: imports pages+layouts, exports render(url)
|
|
14
|
+
// that builds element tree and calls renderToReadableStream from
|
|
15
|
+
// @vitejs/plugin-rsc/rsc to produce an RSC flight stream.
|
|
16
|
+
// 4. virtual:catmint/ssr-rsc-entry
|
|
17
|
+
// SSR environment entry for RSC mode: exports renderToHtml(rscStream)
|
|
18
|
+
// that converts flight stream to HTML, plus endpoint handling.
|
|
19
|
+
// 5. virtual:catmint/client-rsc-entry
|
|
20
|
+
// Client environment entry for RSC mode: reads embedded RSC payload
|
|
21
|
+
// from the HTML and hydrates via hydrateRoot.
|
|
22
|
+
import { dirname, join, relative, resolve, sep, posix } from "node:path";
|
|
23
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
24
|
+
import { deterministicHash, toPosixPath, CLIENT_NAVIGATION_RUNTIME, } from "./utils.js";
|
|
25
|
+
const CLIENT_ENTRY_ID = "virtual:catmint/client-entry";
|
|
26
|
+
const SERVER_ENTRY_ID = "virtual:catmint/server-entry";
|
|
27
|
+
const RSC_ENTRY_ID = "virtual:catmint/rsc-entry";
|
|
28
|
+
const SSR_RSC_ENTRY_ID = "virtual:catmint/ssr-rsc-entry";
|
|
29
|
+
const CLIENT_RSC_ENTRY_ID = "virtual:catmint/client-rsc-entry";
|
|
30
|
+
const RESOLVED_CLIENT_ID = "\0" + CLIENT_ENTRY_ID;
|
|
31
|
+
const RESOLVED_SERVER_ID = "\0" + SERVER_ENTRY_ID;
|
|
32
|
+
const RESOLVED_RSC_ID = "\0" + RSC_ENTRY_ID;
|
|
33
|
+
const RESOLVED_SSR_RSC_ID = "\0" + SSR_RSC_ENTRY_ID;
|
|
34
|
+
const RESOLVED_CLIENT_RSC_ID = "\0" + CLIENT_RSC_ENTRY_ID;
|
|
35
|
+
/**
|
|
36
|
+
* Scan the app directory for status page files (e.g. 404.tsx, 500.tsx).
|
|
37
|
+
*
|
|
38
|
+
* Recursively walks the app directory looking for files named `{code}.tsx`
|
|
39
|
+
* (or `.jsx`, `.ts`, `.js`) where `code` is a 3-digit HTTP status code.
|
|
40
|
+
* Returns them sorted from most specific (deepest) to least specific (root).
|
|
41
|
+
*/
|
|
42
|
+
export function scanStatusPages(appDir) {
|
|
43
|
+
const STATUS_PAGE_REGEX = /^(\d{3})\.(tsx|jsx|ts|js)$/;
|
|
44
|
+
const normalizedAppDir = resolve(appDir);
|
|
45
|
+
const results = [];
|
|
46
|
+
function walk(dir) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(dir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const fullPath = join(dir, entry);
|
|
56
|
+
const match = STATUS_PAGE_REGEX.exec(entry);
|
|
57
|
+
if (match) {
|
|
58
|
+
const statusCode = parseInt(match[1], 10);
|
|
59
|
+
// Compute URL prefix: /app/admin/404.tsx → "/admin"
|
|
60
|
+
const relDir = relative(normalizedAppDir, dir);
|
|
61
|
+
const urlPrefix = relDir
|
|
62
|
+
? "/" + relDir.split(sep).join(posix.sep)
|
|
63
|
+
: "/";
|
|
64
|
+
results.push({ statusCode, filePath: fullPath, urlPrefix });
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(fullPath).isDirectory()) {
|
|
68
|
+
// Skip node_modules and hidden dirs
|
|
69
|
+
if (!entry.startsWith(".") && entry !== "node_modules") {
|
|
70
|
+
walk(fullPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Skip inaccessible entries
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
walk(normalizedAppDir);
|
|
80
|
+
// Sort: most specific (longest prefix) first, so resolution picks the nearest
|
|
81
|
+
results.sort((a, b) => b.urlPrefix.length - a.urlPrefix.length);
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Layout file names to check, in priority order.
|
|
86
|
+
*/
|
|
87
|
+
const LAYOUT_FILENAMES = ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"];
|
|
88
|
+
/**
|
|
89
|
+
* Walk up from a page file's directory to the app root, collecting
|
|
90
|
+
* layout files in order from outermost (root) to innermost.
|
|
91
|
+
*/
|
|
92
|
+
function collectLayouts(pageFilePath, appDir) {
|
|
93
|
+
const layouts = [];
|
|
94
|
+
let dir = dirname(pageFilePath);
|
|
95
|
+
const normalizedAppDir = resolve(appDir);
|
|
96
|
+
while (dir.startsWith(normalizedAppDir)) {
|
|
97
|
+
for (const filename of LAYOUT_FILENAMES) {
|
|
98
|
+
const layoutPath = join(dir, filename);
|
|
99
|
+
if (existsSync(layoutPath)) {
|
|
100
|
+
layouts.unshift(layoutPath);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (dir === normalizedAppDir)
|
|
105
|
+
break;
|
|
106
|
+
dir = dirname(dir);
|
|
107
|
+
}
|
|
108
|
+
return layouts;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Convert an absolute file path to a Vite-resolvable import path
|
|
112
|
+
* relative to the project root (e.g. '/app/layout.tsx').
|
|
113
|
+
*/
|
|
114
|
+
function toVitePath(filePath, root) {
|
|
115
|
+
return "/" + relative(root, filePath).split(sep).join(posix.sep);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Deduplicate an array preserving order.
|
|
119
|
+
*/
|
|
120
|
+
function unique(arr) {
|
|
121
|
+
return [...new Set(arr)];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create the build entries plugin.
|
|
125
|
+
*
|
|
126
|
+
* Must be provided with routes and appDir from the build command.
|
|
127
|
+
* Only active during production builds (not in dev mode).
|
|
128
|
+
*/
|
|
129
|
+
export function buildEntriesPlugin(options) {
|
|
130
|
+
const { routes, endpoints, appDir, serverFnFiles, statusPages, softNavigation = true, i18n: i18nConfig = null, } = options;
|
|
131
|
+
let root = "";
|
|
132
|
+
return {
|
|
133
|
+
name: "catmint:build-entries",
|
|
134
|
+
configResolved(config) {
|
|
135
|
+
root = config.root;
|
|
136
|
+
},
|
|
137
|
+
resolveId(id) {
|
|
138
|
+
if (id === CLIENT_ENTRY_ID)
|
|
139
|
+
return RESOLVED_CLIENT_ID;
|
|
140
|
+
if (id === SERVER_ENTRY_ID)
|
|
141
|
+
return RESOLVED_SERVER_ID;
|
|
142
|
+
if (id === RSC_ENTRY_ID)
|
|
143
|
+
return RESOLVED_RSC_ID;
|
|
144
|
+
if (id === SSR_RSC_ENTRY_ID)
|
|
145
|
+
return RESOLVED_SSR_RSC_ID;
|
|
146
|
+
if (id === CLIENT_RSC_ENTRY_ID)
|
|
147
|
+
return RESOLVED_CLIENT_RSC_ID;
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
load(id) {
|
|
151
|
+
if (id === RESOLVED_CLIENT_ID) {
|
|
152
|
+
return generateClientEntry(routes, appDir, root, i18nConfig);
|
|
153
|
+
}
|
|
154
|
+
if (id === RESOLVED_SERVER_ID) {
|
|
155
|
+
return generateServerEntry(routes, endpoints, appDir, root, i18nConfig);
|
|
156
|
+
}
|
|
157
|
+
if (id === RESOLVED_RSC_ID) {
|
|
158
|
+
return generateRscEntry(routes, appDir, root, i18nConfig, statusPages ?? []);
|
|
159
|
+
}
|
|
160
|
+
if (id === RESOLVED_SSR_RSC_ID) {
|
|
161
|
+
return generateSsrRscEntry(endpoints, root, serverFnFiles ?? []);
|
|
162
|
+
}
|
|
163
|
+
if (id === RESOLVED_CLIENT_RSC_ID) {
|
|
164
|
+
return generateClientRscEntry(softNavigation);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Generate the client-side hydration entry module.
|
|
172
|
+
*
|
|
173
|
+
* Creates a route map where each route lazily imports its page component.
|
|
174
|
+
* At runtime, matches the current URL against the route patterns and
|
|
175
|
+
* hydrates with the correct page + layout chain.
|
|
176
|
+
*
|
|
177
|
+
* Layout components are eagerly imported since they're shared across
|
|
178
|
+
* many routes and needed immediately for hydration.
|
|
179
|
+
*/
|
|
180
|
+
function generateClientEntry(routes, appDir, root, i18nConfig = null) {
|
|
181
|
+
// Collect all unique layout files across all routes
|
|
182
|
+
const allLayoutPaths = [];
|
|
183
|
+
const routeLayoutMap = new Map();
|
|
184
|
+
for (const route of routes) {
|
|
185
|
+
const layouts = collectLayouts(route.filePath, appDir);
|
|
186
|
+
routeLayoutMap.set(route.pattern, layouts);
|
|
187
|
+
for (const lp of layouts) {
|
|
188
|
+
if (!allLayoutPaths.includes(lp)) {
|
|
189
|
+
allLayoutPaths.push(lp);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const lines = [
|
|
194
|
+
"// Auto-generated client entry by catmint build",
|
|
195
|
+
"// Per-route code splitting with lazy page imports",
|
|
196
|
+
"",
|
|
197
|
+
"import { hydrateRoot } from 'react-dom/client'",
|
|
198
|
+
"import { createElement, lazy, Suspense } from 'react'",
|
|
199
|
+
"import { AppProviders } from 'catmint/runtime/app-providers'",
|
|
200
|
+
"",
|
|
201
|
+
];
|
|
202
|
+
// Eagerly import all layouts (shared across routes)
|
|
203
|
+
for (let i = 0; i < allLayoutPaths.length; i++) {
|
|
204
|
+
const vitePath = toVitePath(allLayoutPaths[i], root);
|
|
205
|
+
lines.push(`import Layout_${i} from '${vitePath}'`);
|
|
206
|
+
}
|
|
207
|
+
lines.push("");
|
|
208
|
+
// Create the route map with lazy page imports
|
|
209
|
+
lines.push("const routeMap = {");
|
|
210
|
+
for (const route of routes) {
|
|
211
|
+
const vitePath = toVitePath(route.filePath, root);
|
|
212
|
+
// Use JSON.stringify for the key to handle patterns with special chars
|
|
213
|
+
lines.push(` ${JSON.stringify(route.pattern)}: {`);
|
|
214
|
+
lines.push(` page: () => import('${vitePath}'),`);
|
|
215
|
+
// Map this route's layouts to their indices in allLayoutPaths
|
|
216
|
+
const layouts = routeLayoutMap.get(route.pattern) ?? [];
|
|
217
|
+
const layoutIndices = layouts.map((lp) => allLayoutPaths.indexOf(lp));
|
|
218
|
+
lines.push(` layouts: [${layoutIndices.map((i) => `Layout_${i}`).join(", ")}],`);
|
|
219
|
+
lines.push(` },`);
|
|
220
|
+
}
|
|
221
|
+
lines.push("}");
|
|
222
|
+
lines.push("");
|
|
223
|
+
// Serialize i18n config for injection into generated code
|
|
224
|
+
const i18nConfigJson = i18nConfig ? JSON.stringify(i18nConfig) : "null";
|
|
225
|
+
// Generate the route matching and hydration logic
|
|
226
|
+
lines.push(`
|
|
227
|
+
// Match the current URL to a route pattern
|
|
228
|
+
function matchRoute(pathname) {
|
|
229
|
+
// First try exact match
|
|
230
|
+
if (routeMap[pathname]) return { route: routeMap[pathname], params: {} }
|
|
231
|
+
|
|
232
|
+
// Try pattern matching for dynamic routes
|
|
233
|
+
for (const [pattern, route] of Object.entries(routeMap)) {
|
|
234
|
+
const params = matchPattern(pattern, pathname)
|
|
235
|
+
if (params) return { route, params }
|
|
236
|
+
}
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Match a URL pattern like '/user/:id' or '/docs/*slug' against a pathname
|
|
241
|
+
function matchPattern(pattern, pathname) {
|
|
242
|
+
const patternParts = pattern.split('/').filter(Boolean)
|
|
243
|
+
const pathParts = pathname.split('/').filter(Boolean)
|
|
244
|
+
|
|
245
|
+
const params = {}
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
248
|
+
const pp = patternParts[i]
|
|
249
|
+
|
|
250
|
+
// Catch-all segment
|
|
251
|
+
if (pp.startsWith('*')) {
|
|
252
|
+
const paramName = pp.endsWith('?') ? pp.slice(1, -1) : pp.slice(1)
|
|
253
|
+
params[paramName] = pathParts.slice(i)
|
|
254
|
+
return params
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Dynamic segment
|
|
258
|
+
if (pp.startsWith(':')) {
|
|
259
|
+
if (i >= pathParts.length) return null
|
|
260
|
+
params[pp.slice(1)] = pathParts[i]
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Static segment
|
|
265
|
+
if (i >= pathParts.length || pp !== pathParts[i]) return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Optional catch-all: pattern has fewer parts and last is optional
|
|
269
|
+
if (patternParts.length < pathParts.length) return null
|
|
270
|
+
if (patternParts.length > pathParts.length) {
|
|
271
|
+
// Check if remaining are optional catch-all
|
|
272
|
+
const remaining = patternParts[pathParts.length]
|
|
273
|
+
if (remaining && remaining.startsWith('*') && remaining.endsWith('?')) {
|
|
274
|
+
const paramName = remaining.slice(1, -1)
|
|
275
|
+
params[paramName] = []
|
|
276
|
+
return params
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return params
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Hydrate the current page
|
|
285
|
+
var __i18nConfig = ${i18nConfigJson}
|
|
286
|
+
var pathname = window.location.pathname
|
|
287
|
+
var matched = matchRoute(pathname)
|
|
288
|
+
|
|
289
|
+
if (matched) {
|
|
290
|
+
matched.route.page().then(function(pageMod) {
|
|
291
|
+
var Page = pageMod.default
|
|
292
|
+
var layouts = matched.route.layouts
|
|
293
|
+
|
|
294
|
+
// Build component tree: outermost layout wraps innermost wraps page
|
|
295
|
+
var element = createElement(Page, null)
|
|
296
|
+
for (var i = layouts.length - 1; i >= 0; i--) {
|
|
297
|
+
element = createElement(layouts[i], null, element)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Wrap with framework providers
|
|
301
|
+
var providerProps = { pathname: pathname, params: matched.params }
|
|
302
|
+
if (__i18nConfig) providerProps.i18nConfig = __i18nConfig
|
|
303
|
+
element = createElement(AppProviders, providerProps, element)
|
|
304
|
+
|
|
305
|
+
hydrateRoot(document, element)
|
|
306
|
+
})
|
|
307
|
+
} else {
|
|
308
|
+
console.warn('[catmint] No matching route for', pathname)
|
|
309
|
+
}
|
|
310
|
+
`);
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Generate the server-side render entry module (legacy, non-RSC mode).
|
|
315
|
+
*
|
|
316
|
+
* Exports a `render(url, options)` function that:
|
|
317
|
+
* 1. Matches the URL to a route
|
|
318
|
+
* 2. Loads the page + layout chain
|
|
319
|
+
* 3. Renders with renderToPipeableStream
|
|
320
|
+
* 4. Returns a pipeable stream
|
|
321
|
+
*/
|
|
322
|
+
function generateServerEntry(routes, endpoints, appDir, root, i18nConfig = null) {
|
|
323
|
+
// Collect all unique layout files
|
|
324
|
+
const allLayoutPaths = [];
|
|
325
|
+
const routeLayoutMap = new Map();
|
|
326
|
+
for (const route of routes) {
|
|
327
|
+
const layouts = collectLayouts(route.filePath, appDir);
|
|
328
|
+
routeLayoutMap.set(route.pattern, layouts);
|
|
329
|
+
for (const lp of layouts) {
|
|
330
|
+
if (!allLayoutPaths.includes(lp)) {
|
|
331
|
+
allLayoutPaths.push(lp);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const lines = [
|
|
336
|
+
"// Auto-generated server entry by catmint build",
|
|
337
|
+
"",
|
|
338
|
+
"import { createElement } from 'react'",
|
|
339
|
+
"import { renderToPipeableStream } from 'react-dom/server'",
|
|
340
|
+
"import { AppProviders } from 'catmint/runtime/app-providers'",
|
|
341
|
+
"",
|
|
342
|
+
];
|
|
343
|
+
// Import all page components
|
|
344
|
+
for (let i = 0; i < routes.length; i++) {
|
|
345
|
+
const vitePath = toVitePath(routes[i].filePath, root);
|
|
346
|
+
lines.push(`import Page_${i} from '${vitePath}'`);
|
|
347
|
+
}
|
|
348
|
+
lines.push("");
|
|
349
|
+
// Import all layout components
|
|
350
|
+
for (let i = 0; i < allLayoutPaths.length; i++) {
|
|
351
|
+
const vitePath = toVitePath(allLayoutPaths[i], root);
|
|
352
|
+
lines.push(`import Layout_${i} from '${vitePath}'`);
|
|
353
|
+
}
|
|
354
|
+
lines.push("");
|
|
355
|
+
// Create the route map
|
|
356
|
+
lines.push("const routeMap = {");
|
|
357
|
+
for (let i = 0; i < routes.length; i++) {
|
|
358
|
+
const route = routes[i];
|
|
359
|
+
const layouts = routeLayoutMap.get(route.pattern) ?? [];
|
|
360
|
+
const layoutIndices = layouts.map((lp) => allLayoutPaths.indexOf(lp));
|
|
361
|
+
lines.push(` ${JSON.stringify(route.pattern)}: {`);
|
|
362
|
+
lines.push(` page: Page_${i},`);
|
|
363
|
+
lines.push(` layouts: [${layoutIndices.map((j) => `Layout_${j}`).join(", ")}],`);
|
|
364
|
+
lines.push(` },`);
|
|
365
|
+
}
|
|
366
|
+
lines.push("}");
|
|
367
|
+
lines.push("");
|
|
368
|
+
// Route matching function
|
|
369
|
+
lines.push(ROUTE_MATCH_CODE);
|
|
370
|
+
// Serialize i18n config for injection into generated code
|
|
371
|
+
const i18nConfigJson = i18nConfig ? JSON.stringify(i18nConfig) : "null";
|
|
372
|
+
// Export the render function
|
|
373
|
+
lines.push(`
|
|
374
|
+
var __i18nConfig = ${i18nConfigJson}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Render a URL to a pipeable stream.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} url - The URL pathname to render
|
|
380
|
+
* @param {object} [options] - Render options
|
|
381
|
+
* @param {string[]} [options.bootstrapModules] - Client JS modules to inject as <script> tags
|
|
382
|
+
* @returns {{ stream: import('react-dom/server').PipeableStream, statusCode: number } | null}
|
|
383
|
+
*/
|
|
384
|
+
export function render(url, options) {
|
|
385
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
386
|
+
const matched = matchRoute(pathname)
|
|
387
|
+
|
|
388
|
+
if (!matched) return null
|
|
389
|
+
|
|
390
|
+
const { route, params } = matched
|
|
391
|
+
const { page: Page, layouts } = route
|
|
392
|
+
|
|
393
|
+
// Build component tree: Layout0(Layout1(...(Page)))
|
|
394
|
+
let element = createElement(Page, null)
|
|
395
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
396
|
+
element = createElement(layouts[i], null, element)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Wrap with framework providers
|
|
400
|
+
var providerProps = { pathname: pathname, params: params }
|
|
401
|
+
if (__i18nConfig) providerProps.i18nConfig = __i18nConfig
|
|
402
|
+
element = createElement(AppProviders, providerProps, element)
|
|
403
|
+
|
|
404
|
+
const streamOptions = {}
|
|
405
|
+
if (options && options.bootstrapModules) {
|
|
406
|
+
streamOptions.bootstrapModules = options.bootstrapModules
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const stream = renderToPipeableStream(element, streamOptions)
|
|
410
|
+
|
|
411
|
+
return { stream, statusCode: 200, params }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Check if a URL matches any page route.
|
|
416
|
+
*/
|
|
417
|
+
export function hasRoute(url) {
|
|
418
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
419
|
+
return matchRoute(pathname) !== null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Get all registered route patterns.
|
|
424
|
+
*/
|
|
425
|
+
export function getRoutePatterns() {
|
|
426
|
+
return Object.keys(routeMap)
|
|
427
|
+
}
|
|
428
|
+
`);
|
|
429
|
+
// --- Endpoint handling ---
|
|
430
|
+
if (endpoints.length > 0) {
|
|
431
|
+
lines.push("");
|
|
432
|
+
// Import all endpoint modules
|
|
433
|
+
for (let i = 0; i < endpoints.length; i++) {
|
|
434
|
+
const vitePath = toVitePath(endpoints[i].filePath, root);
|
|
435
|
+
lines.push(`import * as Endpoint_${i} from '${vitePath}'`);
|
|
436
|
+
}
|
|
437
|
+
lines.push("");
|
|
438
|
+
// Create the endpoint route map
|
|
439
|
+
lines.push("const endpointMap = {");
|
|
440
|
+
for (let i = 0; i < endpoints.length; i++) {
|
|
441
|
+
const ep = endpoints[i];
|
|
442
|
+
lines.push(` ${JSON.stringify(ep.pattern)}: {`);
|
|
443
|
+
lines.push(` mod: Endpoint_${i},`);
|
|
444
|
+
lines.push(` methods: ${JSON.stringify(ep.methods)},`);
|
|
445
|
+
lines.push(` },`);
|
|
446
|
+
}
|
|
447
|
+
lines.push("}");
|
|
448
|
+
lines.push(`
|
|
449
|
+
|
|
450
|
+
function matchEndpoint(pathname) {
|
|
451
|
+
if (endpointMap[pathname]) return { endpoint: endpointMap[pathname], params: {} }
|
|
452
|
+
for (const [pattern, endpoint] of Object.entries(endpointMap)) {
|
|
453
|
+
const params = matchPattern(pattern, pathname)
|
|
454
|
+
if (params) return { endpoint, params }
|
|
455
|
+
}
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Handle an API endpoint request.
|
|
461
|
+
*/
|
|
462
|
+
export async function handleEndpoint(url, method, request) {
|
|
463
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
464
|
+
const matched = matchEndpoint(pathname)
|
|
465
|
+
|
|
466
|
+
if (!matched) return null
|
|
467
|
+
|
|
468
|
+
const { endpoint, params } = matched
|
|
469
|
+
const mod = endpoint.mod
|
|
470
|
+
|
|
471
|
+
const handler = mod[method] || mod.ANY || mod.default
|
|
472
|
+
if (!handler) return null
|
|
473
|
+
|
|
474
|
+
const ctx = { params }
|
|
475
|
+
const response = await handler(request, ctx)
|
|
476
|
+
return { response }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Check if a URL matches any endpoint route.
|
|
481
|
+
*/
|
|
482
|
+
export function hasEndpoint(url) {
|
|
483
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
484
|
+
return matchEndpoint(pathname) !== null
|
|
485
|
+
}
|
|
486
|
+
`);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
lines.push(`
|
|
490
|
+
export async function handleEndpoint() { return null }
|
|
491
|
+
export function hasEndpoint() { return false }
|
|
492
|
+
`);
|
|
493
|
+
}
|
|
494
|
+
return lines.join("\n");
|
|
495
|
+
}
|
|
496
|
+
// --------------------------------------------------------------------------
|
|
497
|
+
// RSC build entry generators
|
|
498
|
+
// --------------------------------------------------------------------------
|
|
499
|
+
/**
|
|
500
|
+
* Generate the RSC environment entry module.
|
|
501
|
+
*
|
|
502
|
+
* This module runs in the RSC environment (with `react-server` resolve conditions).
|
|
503
|
+
* It imports all pages and layouts, and exports a `render(url)` function that:
|
|
504
|
+
* 1. Matches the URL to a route
|
|
505
|
+
* 2. Builds the React element tree (page + layouts)
|
|
506
|
+
* 3. Calls `renderToReadableStream` from @vitejs/plugin-rsc/rsc
|
|
507
|
+
* 4. Returns the RSC flight ReadableStream
|
|
508
|
+
*
|
|
509
|
+
* The RSC plugin automatically handles "use client" directives, replacing
|
|
510
|
+
* client components with client reference proxies in this environment.
|
|
511
|
+
*/
|
|
512
|
+
function generateRscEntry(routes, appDir, root, i18nConfig = null, statusPages = []) {
|
|
513
|
+
const allLayoutPaths = [];
|
|
514
|
+
const routeLayoutMap = new Map();
|
|
515
|
+
for (const route of routes) {
|
|
516
|
+
const layouts = collectLayouts(route.filePath, appDir);
|
|
517
|
+
routeLayoutMap.set(route.pattern, layouts);
|
|
518
|
+
for (const lp of layouts) {
|
|
519
|
+
if (!allLayoutPaths.includes(lp)) {
|
|
520
|
+
allLayoutPaths.push(lp);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const lines = [
|
|
525
|
+
"// Auto-generated RSC entry by catmint build",
|
|
526
|
+
"// Runs in the RSC environment with react-server resolve conditions",
|
|
527
|
+
"",
|
|
528
|
+
"import { createElement } from 'react'",
|
|
529
|
+
"import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'",
|
|
530
|
+
"import { AppProviders } from 'catmint/runtime/app-providers'",
|
|
531
|
+
"",
|
|
532
|
+
];
|
|
533
|
+
// Import all page components (default export + full module for generateMetadata)
|
|
534
|
+
for (let i = 0; i < routes.length; i++) {
|
|
535
|
+
const vitePath = toVitePath(routes[i].filePath, root);
|
|
536
|
+
lines.push(`import Page_${i}, * as PageMod_${i} from '${vitePath}'`);
|
|
537
|
+
}
|
|
538
|
+
lines.push("");
|
|
539
|
+
// Import all layout components (default export + full module for generateMetadata)
|
|
540
|
+
for (let i = 0; i < allLayoutPaths.length; i++) {
|
|
541
|
+
const vitePath = toVitePath(allLayoutPaths[i], root);
|
|
542
|
+
lines.push(`import Layout_${i}, * as LayoutMod_${i} from '${vitePath}'`);
|
|
543
|
+
}
|
|
544
|
+
lines.push("");
|
|
545
|
+
// Create the route map (includes module references for generateMetadata)
|
|
546
|
+
lines.push("const routeMap = {");
|
|
547
|
+
for (let i = 0; i < routes.length; i++) {
|
|
548
|
+
const route = routes[i];
|
|
549
|
+
const layouts = routeLayoutMap.get(route.pattern) ?? [];
|
|
550
|
+
const layoutIndices = layouts.map((lp) => allLayoutPaths.indexOf(lp));
|
|
551
|
+
lines.push(` ${JSON.stringify(route.pattern)}: {`);
|
|
552
|
+
lines.push(` page: Page_${i},`);
|
|
553
|
+
lines.push(` pageMod: PageMod_${i},`);
|
|
554
|
+
lines.push(` layouts: [${layoutIndices.map((j) => `Layout_${j}`).join(", ")}],`);
|
|
555
|
+
lines.push(` layoutMods: [${layoutIndices.map((j) => `LayoutMod_${j}`).join(", ")}],`);
|
|
556
|
+
lines.push(` },`);
|
|
557
|
+
}
|
|
558
|
+
lines.push("}");
|
|
559
|
+
// Serialize i18n config for injection into generated code
|
|
560
|
+
const i18nConfigJson = i18nConfig ? JSON.stringify(i18nConfig) : "null";
|
|
561
|
+
// Route matching + render function (with generateMetadata support)
|
|
562
|
+
lines.push(`
|
|
563
|
+
${ROUTE_MATCH_CODE}
|
|
564
|
+
|
|
565
|
+
var __i18nConfig = ${i18nConfigJson}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Merge multiple HeadConfig objects (last wins for title and meta keys).
|
|
569
|
+
*/
|
|
570
|
+
function __mergeHeadConfigs(configs) {
|
|
571
|
+
var merged = {}
|
|
572
|
+
var metaMap = new Map()
|
|
573
|
+
var links = []
|
|
574
|
+
|
|
575
|
+
for (var i = 0; i < configs.length; i++) {
|
|
576
|
+
var config = configs[i]
|
|
577
|
+
if (config.title !== undefined) merged.title = config.title
|
|
578
|
+
if (config.meta) {
|
|
579
|
+
for (var j = 0; j < config.meta.length; j++) {
|
|
580
|
+
var meta = config.meta[j]
|
|
581
|
+
var key = meta.name ? 'name:' + meta.name : meta.property ? 'property:' + meta.property : 'content:' + meta.content
|
|
582
|
+
metaMap.set(key, meta)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (config.link) links = links.concat(config.link)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (metaMap.size > 0) merged.meta = Array.from(metaMap.values())
|
|
589
|
+
if (links.length > 0) merged.link = links
|
|
590
|
+
|
|
591
|
+
return merged
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Resolve generateMetadata exports from page and layout modules.
|
|
596
|
+
* Layouts first (outermost to innermost), page last (page wins on conflict).
|
|
597
|
+
*/
|
|
598
|
+
async function __resolveGenerateMetadata(pageMod, layoutMods, params, url) {
|
|
599
|
+
var urlObj = new URL(url, 'http://localhost')
|
|
600
|
+
var search = {}
|
|
601
|
+
for (var [key, value] of urlObj.searchParams.entries()) {
|
|
602
|
+
search[key] = value
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
var metadataArgs = { params: params || {}, search: search }
|
|
606
|
+
var configs = []
|
|
607
|
+
|
|
608
|
+
for (var i = 0; i < layoutMods.length; i++) {
|
|
609
|
+
if (typeof layoutMods[i].generateMetadata === 'function') {
|
|
610
|
+
try {
|
|
611
|
+
var config = await layoutMods[i].generateMetadata(metadataArgs)
|
|
612
|
+
if (config && typeof config === 'object') configs.push(config)
|
|
613
|
+
} catch (e) { /* skip */ }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (typeof pageMod.generateMetadata === 'function') {
|
|
618
|
+
try {
|
|
619
|
+
var config = await pageMod.generateMetadata(metadataArgs)
|
|
620
|
+
if (config && typeof config === 'object') configs.push(config)
|
|
621
|
+
} catch (e) { /* skip */ }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (configs.length === 0) return undefined
|
|
625
|
+
return __mergeHeadConfigs(configs)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Render a URL to an RSC flight ReadableStream with optional head metadata.
|
|
630
|
+
*
|
|
631
|
+
* @param {string} url - The URL pathname to render
|
|
632
|
+
* @returns {Promise<{ stream: ReadableStream<Uint8Array>, headConfig?: object } | null>}
|
|
633
|
+
*/
|
|
634
|
+
export async function render(url) {
|
|
635
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
636
|
+
const matched = matchRoute(pathname)
|
|
637
|
+
|
|
638
|
+
if (!matched) return null
|
|
639
|
+
|
|
640
|
+
const { route, params } = matched
|
|
641
|
+
const { page: Page, pageMod, layouts, layoutMods } = route
|
|
642
|
+
|
|
643
|
+
// Resolve generateMetadata from page + layout modules
|
|
644
|
+
var headConfig = await __resolveGenerateMetadata(pageMod, layoutMods, params, url)
|
|
645
|
+
|
|
646
|
+
// Build component tree: Layout0(Layout1(...(Page)))
|
|
647
|
+
let element = createElement(Page, null)
|
|
648
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
649
|
+
element = createElement(layouts[i], null, element)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Wrap with framework providers (AppProviders is a client reference in RSC env)
|
|
653
|
+
var providerProps = { pathname: pathname, params: params }
|
|
654
|
+
if (__i18nConfig) providerProps.i18nConfig = __i18nConfig
|
|
655
|
+
if (headConfig) providerProps.headConfig = headConfig
|
|
656
|
+
element = createElement(AppProviders, providerProps, element)
|
|
657
|
+
|
|
658
|
+
return { stream: renderToReadableStream(element), headConfig: headConfig }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Check if a URL matches any page route.
|
|
663
|
+
*/
|
|
664
|
+
export function hasRoute(url) {
|
|
665
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
666
|
+
return matchRoute(pathname) !== null
|
|
667
|
+
}
|
|
668
|
+
`);
|
|
669
|
+
// --- Status page rendering ---
|
|
670
|
+
if (statusPages.length > 0) {
|
|
671
|
+
lines.push("");
|
|
672
|
+
lines.push("// Status page imports");
|
|
673
|
+
// Import each status page component (default + module namespace for generateMetadata)
|
|
674
|
+
for (let i = 0; i < statusPages.length; i++) {
|
|
675
|
+
const vitePath = toVitePath(statusPages[i].filePath, root);
|
|
676
|
+
lines.push(`import StatusPage_${i}, * as StatusPageMod_${i} from '${vitePath}'`);
|
|
677
|
+
}
|
|
678
|
+
lines.push("");
|
|
679
|
+
// Collect layouts for each status page
|
|
680
|
+
const statusPageLayoutMap = new Map();
|
|
681
|
+
for (let i = 0; i < statusPages.length; i++) {
|
|
682
|
+
const layouts = collectLayouts(statusPages[i].filePath, appDir);
|
|
683
|
+
statusPageLayoutMap.set(i, layouts);
|
|
684
|
+
// Add any new layout paths that aren't already tracked
|
|
685
|
+
for (const lp of layouts) {
|
|
686
|
+
if (!allLayoutPaths.includes(lp)) {
|
|
687
|
+
const vitePath = toVitePath(lp, root);
|
|
688
|
+
lines.push(`import Layout_${allLayoutPaths.length}, * as LayoutMod_${allLayoutPaths.length} from '${vitePath}'`);
|
|
689
|
+
allLayoutPaths.push(lp);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
lines.push("");
|
|
694
|
+
// Build the status page map: { [statusCode]: [{ urlPrefix, component, componentMod, layouts, layoutMods }] }
|
|
695
|
+
// Entries are ordered most-specific first (longest urlPrefix first)
|
|
696
|
+
lines.push("const statusPageMap = {");
|
|
697
|
+
const byCode = new Map();
|
|
698
|
+
for (let i = 0; i < statusPages.length; i++) {
|
|
699
|
+
const sp = statusPages[i];
|
|
700
|
+
if (!byCode.has(sp.statusCode))
|
|
701
|
+
byCode.set(sp.statusCode, []);
|
|
702
|
+
byCode.get(sp.statusCode).push({ index: i, urlPrefix: sp.urlPrefix });
|
|
703
|
+
}
|
|
704
|
+
for (const [code, entries] of byCode) {
|
|
705
|
+
// Already sorted most-specific first from scanStatusPages
|
|
706
|
+
lines.push(` ${code}: [`);
|
|
707
|
+
for (const entry of entries) {
|
|
708
|
+
const layouts = statusPageLayoutMap.get(entry.index) ?? [];
|
|
709
|
+
const layoutIndices = layouts.map((lp) => allLayoutPaths.indexOf(lp));
|
|
710
|
+
lines.push(` {`);
|
|
711
|
+
lines.push(` urlPrefix: ${JSON.stringify(entry.urlPrefix)},`);
|
|
712
|
+
lines.push(` component: StatusPage_${entry.index},`);
|
|
713
|
+
lines.push(` componentMod: StatusPageMod_${entry.index},`);
|
|
714
|
+
lines.push(` layouts: [${layoutIndices.map((j) => `Layout_${j}`).join(", ")}],`);
|
|
715
|
+
lines.push(` layoutMods: [${layoutIndices.map((j) => `LayoutMod_${j}`).join(", ")}],`);
|
|
716
|
+
lines.push(` },`);
|
|
717
|
+
}
|
|
718
|
+
lines.push(` ],`);
|
|
719
|
+
}
|
|
720
|
+
lines.push("}");
|
|
721
|
+
lines.push(`
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Render a status page to an RSC flight ReadableStream with optional head metadata.
|
|
725
|
+
*
|
|
726
|
+
* Finds the most specific status page for the given code and pathname,
|
|
727
|
+
* renders it through the RSC pipeline with layouts and AppProviders.
|
|
728
|
+
*
|
|
729
|
+
* @param {number} statusCode - HTTP status code (e.g. 404, 500)
|
|
730
|
+
* @param {string} pathname - The request pathname (for resolution + provider props)
|
|
731
|
+
* @param {object} [data] - Optional data to pass as props to the status page
|
|
732
|
+
* @returns {Promise<{ stream: ReadableStream<Uint8Array>, headConfig?: object } | null>}
|
|
733
|
+
*/
|
|
734
|
+
export async function renderStatusPage(statusCode, pathname, data) {
|
|
735
|
+
var candidates = statusPageMap[statusCode]
|
|
736
|
+
if (!candidates || candidates.length === 0) return null
|
|
737
|
+
|
|
738
|
+
// Find the most specific status page whose urlPrefix matches the pathname
|
|
739
|
+
var match = null
|
|
740
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
741
|
+
var c = candidates[i]
|
|
742
|
+
if (c.urlPrefix === "/" || pathname.startsWith(c.urlPrefix + "/") || pathname === c.urlPrefix) {
|
|
743
|
+
match = c
|
|
744
|
+
break
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (!match) return null
|
|
748
|
+
|
|
749
|
+
var StatusPage = match.component
|
|
750
|
+
var layouts = match.layouts
|
|
751
|
+
|
|
752
|
+
// Resolve generateMetadata from status page + layout modules
|
|
753
|
+
var headConfig = await __resolveGenerateMetadata(match.componentMod, match.layoutMods, {}, pathname)
|
|
754
|
+
|
|
755
|
+
// Build component tree with status props
|
|
756
|
+
var statusProps = { status: statusCode }
|
|
757
|
+
if (data) { for (var k in data) statusProps[k] = data[k] }
|
|
758
|
+
var element = createElement(StatusPage, statusProps)
|
|
759
|
+
|
|
760
|
+
// Wrap with layouts
|
|
761
|
+
for (var i = layouts.length - 1; i >= 0; i--) {
|
|
762
|
+
element = createElement(layouts[i], null, element)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Wrap with AppProviders
|
|
766
|
+
var providerProps = { pathname: pathname, params: {} }
|
|
767
|
+
if (__i18nConfig) providerProps.i18nConfig = __i18nConfig
|
|
768
|
+
if (headConfig) providerProps.headConfig = headConfig
|
|
769
|
+
element = createElement(AppProviders, providerProps, element)
|
|
770
|
+
|
|
771
|
+
return { stream: renderToReadableStream(element), headConfig: headConfig }
|
|
772
|
+
}
|
|
773
|
+
`);
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
lines.push(`
|
|
777
|
+
export function renderStatusPage() { return null }
|
|
778
|
+
`);
|
|
779
|
+
}
|
|
780
|
+
return lines.join("\n");
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Generate the SSR environment entry for RSC mode.
|
|
784
|
+
*
|
|
785
|
+
* This module runs in the SSR environment. It exports:
|
|
786
|
+
* - `renderToHtml(rscStream)` — takes an RSC flight stream, converts it
|
|
787
|
+
* to HTML via react-dom/server, and injects the RSC payload for client hydration
|
|
788
|
+
* - Endpoint handling functions (handleEndpoint, hasEndpoint)
|
|
789
|
+
*
|
|
790
|
+
* The RSC plugin handles "use client" references via its SSR module transforms,
|
|
791
|
+
* so client components are resolved to their correct chunk references.
|
|
792
|
+
*/
|
|
793
|
+
function generateSsrRscEntry(endpoints, root, serverFnFiles) {
|
|
794
|
+
const lines = [
|
|
795
|
+
"// Auto-generated SSR-RSC entry by catmint build",
|
|
796
|
+
"// Runs in the SSR environment for RSC → HTML conversion",
|
|
797
|
+
"",
|
|
798
|
+
"import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'",
|
|
799
|
+
"import { renderToReadableStream } from 'react-dom/server.edge'",
|
|
800
|
+
"import { injectRSCPayload } from 'rsc-html-stream/server'",
|
|
801
|
+
"import assetsManifest from 'virtual:vite-rsc/assets-manifest'",
|
|
802
|
+
"",
|
|
803
|
+
];
|
|
804
|
+
// Import server function modules at the top (alongside other imports)
|
|
805
|
+
if (serverFnFiles.length > 0) {
|
|
806
|
+
lines.push("// Server function module imports");
|
|
807
|
+
for (let i = 0; i < serverFnFiles.length; i++) {
|
|
808
|
+
const vitePath = toVitePath(serverFnFiles[i].filePath, root);
|
|
809
|
+
lines.push(`import * as ServerFn_${i} from '${vitePath}'`);
|
|
810
|
+
}
|
|
811
|
+
lines.push("");
|
|
812
|
+
}
|
|
813
|
+
// Export the RSC → HTML rendering function
|
|
814
|
+
lines.push(`
|
|
815
|
+
/**
|
|
816
|
+
* Escape a string for safe insertion into an HTML attribute value.
|
|
817
|
+
*/
|
|
818
|
+
function __escapeHtmlAttr(str) {
|
|
819
|
+
return str
|
|
820
|
+
.replace(/&/g, '&')
|
|
821
|
+
.replace(/"/g, '"')
|
|
822
|
+
.replace(/</g, '<')
|
|
823
|
+
.replace(/>/g, '>')
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Serialize a HeadConfig into HTML tags for injection into <head>.
|
|
828
|
+
*/
|
|
829
|
+
function __renderHeadToString(config) {
|
|
830
|
+
var parts = []
|
|
831
|
+
|
|
832
|
+
if (config.title !== undefined) {
|
|
833
|
+
parts.push('<title>' + __escapeHtmlAttr(config.title) + '</title>')
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (config.meta) {
|
|
837
|
+
for (var i = 0; i < config.meta.length; i++) {
|
|
838
|
+
var meta = config.meta[i]
|
|
839
|
+
var attrs = []
|
|
840
|
+
if (meta.name) attrs.push('name="' + __escapeHtmlAttr(meta.name) + '"')
|
|
841
|
+
if (meta.property) attrs.push('property="' + __escapeHtmlAttr(meta.property) + '"')
|
|
842
|
+
attrs.push('content="' + __escapeHtmlAttr(meta.content) + '"')
|
|
843
|
+
parts.push('<meta ' + attrs.join(' ') + '>')
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (config.link) {
|
|
848
|
+
for (var i = 0; i < config.link.length; i++) {
|
|
849
|
+
var linkDef = config.link[i]
|
|
850
|
+
var attrs = [
|
|
851
|
+
'rel="' + __escapeHtmlAttr(linkDef.rel) + '"',
|
|
852
|
+
'href="' + __escapeHtmlAttr(linkDef.href) + '"',
|
|
853
|
+
]
|
|
854
|
+
for (var key in linkDef) {
|
|
855
|
+
if (key !== 'rel' && key !== 'href') {
|
|
856
|
+
attrs.push(key + '="' + __escapeHtmlAttr(linkDef[key]) + '"')
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
parts.push('<link ' + attrs.join(' ') + '>')
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return parts.join('\\n')
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Strip existing <title> from SSR-rendered HTML so the framework-injected
|
|
868
|
+
* title from generateMetadata takes precedence.
|
|
869
|
+
*/
|
|
870
|
+
function __stripExistingHeadTags(html, config) {
|
|
871
|
+
var result = html
|
|
872
|
+
if (config.title !== undefined) {
|
|
873
|
+
result = result.replace(/<title>[^<]*<\\/title>/i, '')
|
|
874
|
+
}
|
|
875
|
+
return result
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Convert an RSC flight stream to an HTML ReadableStream.
|
|
880
|
+
*
|
|
881
|
+
* 1. Tees the RSC stream — one fork for SSR, one for browser injection
|
|
882
|
+
* 2. createFromReadableStream → React VDOM
|
|
883
|
+
* 3. renderToReadableStream → HTML stream (with bootstrapScriptContent for client JS)
|
|
884
|
+
* 4. injectRSCPayload → embeds RSC payload as <script> tags in HTML
|
|
885
|
+
* 5. If headConfig is provided, injects <title>/<meta>/<link> tags before </head>
|
|
886
|
+
*
|
|
887
|
+
* The bootstrapScriptContent comes from the RSC assets manifest and tells the
|
|
888
|
+
* browser to load the client entry JS module for hydration.
|
|
889
|
+
*
|
|
890
|
+
* @param {ReadableStream} rscStream - The RSC flight stream
|
|
891
|
+
* @param {object} [headConfig] - Optional HeadConfig to inject into <head>
|
|
892
|
+
* @returns {Promise<ReadableStream>} HTML stream with embedded RSC payload
|
|
893
|
+
*/
|
|
894
|
+
export async function renderToHtml(rscStream, headConfig) {
|
|
895
|
+
const [rscForSsr, rscForBrowser] = rscStream.tee()
|
|
896
|
+
const root = createFromReadableStream(rscForSsr)
|
|
897
|
+
const htmlStream = await renderToReadableStream(root, {
|
|
898
|
+
bootstrapScriptContent: assetsManifest.bootstrapScriptContent,
|
|
899
|
+
})
|
|
900
|
+
var finalStream = htmlStream.pipeThrough(injectRSCPayload(rscForBrowser))
|
|
901
|
+
|
|
902
|
+
// If headConfig is provided, inject head tags before </head>
|
|
903
|
+
if (headConfig) {
|
|
904
|
+
var headTags = __renderHeadToString(headConfig)
|
|
905
|
+
var decoder = new TextDecoder()
|
|
906
|
+
var encoder = new TextEncoder()
|
|
907
|
+
var injected = false
|
|
908
|
+
var buffer = ''
|
|
909
|
+
|
|
910
|
+
finalStream = finalStream.pipeThrough(new TransformStream({
|
|
911
|
+
transform(chunk, controller) {
|
|
912
|
+
if (injected) {
|
|
913
|
+
controller.enqueue(chunk)
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
917
|
+
var idx = buffer.indexOf('</head>')
|
|
918
|
+
if (idx !== -1) {
|
|
919
|
+
injected = true
|
|
920
|
+
var before = __stripExistingHeadTags(buffer.slice(0, idx), headConfig)
|
|
921
|
+
var after = buffer.slice(idx)
|
|
922
|
+
controller.enqueue(encoder.encode(before + headTags + '\\n' + after))
|
|
923
|
+
buffer = ''
|
|
924
|
+
}
|
|
925
|
+
},
|
|
926
|
+
flush(controller) {
|
|
927
|
+
if (!injected && buffer) {
|
|
928
|
+
controller.enqueue(encoder.encode(buffer + headTags))
|
|
929
|
+
} else if (buffer) {
|
|
930
|
+
controller.enqueue(encoder.encode(buffer))
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}))
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return finalStream
|
|
937
|
+
}
|
|
938
|
+
`);
|
|
939
|
+
// Route matching function (needed for endpoint matching)
|
|
940
|
+
lines.push(ROUTE_MATCH_CODE);
|
|
941
|
+
// --- Endpoint handling ---
|
|
942
|
+
if (endpoints.length > 0) {
|
|
943
|
+
lines.push("");
|
|
944
|
+
for (let i = 0; i < endpoints.length; i++) {
|
|
945
|
+
const vitePath = toVitePath(endpoints[i].filePath, root);
|
|
946
|
+
lines.push(`import * as Endpoint_${i} from '${vitePath}'`);
|
|
947
|
+
}
|
|
948
|
+
lines.push("");
|
|
949
|
+
lines.push("const endpointMap = {");
|
|
950
|
+
for (let i = 0; i < endpoints.length; i++) {
|
|
951
|
+
const ep = endpoints[i];
|
|
952
|
+
lines.push(` ${JSON.stringify(ep.pattern)}: {`);
|
|
953
|
+
lines.push(` mod: Endpoint_${i},`);
|
|
954
|
+
lines.push(` methods: ${JSON.stringify(ep.methods)},`);
|
|
955
|
+
lines.push(` },`);
|
|
956
|
+
}
|
|
957
|
+
lines.push("}");
|
|
958
|
+
lines.push(`
|
|
959
|
+
|
|
960
|
+
function matchEndpoint(pathname) {
|
|
961
|
+
if (endpointMap[pathname]) return { endpoint: endpointMap[pathname], params: {} }
|
|
962
|
+
for (const [pattern, endpoint] of Object.entries(endpointMap)) {
|
|
963
|
+
const params = matchPattern(pattern, pathname)
|
|
964
|
+
if (params) return { endpoint, params }
|
|
965
|
+
}
|
|
966
|
+
return null
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export async function handleEndpoint(url, method, request) {
|
|
970
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
971
|
+
const matched = matchEndpoint(pathname)
|
|
972
|
+
|
|
973
|
+
if (!matched) return null
|
|
974
|
+
|
|
975
|
+
const { endpoint, params } = matched
|
|
976
|
+
const mod = endpoint.mod
|
|
977
|
+
|
|
978
|
+
const handler = mod[method] || mod.ANY || mod.default
|
|
979
|
+
if (!handler) return null
|
|
980
|
+
|
|
981
|
+
const ctx = { params }
|
|
982
|
+
const response = await handler(request, ctx)
|
|
983
|
+
return { response }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function hasEndpoint(url) {
|
|
987
|
+
const pathname = new URL(url, 'http://localhost').pathname
|
|
988
|
+
return matchEndpoint(pathname) !== null
|
|
989
|
+
}
|
|
990
|
+
`);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
lines.push(`
|
|
994
|
+
export async function handleEndpoint() { return null }
|
|
995
|
+
export function hasEndpoint() { return false }
|
|
996
|
+
`);
|
|
997
|
+
}
|
|
998
|
+
// --- Server function RPC handling ---
|
|
999
|
+
if (serverFnFiles.length > 0) {
|
|
1000
|
+
lines.push("");
|
|
1001
|
+
// Build the hash → function lookup map
|
|
1002
|
+
lines.push("// Hash → function lookup for server function RPC");
|
|
1003
|
+
lines.push("const serverFnMap = {");
|
|
1004
|
+
for (let i = 0; i < serverFnFiles.length; i++) {
|
|
1005
|
+
const file = serverFnFiles[i];
|
|
1006
|
+
const relativePath = toPosixPath(relative(root, file.filePath));
|
|
1007
|
+
for (const exportName of file.exports) {
|
|
1008
|
+
const hash = deterministicHash(`${relativePath}:${exportName}`);
|
|
1009
|
+
const basePath = relativePath.replace(/\.(fn\.ts|fn\.tsx?)$/, "");
|
|
1010
|
+
const key = `${basePath}/${hash}`;
|
|
1011
|
+
if (exportName === "default") {
|
|
1012
|
+
lines.push(` ${JSON.stringify(key)}: ServerFn_${i}.default,`);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
lines.push(` ${JSON.stringify(key)}: ServerFn_${i}.${exportName},`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
lines.push("}");
|
|
1020
|
+
lines.push(`
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Handle a server function RPC call.
|
|
1024
|
+
*
|
|
1025
|
+
* @param {string} pathname - The URL pathname (e.g. '/__catmint/fn/app/examples/server-fn/actions/c875b945')
|
|
1026
|
+
* @param {unknown} body - The parsed request body (JSON input to the function)
|
|
1027
|
+
* @returns {Promise<{ result: unknown } | null>} The function result, or null if no match
|
|
1028
|
+
*/
|
|
1029
|
+
export async function handleServerFn(pathname, body) {
|
|
1030
|
+
const prefix = '/__catmint/fn/'
|
|
1031
|
+
if (!pathname.startsWith(prefix)) return null
|
|
1032
|
+
|
|
1033
|
+
const key = pathname.slice(prefix.length)
|
|
1034
|
+
const fn = serverFnMap[key]
|
|
1035
|
+
if (!fn) return null
|
|
1036
|
+
if (typeof fn !== 'function') return null
|
|
1037
|
+
|
|
1038
|
+
const result = await fn(body)
|
|
1039
|
+
return { result }
|
|
1040
|
+
}
|
|
1041
|
+
`);
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
lines.push(`
|
|
1045
|
+
export async function handleServerFn() { return null }
|
|
1046
|
+
`);
|
|
1047
|
+
}
|
|
1048
|
+
return lines.join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Generate the client entry for RSC mode.
|
|
1052
|
+
*
|
|
1053
|
+
* This module runs in the browser. It:
|
|
1054
|
+
* 1. Reads the embedded RSC payload from the HTML via rsc-html-stream/client
|
|
1055
|
+
* 2. Deserializes it via createFromReadableStream from @vitejs/plugin-rsc/browser
|
|
1056
|
+
* 3. Hydrates the document via hydrateRoot from react-dom/client
|
|
1057
|
+
* 4. If soft navigation is enabled, installs click handlers to intercept
|
|
1058
|
+
* relative link clicks and perform RSC-powered client-side transitions
|
|
1059
|
+
*
|
|
1060
|
+
* The RSC plugin handles client reference resolution automatically — when
|
|
1061
|
+
* the browser encounters a client reference in the RSC payload, it loads
|
|
1062
|
+
* the appropriate client component chunk.
|
|
1063
|
+
*/
|
|
1064
|
+
function generateClientRscEntry(softNavigation) {
|
|
1065
|
+
return `// Auto-generated client RSC entry by catmint build
|
|
1066
|
+
// Hydrates from embedded RSC payload in the HTML
|
|
1067
|
+
|
|
1068
|
+
import { rscStream } from 'rsc-html-stream/client'
|
|
1069
|
+
import { createFromReadableStream } from '@vitejs/plugin-rsc/browser'
|
|
1070
|
+
import { hydrateRoot } from 'react-dom/client'
|
|
1071
|
+
|
|
1072
|
+
let root = null
|
|
1073
|
+
|
|
1074
|
+
${CLIENT_NAVIGATION_RUNTIME}
|
|
1075
|
+
|
|
1076
|
+
async function hydrate() {
|
|
1077
|
+
const initialRoot = await createFromReadableStream(rscStream)
|
|
1078
|
+
root = hydrateRoot(document, initialRoot)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
hydrate().then(function() {
|
|
1082
|
+
if (${softNavigation ? "true" : "false"}) {
|
|
1083
|
+
setupClientNavigation(root)
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
`;
|
|
1087
|
+
}
|
|
1088
|
+
// --------------------------------------------------------------------------
|
|
1089
|
+
// Shared route matching code (used by both RSC and legacy entries)
|
|
1090
|
+
// --------------------------------------------------------------------------
|
|
1091
|
+
/**
|
|
1092
|
+
* Route matching JavaScript code shared between entry generators.
|
|
1093
|
+
* Extracted to avoid duplication.
|
|
1094
|
+
*/
|
|
1095
|
+
const ROUTE_MATCH_CODE = `
|
|
1096
|
+
function matchRoute(pathname) {
|
|
1097
|
+
if (routeMap[pathname]) return { route: routeMap[pathname], params: {} }
|
|
1098
|
+
for (const [pattern, route] of Object.entries(routeMap)) {
|
|
1099
|
+
const params = matchPattern(pattern, pathname)
|
|
1100
|
+
if (params) return { route, params }
|
|
1101
|
+
}
|
|
1102
|
+
return null
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function matchPattern(pattern, pathname) {
|
|
1106
|
+
const patternParts = pattern.split('/').filter(Boolean)
|
|
1107
|
+
const pathParts = pathname.split('/').filter(Boolean)
|
|
1108
|
+
const params = {}
|
|
1109
|
+
|
|
1110
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1111
|
+
const pp = patternParts[i]
|
|
1112
|
+
if (pp.startsWith('*')) {
|
|
1113
|
+
const paramName = pp.endsWith('?') ? pp.slice(1, -1) : pp.slice(1)
|
|
1114
|
+
params[paramName] = pathParts.slice(i)
|
|
1115
|
+
return params
|
|
1116
|
+
}
|
|
1117
|
+
if (pp.startsWith(':')) {
|
|
1118
|
+
if (i >= pathParts.length) return null
|
|
1119
|
+
params[pp.slice(1)] = pathParts[i]
|
|
1120
|
+
continue
|
|
1121
|
+
}
|
|
1122
|
+
if (i >= pathParts.length || pp !== pathParts[i]) return null
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (patternParts.length < pathParts.length) return null
|
|
1126
|
+
if (patternParts.length > pathParts.length) {
|
|
1127
|
+
const remaining = patternParts[pathParts.length]
|
|
1128
|
+
if (remaining && remaining.startsWith('*') && remaining.endsWith('?')) {
|
|
1129
|
+
const paramName = remaining.slice(1, -1)
|
|
1130
|
+
params[paramName] = []
|
|
1131
|
+
return params
|
|
1132
|
+
}
|
|
1133
|
+
return null
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return params
|
|
1137
|
+
}
|
|
1138
|
+
`;
|
|
1139
|
+
//# sourceMappingURL=build-entries.js.map
|