@catmint/vite 0.0.0-prealpha.2 → 0.0.0-prealpha.20
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/dist/build-entries.d.ts +8 -1
- package/dist/build-entries.d.ts.map +1 -1
- package/dist/build-entries.js +196 -8
- package/dist/build-entries.js.map +1 -1
- package/dist/dev-server.d.ts +16 -0
- package/dist/dev-server.d.ts.map +1 -1
- package/dist/dev-server.js +674 -135
- package/dist/dev-server.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -6
- package/dist/middleware.js.map +1 -1
- package/dist/resolve-utils.d.ts.map +1 -1
- package/dist/resolve-utils.js +2 -7
- package/dist/resolve-utils.js.map +1 -1
- package/dist/route-gen.d.ts.map +1 -1
- package/dist/route-gen.js +2 -5
- package/dist/route-gen.js.map +1 -1
- package/dist/server-fn-transform.js +49 -3
- package/dist/server-fn-transform.js.map +1 -1
- package/dist/utils.js +1 -1
- package/dist/utils.js.map +1 -1
- package/package.json +2 -2
package/dist/dev-server.js
CHANGED
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
// → injectRSCPayload → embedded in HTML as <script> tags
|
|
13
13
|
// 3. Client: Browser reads embedded payload via rsc-html-stream/client,
|
|
14
14
|
// calls createFromReadableStream → React VDOM → hydrateRoot
|
|
15
|
-
import { join, dirname, relative } from "node:path";
|
|
16
|
-
import { existsSync } from "node:fs";
|
|
15
|
+
import { join, dirname, relative, resolve } from "node:path";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
17
18
|
import { createRequire } from "node:module";
|
|
18
19
|
import { deterministicHash, toPosixPath, CLIENT_NAVIGATION_RUNTIME, } from "./utils.js";
|
|
20
|
+
/**
|
|
21
|
+
* Runtime context APIs — dynamically imported from `catmint/runtime/context`
|
|
22
|
+
* in `configureServer` so the module is resolved via SSR externals (Node.js
|
|
23
|
+
* resolution) rather than Vite's module graph. The functions are stored in
|
|
24
|
+
* these module-level variables after initialisation.
|
|
25
|
+
*/
|
|
26
|
+
let createRequestStore;
|
|
27
|
+
let runWithRequestContext;
|
|
19
28
|
import { errorPageHtml } from "./error-page.js";
|
|
20
29
|
import { scanStatusPages } from "./build-entries.js";
|
|
21
30
|
import { buildPreflightHeaders } from "./cors-utils.js";
|
|
@@ -94,7 +103,7 @@ function collectLayouts(pageFilePath, appDir) {
|
|
|
94
103
|
const layouts = [];
|
|
95
104
|
let dir = dirname(pageFilePath);
|
|
96
105
|
while (dir.startsWith(appDir)) {
|
|
97
|
-
for (const name of ["layout.tsx", "layout.
|
|
106
|
+
for (const name of ["layout.tsx", "layout.ts"]) {
|
|
98
107
|
const layoutPath = join(dir, name);
|
|
99
108
|
if (existsSync(layoutPath)) {
|
|
100
109
|
layouts.unshift(layoutPath);
|
|
@@ -107,6 +116,154 @@ function collectLayouts(pageFilePath, appDir) {
|
|
|
107
116
|
}
|
|
108
117
|
return layouts;
|
|
109
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* File names checked for middleware, in priority order.
|
|
121
|
+
*/
|
|
122
|
+
const MIDDLEWARE_FILENAMES = ["middleware.ts", "middleware.tsx"];
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the middleware chain for a given route directory.
|
|
125
|
+
* Walks from the page's directory up to appDir, collecting middleware files.
|
|
126
|
+
* Returns paths in root-to-tip execution order.
|
|
127
|
+
*/
|
|
128
|
+
function collectMiddleware(pageFilePath, appDir) {
|
|
129
|
+
const middlewareFiles = [];
|
|
130
|
+
let dir = dirname(pageFilePath);
|
|
131
|
+
while (dir.startsWith(appDir)) {
|
|
132
|
+
for (const name of MIDDLEWARE_FILENAMES) {
|
|
133
|
+
const middlewarePath = join(dir, name);
|
|
134
|
+
if (existsSync(middlewarePath)) {
|
|
135
|
+
middlewareFiles.unshift(middlewarePath); // prepend for root-to-tip order
|
|
136
|
+
break; // Only one middleware file per directory
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (dir === appDir)
|
|
140
|
+
break;
|
|
141
|
+
dir = dirname(dir);
|
|
142
|
+
}
|
|
143
|
+
return middlewareFiles;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Convert a Node.js IncomingMessage to a Web Request object.
|
|
147
|
+
*/
|
|
148
|
+
function nodeReqToWebRequest(req) {
|
|
149
|
+
const protocol = "http";
|
|
150
|
+
const host = req.headers.host ?? "localhost";
|
|
151
|
+
const url = new URL(req.originalUrl ?? req.url ?? "/", `${protocol}://${host}`);
|
|
152
|
+
const headers = new Headers();
|
|
153
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
154
|
+
if (value) {
|
|
155
|
+
if (Array.isArray(value)) {
|
|
156
|
+
for (const v of value) {
|
|
157
|
+
headers.append(key, v);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
headers.set(key, value);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return new Request(url.href, {
|
|
166
|
+
method: req.method ?? "GET",
|
|
167
|
+
headers,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Send a Web Response back through a Node.js ServerResponse.
|
|
172
|
+
*/
|
|
173
|
+
async function sendWebResponse(res, webResponse) {
|
|
174
|
+
res.statusCode = webResponse.status;
|
|
175
|
+
webResponse.headers.forEach((value, key) => {
|
|
176
|
+
res.setHeader(key, value);
|
|
177
|
+
});
|
|
178
|
+
const body = await webResponse.text();
|
|
179
|
+
res.end(body);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Execute the middleware chain for a route. Returns a Response from middleware
|
|
183
|
+
* if middleware short-circuits (e.g., 401), or null if middleware calls next()
|
|
184
|
+
* and page rendering should proceed. Also returns any response headers that
|
|
185
|
+
* middleware added (e.g., X-Response-Time) so they can be merged into the
|
|
186
|
+
* final page response.
|
|
187
|
+
*/
|
|
188
|
+
async function executeMiddleware(server, req, pageFilePath, appDir) {
|
|
189
|
+
const middlewarePaths = collectMiddleware(pageFilePath, appDir);
|
|
190
|
+
if (middlewarePaths.length === 0) {
|
|
191
|
+
return { shortCircuit: null, headers: new Headers() };
|
|
192
|
+
}
|
|
193
|
+
const handlers = [];
|
|
194
|
+
for (const mwPath of middlewarePaths) {
|
|
195
|
+
try {
|
|
196
|
+
const importPath = "/" + relative(server.config.root, mwPath);
|
|
197
|
+
const mod = await server.ssrLoadModule(importPath);
|
|
198
|
+
const handler = mod.default;
|
|
199
|
+
if (typeof handler === "function") {
|
|
200
|
+
handlers.push(handler);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
server.config.logger.warn(`[catmint] Failed to load middleware ${mwPath}: ${err}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (handlers.length === 0) {
|
|
208
|
+
return { shortCircuit: null, headers: new Headers() };
|
|
209
|
+
}
|
|
210
|
+
// Apply inherit boundary: walk backwards to find inherit: false
|
|
211
|
+
let boundaryIndex = 0;
|
|
212
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
213
|
+
const opts = handlers[i].__catmintMiddleware;
|
|
214
|
+
if (opts && opts.inherit === false) {
|
|
215
|
+
boundaryIndex = i;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const effectiveHandlers = handlers.slice(boundaryIndex);
|
|
220
|
+
// Create the Web Request
|
|
221
|
+
const webRequest = nodeReqToWebRequest(req);
|
|
222
|
+
// Compose and execute middleware chain
|
|
223
|
+
let middlewareCalledNext = false;
|
|
224
|
+
const capturedHeaders = new Headers();
|
|
225
|
+
const executeChain = (index) => {
|
|
226
|
+
if (index >= effectiveHandlers.length) {
|
|
227
|
+
// End of chain — middleware called next(), page rendering should proceed
|
|
228
|
+
middlewareCalledNext = true;
|
|
229
|
+
return Promise.resolve(new Response(null, { status: 200 }));
|
|
230
|
+
}
|
|
231
|
+
const handler = effectiveHandlers[index];
|
|
232
|
+
return Promise.resolve(handler(webRequest, () => {
|
|
233
|
+
const nextResult = executeChain(index + 1);
|
|
234
|
+
return nextResult.then((response) => {
|
|
235
|
+
// Capture headers from inner middleware/next
|
|
236
|
+
response.headers.forEach((value, key) => {
|
|
237
|
+
capturedHeaders.set(key, value);
|
|
238
|
+
});
|
|
239
|
+
return response;
|
|
240
|
+
});
|
|
241
|
+
})).then((response) => {
|
|
242
|
+
// Capture headers from this middleware
|
|
243
|
+
response.headers.forEach((value, key) => {
|
|
244
|
+
capturedHeaders.set(key, value);
|
|
245
|
+
});
|
|
246
|
+
return response;
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const response = await executeChain(0);
|
|
251
|
+
if (!middlewareCalledNext) {
|
|
252
|
+
// Middleware short-circuited (returned a response without calling next)
|
|
253
|
+
return { shortCircuit: response, headers: capturedHeaders };
|
|
254
|
+
}
|
|
255
|
+
// Middleware called next() — proceed with page rendering
|
|
256
|
+
// but pass along any headers middleware added
|
|
257
|
+
return { shortCircuit: null, headers: capturedHeaders };
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
server.config.logger.error(`[catmint] Middleware error: ${err}`);
|
|
261
|
+
return {
|
|
262
|
+
shortCircuit: new Response("Internal Server Error", { status: 500 }),
|
|
263
|
+
headers: new Headers(),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
110
267
|
/**
|
|
111
268
|
* CSS file extensions that Vite processes.
|
|
112
269
|
*/
|
|
@@ -349,6 +506,52 @@ const SERVER_FN_PREFIX = "/__catmint/fn/";
|
|
|
349
506
|
* RSC flight stream prefix for client-side navigation.
|
|
350
507
|
*/
|
|
351
508
|
const RSC_NAVIGATION_PREFIX = "/__catmint/rsc";
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Server function error detection helpers (duck-typing)
|
|
511
|
+
//
|
|
512
|
+
// The dev-server cannot import from `catmint` directly (it's in the
|
|
513
|
+
// `@catmint/vite` package). Instead, we detect error types by checking
|
|
514
|
+
// for characteristic properties — this matches instances loaded at
|
|
515
|
+
// runtime via Vite's SSR module loader.
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
/**
|
|
518
|
+
* Check if a value looks like a ClientSafeError (duck-typing).
|
|
519
|
+
* Matches: instance has `name === "ClientSafeError"` OR has `statusCode`
|
|
520
|
+
* property and the constructor chain includes a class with name "ClientSafeError".
|
|
521
|
+
*/
|
|
522
|
+
function isClientSafeErrorLike(value) {
|
|
523
|
+
if (!value || typeof value !== "object")
|
|
524
|
+
return false;
|
|
525
|
+
// Direct instance check by name (supports subclasses via prototype chain)
|
|
526
|
+
if (value instanceof Error && typeof value.statusCode === "number") {
|
|
527
|
+
let proto = Object.getPrototypeOf(value);
|
|
528
|
+
while (proto && proto !== Error.prototype) {
|
|
529
|
+
if (proto.constructor?.name === "ClientSafeError")
|
|
530
|
+
return true;
|
|
531
|
+
proto = Object.getPrototypeOf(proto);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Check if a value looks like a RedirectError (duck-typing).
|
|
538
|
+
*/
|
|
539
|
+
function isRedirectErrorLike(value) {
|
|
540
|
+
if (!value || typeof value !== "object")
|
|
541
|
+
return false;
|
|
542
|
+
return (value instanceof Error &&
|
|
543
|
+
value.name === "RedirectError" &&
|
|
544
|
+
typeof value.url === "string" &&
|
|
545
|
+
typeof value.status === "number");
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Generate a short correlation hash for error tracking.
|
|
549
|
+
* Used in production to link client error messages to server logs.
|
|
550
|
+
*/
|
|
551
|
+
function errorRef(err) {
|
|
552
|
+
const content = `${Date.now()}:${err instanceof Error ? err.message : String(err)}`;
|
|
553
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 8);
|
|
554
|
+
}
|
|
352
555
|
/**
|
|
353
556
|
* Handle a server function RPC call.
|
|
354
557
|
*
|
|
@@ -441,17 +644,63 @@ async function handleServerFn(server, req, res, pathname) {
|
|
|
441
644
|
// Call the server function
|
|
442
645
|
try {
|
|
443
646
|
const result = await matchedFn(input);
|
|
647
|
+
// Check if the result IS a ClientSafeError (returned, not thrown).
|
|
648
|
+
// Use duck-typing since we can't import from `catmint` directly.
|
|
649
|
+
if (isClientSafeErrorLike(result)) {
|
|
650
|
+
res.statusCode = 200;
|
|
651
|
+
res.setHeader("Content-Type", "application/json");
|
|
652
|
+
res.end(JSON.stringify({
|
|
653
|
+
__clientSafeError: true,
|
|
654
|
+
error: result.message,
|
|
655
|
+
statusCode: result.statusCode,
|
|
656
|
+
data: result.data,
|
|
657
|
+
}));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
444
660
|
res.statusCode = 200;
|
|
445
661
|
res.setHeader("Content-Type", "application/json");
|
|
446
662
|
res.end(JSON.stringify(result));
|
|
447
663
|
}
|
|
448
664
|
catch (err) {
|
|
665
|
+
// Always log the full error server-side
|
|
449
666
|
server.config.logger.error(`[catmint] Server function error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
450
|
-
res.statusCode = 500;
|
|
451
667
|
res.setHeader("Content-Type", "application/json");
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
668
|
+
// RedirectError — send redirect envelope to client
|
|
669
|
+
if (isRedirectErrorLike(err)) {
|
|
670
|
+
res.statusCode = 200;
|
|
671
|
+
res.end(JSON.stringify({
|
|
672
|
+
__redirect: true,
|
|
673
|
+
url: err.url,
|
|
674
|
+
status: err.status,
|
|
675
|
+
}));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// ClientSafeError — developer opted in, expose to client
|
|
679
|
+
if (isClientSafeErrorLike(err)) {
|
|
680
|
+
res.statusCode = err.statusCode;
|
|
681
|
+
res.end(JSON.stringify({
|
|
682
|
+
error: err.message,
|
|
683
|
+
data: err.data,
|
|
684
|
+
}));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// Default: sanitize error — in dev mode, leak with warning;
|
|
688
|
+
// in prod mode, use generic message with correlation hash
|
|
689
|
+
const isDev = server.config.mode === "development" || !server.config.isProduction;
|
|
690
|
+
res.statusCode = 500;
|
|
691
|
+
if (isDev) {
|
|
692
|
+
const originalMessage = err instanceof Error ? err.message : String(err);
|
|
693
|
+
res.end(JSON.stringify({
|
|
694
|
+
error: `[DEV ONLY] ${originalMessage}`,
|
|
695
|
+
}));
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
const ref = errorRef(err);
|
|
699
|
+
server.config.logger.error(`[catmint] Error reference [ref: ${ref}] — see above for full error`);
|
|
700
|
+
res.end(JSON.stringify({
|
|
701
|
+
error: `Internal Server Error [ref: ${ref}]`,
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
455
704
|
}
|
|
456
705
|
}
|
|
457
706
|
/**
|
|
@@ -493,7 +742,26 @@ async function handleEndpoint(server, req, res, match, method) {
|
|
|
493
742
|
}
|
|
494
743
|
const webRequest = new Request(fullUrl, requestInit);
|
|
495
744
|
const ctx = { params: match.params };
|
|
496
|
-
|
|
745
|
+
let response;
|
|
746
|
+
try {
|
|
747
|
+
response = await handler(webRequest, ctx);
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
// redirect() throws a RedirectError — convert it to a real HTTP redirect
|
|
751
|
+
if (isRedirectErrorLike(err)) {
|
|
752
|
+
const re = err;
|
|
753
|
+
res.statusCode = re.status;
|
|
754
|
+
res.setHeader("Location", re.url);
|
|
755
|
+
if (re.headers) {
|
|
756
|
+
for (const [k, v] of Object.entries(re.headers)) {
|
|
757
|
+
res.setHeader(k, v);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
res.end();
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
throw err;
|
|
764
|
+
}
|
|
497
765
|
res.statusCode = response.status;
|
|
498
766
|
response.headers.forEach((value, key) => {
|
|
499
767
|
res.setHeader(key, value);
|
|
@@ -543,8 +811,10 @@ const RESOLVED_CLIENT_HYDRATE_ID = "\0" + CLIENT_HYDRATE_ID;
|
|
|
543
811
|
export function devServerPlugin(options) {
|
|
544
812
|
const softNav = options?.softNavigation ?? true;
|
|
545
813
|
const i18nConfig = options?.i18n ?? null;
|
|
814
|
+
const adapter = options?.adapter ?? null;
|
|
546
815
|
let server;
|
|
547
816
|
let appDir;
|
|
817
|
+
let devPlatform = null;
|
|
548
818
|
let pageMatcher = null;
|
|
549
819
|
let endpointMatcher = null;
|
|
550
820
|
// Pre-scanned status pages sorted by most-specific (longest prefix) first.
|
|
@@ -784,6 +1054,71 @@ export function devServerPlugin(options) {
|
|
|
784
1054
|
return null;
|
|
785
1055
|
}
|
|
786
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Read `compilerOptions.paths` from the project's `tsconfig.json` and
|
|
1059
|
+
* convert them to Vite `resolve.alias` entries so that path aliases like
|
|
1060
|
+
* `@/*` or `~/*` work at runtime without extra user configuration.
|
|
1061
|
+
*
|
|
1062
|
+
* - Strips JSON comments (JSONC) before parsing.
|
|
1063
|
+
* - Respects `compilerOptions.baseUrl` (defaults to tsconfig directory).
|
|
1064
|
+
* - Wildcard patterns (`"@/*": ["./app/*"]`) become regex aliases.
|
|
1065
|
+
* - Exact patterns (`"@/utils": ["./src/utils"]`) become string aliases.
|
|
1066
|
+
*/
|
|
1067
|
+
function buildTsconfigAliases(root) {
|
|
1068
|
+
const aliases = [];
|
|
1069
|
+
// Look for tsconfig.json in the project root
|
|
1070
|
+
const tsconfigPath = join(root, "tsconfig.json");
|
|
1071
|
+
if (!existsSync(tsconfigPath))
|
|
1072
|
+
return aliases;
|
|
1073
|
+
let raw;
|
|
1074
|
+
try {
|
|
1075
|
+
raw = readFileSync(tsconfigPath, "utf-8");
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
return aliases;
|
|
1079
|
+
}
|
|
1080
|
+
// Strip single-line and multi-line comments (JSONC → JSON)
|
|
1081
|
+
const stripped = raw
|
|
1082
|
+
.replace(/\/\/.*$/gm, "")
|
|
1083
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
1084
|
+
let parsed;
|
|
1085
|
+
try {
|
|
1086
|
+
parsed = JSON.parse(stripped);
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
return aliases;
|
|
1090
|
+
}
|
|
1091
|
+
const paths = parsed.compilerOptions?.paths;
|
|
1092
|
+
if (!paths)
|
|
1093
|
+
return aliases;
|
|
1094
|
+
const baseUrl = parsed.compilerOptions?.baseUrl
|
|
1095
|
+
? resolve(dirname(tsconfigPath), parsed.compilerOptions.baseUrl)
|
|
1096
|
+
: dirname(tsconfigPath);
|
|
1097
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
1098
|
+
const target = targets?.[0];
|
|
1099
|
+
if (!target)
|
|
1100
|
+
continue;
|
|
1101
|
+
if (pattern.endsWith("/*") && target.endsWith("/*")) {
|
|
1102
|
+
// Wildcard pattern: "@/*" → ["./app/*"]
|
|
1103
|
+
// Convert to regex so Vite matches any subpath
|
|
1104
|
+
const prefix = pattern.slice(0, -2); // "@"
|
|
1105
|
+
const targetDir = resolve(baseUrl, target.slice(0, -2)); // abs path to "./app"
|
|
1106
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1107
|
+
aliases.push({
|
|
1108
|
+
find: new RegExp(`^${escaped}\\/(.+)$`),
|
|
1109
|
+
replacement: `${targetDir}/$1`,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
// Exact pattern: "@/utils" → ["./src/utils"]
|
|
1114
|
+
aliases.push({
|
|
1115
|
+
find: pattern,
|
|
1116
|
+
replacement: resolve(baseUrl, target),
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return aliases;
|
|
1121
|
+
}
|
|
787
1122
|
/**
|
|
788
1123
|
* Build resolve.alias entries so that all Vite environments can find
|
|
789
1124
|
* @vitejs/plugin-rsc subpaths and rsc-html-stream subpaths regardless
|
|
@@ -838,14 +1173,20 @@ export function devServerPlugin(options) {
|
|
|
838
1173
|
return {
|
|
839
1174
|
name: "catmint:dev-server",
|
|
840
1175
|
enforce: "post",
|
|
841
|
-
config() {
|
|
842
|
-
const
|
|
1176
|
+
config(userConfig) {
|
|
1177
|
+
const root = userConfig.root ?? process.cwd();
|
|
1178
|
+
const rscAliases = buildRscAliases();
|
|
1179
|
+
const tsconfigAliases = buildTsconfigAliases(root);
|
|
843
1180
|
return {
|
|
844
1181
|
resolve: {
|
|
845
|
-
alias:
|
|
1182
|
+
alias: [...tsconfigAliases, ...rscAliases],
|
|
846
1183
|
},
|
|
847
1184
|
ssr: {
|
|
848
|
-
external: [
|
|
1185
|
+
external: [
|
|
1186
|
+
"catmint/routing",
|
|
1187
|
+
"catmint/status",
|
|
1188
|
+
"catmint/runtime/context",
|
|
1189
|
+
],
|
|
849
1190
|
},
|
|
850
1191
|
optimizeDeps: {
|
|
851
1192
|
include: [
|
|
@@ -894,6 +1235,61 @@ export function devServerPlugin(options) {
|
|
|
894
1235
|
server = viteServer;
|
|
895
1236
|
appDir = join(server.config.root, "app");
|
|
896
1237
|
statusPages = scanStatusPages(appDir);
|
|
1238
|
+
// Load request context APIs from catmint/runtime/context via SSR
|
|
1239
|
+
// module resolution (not a static import) so the module is resolved
|
|
1240
|
+
// through Node.js resolution at dev time, not Vite's module graph.
|
|
1241
|
+
const contextReady = server
|
|
1242
|
+
.ssrLoadModule("catmint/runtime/context")
|
|
1243
|
+
.then((mod) => {
|
|
1244
|
+
createRequestStore = mod.createRequestStore;
|
|
1245
|
+
runWithRequestContext = mod.runWithRequestContext;
|
|
1246
|
+
});
|
|
1247
|
+
// Store the promise so the first request can await it
|
|
1248
|
+
server.__catmintContextReady = contextReady;
|
|
1249
|
+
// Initialize adapter dev platform (e.g. Cloudflare getPlatformProxy).
|
|
1250
|
+
// This is async but we can't await in configureServer, so we kick it
|
|
1251
|
+
// off and store the promise. The first request will await it.
|
|
1252
|
+
if (adapter?.dev) {
|
|
1253
|
+
const initPromise = Promise.resolve(adapter.dev());
|
|
1254
|
+
initPromise
|
|
1255
|
+
.then((helper) => {
|
|
1256
|
+
devPlatform = helper;
|
|
1257
|
+
server.config.logger.info(`[catmint] Adapter "${adapter.name}" dev platform initialized`);
|
|
1258
|
+
})
|
|
1259
|
+
.catch((err) => {
|
|
1260
|
+
server.config.logger.error(`[catmint] Failed to initialize adapter dev platform: ${err instanceof Error ? err.message : String(err)}`);
|
|
1261
|
+
});
|
|
1262
|
+
// Store the promise so requests can await it
|
|
1263
|
+
server.__catmintDevPlatformInit = initPromise;
|
|
1264
|
+
// Clean up on server close
|
|
1265
|
+
server.httpServer?.on("close", () => {
|
|
1266
|
+
if (devPlatform?.close) {
|
|
1267
|
+
Promise.resolve(devPlatform.close()).catch(() => { });
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
// Invalidate route matchers when route files are added or removed.
|
|
1272
|
+
// Vite's handleHotUpdate only fires for files already in the module
|
|
1273
|
+
// graph, so newly created or deleted files need this watcher to
|
|
1274
|
+
// trigger a re-scan on the next request.
|
|
1275
|
+
const isRouteFile = (f) => f.includes("/app/") &&
|
|
1276
|
+
(f.endsWith("page.tsx") ||
|
|
1277
|
+
f.endsWith("page.mdx") ||
|
|
1278
|
+
f.endsWith("endpoint.ts"));
|
|
1279
|
+
server.watcher.on("add", (file) => {
|
|
1280
|
+
if (isRouteFile(file)) {
|
|
1281
|
+
pageMatcher = null;
|
|
1282
|
+
endpointMatcher = null;
|
|
1283
|
+
server.config.logger.info(`[catmint] Route file added: ${file} — invalidating route cache`);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
server.watcher.on("unlink", (file) => {
|
|
1287
|
+
if (isRouteFile(file)) {
|
|
1288
|
+
pageMatcher = null;
|
|
1289
|
+
endpointMatcher = null;
|
|
1290
|
+
server.config.logger.info(`[catmint] Route file removed: ${file} — invalidating route cache`);
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
897
1293
|
return () => {
|
|
898
1294
|
server.middlewares.use(async (req, res, next) => {
|
|
899
1295
|
const url = req.originalUrl ?? req.url ?? "/";
|
|
@@ -919,142 +1315,222 @@ export function devServerPlugin(options) {
|
|
|
919
1315
|
if (shouldSkip(url)) {
|
|
920
1316
|
return next();
|
|
921
1317
|
}
|
|
922
|
-
// ---
|
|
923
|
-
|
|
924
|
-
|
|
1318
|
+
// --- Establish request context for all server-side handlers ---
|
|
1319
|
+
// This enables cookies(), headers(), getPlatform() during dev.
|
|
1320
|
+
const webRequest = nodeReqToWebRequest(req);
|
|
1321
|
+
// Ensure runtime context APIs are loaded before first use
|
|
1322
|
+
const contextReady = server.__catmintContextReady;
|
|
1323
|
+
if (contextReady) {
|
|
1324
|
+
await contextReady;
|
|
1325
|
+
}
|
|
1326
|
+
// Wait for adapter dev platform initialization if still pending
|
|
1327
|
+
const initPromise = server.__catmintDevPlatformInit;
|
|
1328
|
+
if (initPromise && !devPlatform) {
|
|
925
1329
|
try {
|
|
926
|
-
|
|
1330
|
+
devPlatform = await initPromise;
|
|
927
1331
|
}
|
|
928
|
-
catch
|
|
929
|
-
|
|
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
|
-
}
|
|
1332
|
+
catch {
|
|
1333
|
+
// Already logged during init
|
|
936
1334
|
}
|
|
937
|
-
return;
|
|
938
1335
|
}
|
|
939
|
-
//
|
|
940
|
-
|
|
1336
|
+
// Get platform context from adapter (e.g. Cloudflare { env, ctx })
|
|
1337
|
+
// or fall back to Node.js platform shape
|
|
1338
|
+
let platform;
|
|
1339
|
+
if (devPlatform) {
|
|
941
1340
|
try {
|
|
942
|
-
|
|
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);
|
|
1341
|
+
platform = await devPlatform.getPlatform(webRequest, req, res);
|
|
966
1342
|
}
|
|
967
|
-
catch (
|
|
968
|
-
server.
|
|
969
|
-
|
|
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
|
-
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
server.config.logger.warn(`[catmint] Adapter getPlatform() failed, falling back to Node.js platform: ${err instanceof Error ? err.message : String(err)}`);
|
|
1345
|
+
platform = { req, res };
|
|
975
1346
|
}
|
|
976
|
-
return;
|
|
977
1347
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1348
|
+
else {
|
|
1349
|
+
platform = { req, res };
|
|
1350
|
+
}
|
|
1351
|
+
const store = createRequestStore(webRequest, platform);
|
|
1352
|
+
/**
|
|
1353
|
+
* Flush pending Set-Cookie headers and accumulated response headers
|
|
1354
|
+
* onto the Node.js ServerResponse before it's sent.
|
|
1355
|
+
*/
|
|
1356
|
+
const flushHeaders = () => {
|
|
1357
|
+
if (res.headersSent)
|
|
1358
|
+
return;
|
|
1359
|
+
store.responseHeaders.forEach((value, key) => {
|
|
1360
|
+
res.setHeader(key, value);
|
|
1361
|
+
});
|
|
1362
|
+
for (const pending of store.pendingCookies.values()) {
|
|
1363
|
+
res.appendHeader("Set-Cookie", pending.serialized);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
return runWithRequestContext(store, async () => {
|
|
1367
|
+
// --- Server function RPC handling ---
|
|
1368
|
+
const pathname = url.split("?")[0];
|
|
1369
|
+
if (pathname.startsWith(SERVER_FN_PREFIX)) {
|
|
1370
|
+
try {
|
|
1371
|
+
await handleServerFn(server, req, res, pathname);
|
|
1000
1372
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
server.ssrFixStacktrace(error);
|
|
1375
|
+
server.config.logger.error(`[catmint] Server function error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1376
|
+
if (!res.headersSent) {
|
|
1377
|
+
res.statusCode = 500;
|
|
1378
|
+
res.setHeader("Content-Type", "application/json");
|
|
1379
|
+
res.end(JSON.stringify({ error: "Internal server function error" }));
|
|
1380
|
+
}
|
|
1003
1381
|
}
|
|
1004
|
-
|
|
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`);
|
|
1382
|
+
flushHeaders();
|
|
1008
1383
|
return;
|
|
1009
1384
|
}
|
|
1010
|
-
// ---
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1385
|
+
// --- RSC flight stream for client-side navigation ---
|
|
1386
|
+
if (pathname === RSC_NAVIGATION_PREFIX) {
|
|
1387
|
+
try {
|
|
1388
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
1389
|
+
const targetPath = parsedUrl.searchParams.get("path");
|
|
1390
|
+
if (!targetPath) {
|
|
1391
|
+
res.statusCode = 400;
|
|
1392
|
+
res.setHeader("Content-Type", "application/json");
|
|
1393
|
+
res.end(JSON.stringify({ error: "Missing ?path= parameter" }));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
await ensureMatchers();
|
|
1397
|
+
const match = pageMatcher.matchRoute(targetPath);
|
|
1398
|
+
if (!match) {
|
|
1399
|
+
res.statusCode = 404;
|
|
1400
|
+
res.setHeader("Content-Type", "application/json");
|
|
1401
|
+
res.end(JSON.stringify({ error: "No matching route" }));
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (!hasRscEnvironments()) {
|
|
1405
|
+
// Without RSC, fall back to a full page reload signal
|
|
1406
|
+
res.statusCode = 406;
|
|
1407
|
+
res.setHeader("Content-Type", "application/json");
|
|
1408
|
+
res.end(JSON.stringify({ error: "RSC not available" }));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
// Execute middleware for the TARGET page path (not /__catmint/rsc)
|
|
1412
|
+
const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
|
|
1413
|
+
if (middlewareResult.shortCircuit) {
|
|
1414
|
+
// Middleware short-circuited (e.g., returned 401)
|
|
1415
|
+
flushHeaders();
|
|
1416
|
+
await sendWebResponse(res, middlewareResult.shortCircuit);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
// Apply middleware response headers (e.g., X-Response-Time)
|
|
1420
|
+
middlewareResult.headers.forEach((value, key) => {
|
|
1421
|
+
res.setHeader(key, value);
|
|
1422
|
+
});
|
|
1423
|
+
flushHeaders();
|
|
1424
|
+
await handleRscNavigation(server, req, res, match, appDir, i18nConfig);
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
server.ssrFixStacktrace(error);
|
|
1428
|
+
server.config.logger.error(`[catmint] RSC navigation error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1429
|
+
if (!res.headersSent) {
|
|
1430
|
+
res.statusCode = 500;
|
|
1431
|
+
res.setHeader("Content-Type", "application/json");
|
|
1432
|
+
res.end(JSON.stringify({ error: "RSC navigation error" }));
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1017
1435
|
return;
|
|
1018
1436
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1437
|
+
try {
|
|
1438
|
+
await ensureMatchers();
|
|
1439
|
+
// --- API endpoint handling ---
|
|
1440
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1441
|
+
const endpointMatch = endpointMatcher.matchRoute(url);
|
|
1442
|
+
if (endpointMatch) {
|
|
1443
|
+
const epMethods = endpointMatch.route.methods ?? [];
|
|
1444
|
+
const hasHandler = epMethods.includes(method) ||
|
|
1445
|
+
epMethods.includes("ANY") ||
|
|
1446
|
+
epMethods.includes("default");
|
|
1447
|
+
// CORS auto-preflight: when OPTIONS is requested but no
|
|
1448
|
+
// OPTIONS handler is exported, generate a sensible preflight
|
|
1449
|
+
// response (PRD §26.3). Safe by default — no Allow-Origin.
|
|
1450
|
+
if (method === "OPTIONS" && !hasHandler) {
|
|
1451
|
+
const headers = buildPreflightHeaders(epMethods);
|
|
1452
|
+
res.statusCode = 204;
|
|
1453
|
+
res.setHeader("Allow", headers.allow);
|
|
1454
|
+
res.setHeader("Access-Control-Allow-Methods", headers.accessControlAllowMethods);
|
|
1455
|
+
res.setHeader("Access-Control-Allow-Headers", headers.accessControlAllowHeaders);
|
|
1456
|
+
res.setHeader("Access-Control-Max-Age", headers.accessControlMaxAge);
|
|
1457
|
+
res.end();
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (hasHandler) {
|
|
1461
|
+
flushHeaders();
|
|
1462
|
+
return await handleEndpoint(server, req, res, endpointMatch, method);
|
|
1463
|
+
}
|
|
1464
|
+
res.statusCode = 405;
|
|
1465
|
+
res.setHeader("Content-Type", "text/plain");
|
|
1466
|
+
res.setHeader("Allow", epMethods.filter((m) => m !== "default").join(", ") || "GET");
|
|
1467
|
+
res.end(`Method ${method} not allowed`);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
// --- Page handling (GET only) ---
|
|
1471
|
+
if (method !== "GET") {
|
|
1472
|
+
return next();
|
|
1473
|
+
}
|
|
1474
|
+
const match = pageMatcher.matchRoute(url);
|
|
1475
|
+
if (!match) {
|
|
1476
|
+
await sendStatusPage(res, 404, url);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
// --- Execute middleware chain for this route ---
|
|
1480
|
+
const routeDir = dirname(match.route.filePath);
|
|
1481
|
+
const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
|
|
1482
|
+
if (middlewareResult.shortCircuit) {
|
|
1483
|
+
// Middleware short-circuited (e.g., returned 401)
|
|
1484
|
+
flushHeaders();
|
|
1485
|
+
await sendWebResponse(res, middlewareResult.shortCircuit);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
// Apply middleware response headers (e.g., X-Response-Time)
|
|
1489
|
+
// These will be set on the response before page rendering streams
|
|
1490
|
+
middlewareResult.headers.forEach((value, key) => {
|
|
1491
|
+
res.setHeader(key, value);
|
|
1492
|
+
});
|
|
1493
|
+
// Flush pending cookie/response headers before page rendering
|
|
1494
|
+
flushHeaders();
|
|
1495
|
+
// Check if RSC environments are available
|
|
1496
|
+
if (hasRscEnvironments()) {
|
|
1497
|
+
await handlePageWithRsc(server, req, res, match, appDir, softNav, i18nConfig, sendStatusPage);
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
await handlePageLegacy(server, req, res, match, appDir, hydrationScripts, hydrationCounter++, HYDRATE_VIRTUAL_PREFIX, i18nConfig);
|
|
1034
1501
|
}
|
|
1035
1502
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
server.ssrFixStacktrace(error);
|
|
1505
|
+
if (isStatusError(error)) {
|
|
1506
|
+
// User code threw statusResponse() — render the corresponding status page
|
|
1507
|
+
server.config.logger.info(`[catmint] Status ${error.statusCode} for ${url}`);
|
|
1508
|
+
if (!res.headersSent) {
|
|
1509
|
+
flushHeaders();
|
|
1510
|
+
await sendStatusPage(res, error.statusCode, url, undefined, error.data);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
// Unexpected error — render 500 page
|
|
1515
|
+
server.config.logger.error(`[catmint] SSR error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1516
|
+
if (!res.headersSent) {
|
|
1517
|
+
await sendStatusPage(res, 500, url, {
|
|
1518
|
+
detail: error instanceof Error
|
|
1519
|
+
? (error.stack ?? error.message)
|
|
1520
|
+
: String(error),
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1045
1523
|
}
|
|
1046
1524
|
}
|
|
1047
|
-
}
|
|
1525
|
+
}); // end runWithRequestContext
|
|
1048
1526
|
});
|
|
1049
1527
|
};
|
|
1050
1528
|
},
|
|
1051
1529
|
handleHotUpdate({ file, server: hmrServer }) {
|
|
1052
1530
|
if (file.includes("/app/") &&
|
|
1053
1531
|
(file.endsWith("page.tsx") ||
|
|
1054
|
-
file.endsWith("page.jsx") ||
|
|
1055
1532
|
file.endsWith("page.mdx") ||
|
|
1056
|
-
file.endsWith("endpoint.ts")
|
|
1057
|
-
file.endsWith("endpoint.js"))) {
|
|
1533
|
+
file.endsWith("endpoint.ts"))) {
|
|
1058
1534
|
pageMatcher = null;
|
|
1059
1535
|
endpointMatcher = null;
|
|
1060
1536
|
}
|
|
@@ -1064,7 +1540,7 @@ export function devServerPlugin(options) {
|
|
|
1064
1540
|
// status page is picked up on the next error.
|
|
1065
1541
|
if (file.includes("/app/")) {
|
|
1066
1542
|
const basename = file.split("/").pop() ?? "";
|
|
1067
|
-
if (/^\d{3}\.(tsx|
|
|
1543
|
+
if (/^\d{3}\.(tsx|ts)$/.test(basename)) {
|
|
1068
1544
|
statusPages = scanStatusPages(appDir);
|
|
1069
1545
|
hmrServer.config.logger.info(`[catmint] Status page changed: ${basename} — triggering reload`);
|
|
1070
1546
|
hmrServer.hot.send({ type: "full-reload", path: "*" });
|
|
@@ -1144,7 +1620,11 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
|
|
|
1144
1620
|
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1145
1621
|
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1146
1622
|
// with the error boundary component inside the nearest layout.
|
|
1147
|
-
|
|
1623
|
+
const pageProps = {};
|
|
1624
|
+
if (match.params && Object.keys(match.params).length > 0) {
|
|
1625
|
+
pageProps.params = match.params;
|
|
1626
|
+
}
|
|
1627
|
+
let element = createElement(PageComponent, Object.keys(pageProps).length > 0 ? pageProps : null);
|
|
1148
1628
|
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1149
1629
|
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1150
1630
|
if (loadingPath) {
|
|
@@ -1172,10 +1652,24 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
|
|
|
1172
1652
|
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1173
1653
|
try {
|
|
1174
1654
|
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1175
|
-
const
|
|
1176
|
-
if (
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1655
|
+
const ErrorFallbackComponent = errorMod.default;
|
|
1656
|
+
if (ErrorFallbackComponent) {
|
|
1657
|
+
// Import the ErrorBoundary class component to properly catch errors
|
|
1658
|
+
const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
|
|
1659
|
+
const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
|
|
1660
|
+
errorBoundaryMod.default?.ErrorBoundary;
|
|
1661
|
+
if (ErrorBoundary) {
|
|
1662
|
+
element = createElement(ErrorBoundary, {
|
|
1663
|
+
fallback: ErrorFallbackComponent,
|
|
1664
|
+
children: element,
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
// Fallback: wrap page with error fallback component directly
|
|
1669
|
+
element = createElement(ErrorFallbackComponent, {
|
|
1670
|
+
children: element,
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1179
1673
|
}
|
|
1180
1674
|
}
|
|
1181
1675
|
catch {
|
|
@@ -1348,7 +1842,11 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
|
|
|
1348
1842
|
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1349
1843
|
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1350
1844
|
// with the error boundary component inside the nearest layout.
|
|
1351
|
-
|
|
1845
|
+
const navPageProps = {};
|
|
1846
|
+
if (match.params && Object.keys(match.params).length > 0) {
|
|
1847
|
+
navPageProps.params = match.params;
|
|
1848
|
+
}
|
|
1849
|
+
let element = createElement(PageComponent, Object.keys(navPageProps).length > 0 ? navPageProps : null);
|
|
1352
1850
|
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1353
1851
|
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1354
1852
|
if (loadingPath) {
|
|
@@ -1376,10 +1874,24 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
|
|
|
1376
1874
|
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1377
1875
|
try {
|
|
1378
1876
|
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1379
|
-
const
|
|
1380
|
-
if (
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1877
|
+
const ErrorFallbackComponent = errorMod.default;
|
|
1878
|
+
if (ErrorFallbackComponent) {
|
|
1879
|
+
// Import the ErrorBoundary class component to properly catch errors
|
|
1880
|
+
const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
|
|
1881
|
+
const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
|
|
1882
|
+
errorBoundaryMod.default?.ErrorBoundary;
|
|
1883
|
+
if (ErrorBoundary) {
|
|
1884
|
+
element = createElement(ErrorBoundary, {
|
|
1885
|
+
fallback: ErrorFallbackComponent,
|
|
1886
|
+
children: element,
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
// Fallback: wrap page with error fallback component directly
|
|
1891
|
+
element = createElement(ErrorFallbackComponent, {
|
|
1892
|
+
children: element,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1383
1895
|
}
|
|
1384
1896
|
}
|
|
1385
1897
|
catch {
|
|
@@ -1459,7 +1971,9 @@ async function handlePageLegacy(server, _req, res, match, appDir, hydrScripts, h
|
|
|
1459
1971
|
}
|
|
1460
1972
|
// Resolve generateMetadata exports from page + layout modules
|
|
1461
1973
|
const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
|
|
1462
|
-
let element = React.createElement(PageComponent,
|
|
1974
|
+
let element = React.createElement(PageComponent, match.params && Object.keys(match.params).length > 0
|
|
1975
|
+
? { params: match.params }
|
|
1976
|
+
: null);
|
|
1463
1977
|
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1464
1978
|
element = React.createElement(layoutComponents[i], null, element);
|
|
1465
1979
|
}
|
|
@@ -1629,6 +2143,31 @@ hydrate().then(() => {
|
|
|
1629
2143
|
setupClientNavigation(root);
|
|
1630
2144
|
}
|
|
1631
2145
|
});
|
|
2146
|
+
|
|
2147
|
+
// HMR: When server components (page.tsx, layout.tsx) are edited, the RSC
|
|
2148
|
+
// plugin sends an "rsc:update" event. Re-fetch the flight stream for the
|
|
2149
|
+
// current page and re-render so the update appears without a full reload.
|
|
2150
|
+
if (import.meta.hot) {
|
|
2151
|
+
import.meta.hot.on("rsc:update", async () => {
|
|
2152
|
+
if (!root) return;
|
|
2153
|
+
try {
|
|
2154
|
+
var rscUrl = "/__catmint/rsc?path=" + encodeURIComponent(
|
|
2155
|
+
window.location.pathname + window.location.search
|
|
2156
|
+
);
|
|
2157
|
+
var response = await fetch(rscUrl);
|
|
2158
|
+
if (!response.ok) {
|
|
2159
|
+
window.location.reload();
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
var { createFromReadableStream } = await import("@vitejs/plugin-rsc/browser");
|
|
2163
|
+
var newRoot = await createFromReadableStream(response.body);
|
|
2164
|
+
root.render(newRoot);
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
console.error("[catmint] HMR update failed, reloading:", err);
|
|
2167
|
+
window.location.reload();
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
1632
2171
|
`;
|
|
1633
2172
|
}
|
|
1634
2173
|
// --------------------------------------------------------------------------
|