@catmint/vite 0.0.0-prealpha.2 → 0.0.0-prealpha.21
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 +778 -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,13 @@ 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;
|
|
818
|
+
// Tsconfig path aliases resolved during config(), used in resolveId()
|
|
819
|
+
// to handle path aliases across all Vite environments (RSC, SSR, client).
|
|
820
|
+
let tsconfigPathMappings = [];
|
|
548
821
|
let pageMatcher = null;
|
|
549
822
|
let endpointMatcher = null;
|
|
550
823
|
// Pre-scanned status pages sorted by most-specific (longest prefix) first.
|
|
@@ -784,6 +1057,131 @@ export function devServerPlugin(options) {
|
|
|
784
1057
|
return null;
|
|
785
1058
|
}
|
|
786
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Read `compilerOptions.paths` from the project's `tsconfig.json` and
|
|
1062
|
+
* convert them to Vite `resolve.alias` entries so that path aliases like
|
|
1063
|
+
* `@/*` or `~/*` work at runtime without extra user configuration.
|
|
1064
|
+
*
|
|
1065
|
+
* - Strips JSON comments (JSONC) before parsing.
|
|
1066
|
+
* - Respects `compilerOptions.baseUrl` (defaults to tsconfig directory).
|
|
1067
|
+
* - Wildcard patterns (`"@/*": ["./app/*"]`) become regex aliases.
|
|
1068
|
+
* - Exact patterns (`"@/utils": ["./src/utils"]`) become string aliases.
|
|
1069
|
+
*/
|
|
1070
|
+
function buildTsconfigAliases(root) {
|
|
1071
|
+
const aliases = [];
|
|
1072
|
+
// Look for tsconfig.json in the project root
|
|
1073
|
+
const tsconfigPath = join(root, "tsconfig.json");
|
|
1074
|
+
if (!existsSync(tsconfigPath))
|
|
1075
|
+
return aliases;
|
|
1076
|
+
let raw;
|
|
1077
|
+
try {
|
|
1078
|
+
raw = readFileSync(tsconfigPath, "utf-8");
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return aliases;
|
|
1082
|
+
}
|
|
1083
|
+
// Strip single-line and multi-line comments (JSONC → JSON)
|
|
1084
|
+
const stripped = raw
|
|
1085
|
+
.replace(/\/\/.*$/gm, "")
|
|
1086
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
1087
|
+
let parsed;
|
|
1088
|
+
try {
|
|
1089
|
+
parsed = JSON.parse(stripped);
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
return aliases;
|
|
1093
|
+
}
|
|
1094
|
+
const paths = parsed.compilerOptions?.paths;
|
|
1095
|
+
if (!paths)
|
|
1096
|
+
return aliases;
|
|
1097
|
+
const baseUrl = parsed.compilerOptions?.baseUrl
|
|
1098
|
+
? resolve(dirname(tsconfigPath), parsed.compilerOptions.baseUrl)
|
|
1099
|
+
: dirname(tsconfigPath);
|
|
1100
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
1101
|
+
const target = targets?.[0];
|
|
1102
|
+
if (!target)
|
|
1103
|
+
continue;
|
|
1104
|
+
if (pattern.endsWith("/*") && target.endsWith("/*")) {
|
|
1105
|
+
// Wildcard pattern: "@/*" → ["./app/*"]
|
|
1106
|
+
// Convert to regex so Vite matches any subpath
|
|
1107
|
+
const prefix = pattern.slice(0, -2); // "@"
|
|
1108
|
+
const targetDir = resolve(baseUrl, target.slice(0, -2)); // abs path to "./app"
|
|
1109
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1110
|
+
aliases.push({
|
|
1111
|
+
find: new RegExp(`^${escaped}\\/(.+)$`),
|
|
1112
|
+
replacement: `${targetDir}/$1`,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
// Exact pattern: "@/utils" → ["./src/utils"]
|
|
1117
|
+
aliases.push({
|
|
1118
|
+
find: pattern,
|
|
1119
|
+
replacement: resolve(baseUrl, target),
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return aliases;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Read `compilerOptions.paths` from the project's `tsconfig.json` and
|
|
1127
|
+
* return structured path mappings for use in the `resolveId` hook.
|
|
1128
|
+
*
|
|
1129
|
+
* Unlike `buildTsconfigAliases` (which returns Vite `resolve.alias` entries
|
|
1130
|
+
* that only work in the top-level SSR pipeline), these mappings are used by
|
|
1131
|
+
* `resolveId` which runs across ALL Vite environments (RSC, SSR, client).
|
|
1132
|
+
*/
|
|
1133
|
+
function buildTsconfigPathMappings(root) {
|
|
1134
|
+
const mappings = [];
|
|
1135
|
+
const tsconfigPath = join(root, "tsconfig.json");
|
|
1136
|
+
if (!existsSync(tsconfigPath))
|
|
1137
|
+
return mappings;
|
|
1138
|
+
let raw;
|
|
1139
|
+
try {
|
|
1140
|
+
raw = readFileSync(tsconfigPath, "utf-8");
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
return mappings;
|
|
1144
|
+
}
|
|
1145
|
+
// Strip single-line and multi-line comments (JSONC → JSON)
|
|
1146
|
+
const stripped = raw
|
|
1147
|
+
.replace(/\/\/.*$/gm, "")
|
|
1148
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
1149
|
+
let parsed;
|
|
1150
|
+
try {
|
|
1151
|
+
parsed = JSON.parse(stripped);
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
return mappings;
|
|
1155
|
+
}
|
|
1156
|
+
const paths = parsed.compilerOptions?.paths;
|
|
1157
|
+
if (!paths)
|
|
1158
|
+
return mappings;
|
|
1159
|
+
const baseUrl = parsed.compilerOptions?.baseUrl
|
|
1160
|
+
? resolve(dirname(tsconfigPath), parsed.compilerOptions.baseUrl)
|
|
1161
|
+
: dirname(tsconfigPath);
|
|
1162
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
1163
|
+
const target = targets?.[0];
|
|
1164
|
+
if (!target)
|
|
1165
|
+
continue;
|
|
1166
|
+
if (pattern.endsWith("/*") && target.endsWith("/*")) {
|
|
1167
|
+
// Wildcard: "@/*" → ["./app/*"]
|
|
1168
|
+
mappings.push({
|
|
1169
|
+
prefix: pattern.slice(0, -2), // "@"
|
|
1170
|
+
targetDir: resolve(baseUrl, target.slice(0, -2)), // abs path to "./app"
|
|
1171
|
+
isWildcard: true,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
// Exact: "@/utils" → ["./src/utils"]
|
|
1176
|
+
mappings.push({
|
|
1177
|
+
prefix: pattern,
|
|
1178
|
+
targetDir: resolve(baseUrl, target),
|
|
1179
|
+
isWildcard: false,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return mappings;
|
|
1184
|
+
}
|
|
787
1185
|
/**
|
|
788
1186
|
* Build resolve.alias entries so that all Vite environments can find
|
|
789
1187
|
* @vitejs/plugin-rsc subpaths and rsc-html-stream subpaths regardless
|
|
@@ -838,14 +1236,22 @@ export function devServerPlugin(options) {
|
|
|
838
1236
|
return {
|
|
839
1237
|
name: "catmint:dev-server",
|
|
840
1238
|
enforce: "post",
|
|
841
|
-
config() {
|
|
842
|
-
const
|
|
1239
|
+
config(userConfig) {
|
|
1240
|
+
const root = userConfig.root ?? process.cwd();
|
|
1241
|
+
const rscAliases = buildRscAliases();
|
|
1242
|
+
const tsconfigAliases = buildTsconfigAliases(root);
|
|
1243
|
+
// Store path mappings for resolveId (works across all environments)
|
|
1244
|
+
tsconfigPathMappings = buildTsconfigPathMappings(root);
|
|
843
1245
|
return {
|
|
844
1246
|
resolve: {
|
|
845
|
-
alias:
|
|
1247
|
+
alias: [...tsconfigAliases, ...rscAliases],
|
|
846
1248
|
},
|
|
847
1249
|
ssr: {
|
|
848
|
-
external: [
|
|
1250
|
+
external: [
|
|
1251
|
+
"catmint/routing",
|
|
1252
|
+
"catmint/status",
|
|
1253
|
+
"catmint/runtime/context",
|
|
1254
|
+
],
|
|
849
1255
|
},
|
|
850
1256
|
optimizeDeps: {
|
|
851
1257
|
include: [
|
|
@@ -858,6 +1264,45 @@ export function devServerPlugin(options) {
|
|
|
858
1264
|
};
|
|
859
1265
|
},
|
|
860
1266
|
resolveId(id) {
|
|
1267
|
+
// ── tsconfig path aliases ────────────────────────────────────
|
|
1268
|
+
// Resolve `compilerOptions.paths` aliases (e.g. `@/*`) here in
|
|
1269
|
+
// `resolveId` so they work across ALL Vite environments (RSC,
|
|
1270
|
+
// SSR, client), not just the top-level SSR pipeline that
|
|
1271
|
+
// honours `resolve.alias`.
|
|
1272
|
+
for (const mapping of tsconfigPathMappings) {
|
|
1273
|
+
let candidate = null;
|
|
1274
|
+
if (mapping.isWildcard) {
|
|
1275
|
+
const prefixSlash = mapping.prefix + "/";
|
|
1276
|
+
if (id.startsWith(prefixSlash)) {
|
|
1277
|
+
const rest = id.slice(prefixSlash.length);
|
|
1278
|
+
candidate = join(mapping.targetDir, rest);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
else if (id === mapping.prefix) {
|
|
1282
|
+
candidate = mapping.targetDir;
|
|
1283
|
+
}
|
|
1284
|
+
if (candidate) {
|
|
1285
|
+
// Try common file extensions and index files
|
|
1286
|
+
const extensions = [
|
|
1287
|
+
"",
|
|
1288
|
+
".ts",
|
|
1289
|
+
".tsx",
|
|
1290
|
+
".js",
|
|
1291
|
+
".jsx",
|
|
1292
|
+
"/index.ts",
|
|
1293
|
+
"/index.tsx",
|
|
1294
|
+
"/index.js",
|
|
1295
|
+
"/index.jsx",
|
|
1296
|
+
];
|
|
1297
|
+
for (const ext of extensions) {
|
|
1298
|
+
const full = candidate + ext;
|
|
1299
|
+
if (existsSync(full)) {
|
|
1300
|
+
return full;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// ── virtual modules ──────────────────────────────────────────
|
|
861
1306
|
if (id === RSC_RENDERER_ID || id === RESOLVED_RSC_RENDERER_ID) {
|
|
862
1307
|
return RESOLVED_RSC_RENDERER_ID;
|
|
863
1308
|
}
|
|
@@ -894,6 +1339,61 @@ export function devServerPlugin(options) {
|
|
|
894
1339
|
server = viteServer;
|
|
895
1340
|
appDir = join(server.config.root, "app");
|
|
896
1341
|
statusPages = scanStatusPages(appDir);
|
|
1342
|
+
// Load request context APIs from catmint/runtime/context via SSR
|
|
1343
|
+
// module resolution (not a static import) so the module is resolved
|
|
1344
|
+
// through Node.js resolution at dev time, not Vite's module graph.
|
|
1345
|
+
const contextReady = server
|
|
1346
|
+
.ssrLoadModule("catmint/runtime/context")
|
|
1347
|
+
.then((mod) => {
|
|
1348
|
+
createRequestStore = mod.createRequestStore;
|
|
1349
|
+
runWithRequestContext = mod.runWithRequestContext;
|
|
1350
|
+
});
|
|
1351
|
+
// Store the promise so the first request can await it
|
|
1352
|
+
server.__catmintContextReady = contextReady;
|
|
1353
|
+
// Initialize adapter dev platform (e.g. Cloudflare getPlatformProxy).
|
|
1354
|
+
// This is async but we can't await in configureServer, so we kick it
|
|
1355
|
+
// off and store the promise. The first request will await it.
|
|
1356
|
+
if (adapter?.dev) {
|
|
1357
|
+
const initPromise = Promise.resolve(adapter.dev());
|
|
1358
|
+
initPromise
|
|
1359
|
+
.then((helper) => {
|
|
1360
|
+
devPlatform = helper;
|
|
1361
|
+
server.config.logger.info(`[catmint] Adapter "${adapter.name}" dev platform initialized`);
|
|
1362
|
+
})
|
|
1363
|
+
.catch((err) => {
|
|
1364
|
+
server.config.logger.error(`[catmint] Failed to initialize adapter dev platform: ${err instanceof Error ? err.message : String(err)}`);
|
|
1365
|
+
});
|
|
1366
|
+
// Store the promise so requests can await it
|
|
1367
|
+
server.__catmintDevPlatformInit = initPromise;
|
|
1368
|
+
// Clean up on server close
|
|
1369
|
+
server.httpServer?.on("close", () => {
|
|
1370
|
+
if (devPlatform?.close) {
|
|
1371
|
+
Promise.resolve(devPlatform.close()).catch(() => { });
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
// Invalidate route matchers when route files are added or removed.
|
|
1376
|
+
// Vite's handleHotUpdate only fires for files already in the module
|
|
1377
|
+
// graph, so newly created or deleted files need this watcher to
|
|
1378
|
+
// trigger a re-scan on the next request.
|
|
1379
|
+
const isRouteFile = (f) => f.includes("/app/") &&
|
|
1380
|
+
(f.endsWith("page.tsx") ||
|
|
1381
|
+
f.endsWith("page.mdx") ||
|
|
1382
|
+
f.endsWith("endpoint.ts"));
|
|
1383
|
+
server.watcher.on("add", (file) => {
|
|
1384
|
+
if (isRouteFile(file)) {
|
|
1385
|
+
pageMatcher = null;
|
|
1386
|
+
endpointMatcher = null;
|
|
1387
|
+
server.config.logger.info(`[catmint] Route file added: ${file} — invalidating route cache`);
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
server.watcher.on("unlink", (file) => {
|
|
1391
|
+
if (isRouteFile(file)) {
|
|
1392
|
+
pageMatcher = null;
|
|
1393
|
+
endpointMatcher = null;
|
|
1394
|
+
server.config.logger.info(`[catmint] Route file removed: ${file} — invalidating route cache`);
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
897
1397
|
return () => {
|
|
898
1398
|
server.middlewares.use(async (req, res, next) => {
|
|
899
1399
|
const url = req.originalUrl ?? req.url ?? "/";
|
|
@@ -919,142 +1419,222 @@ export function devServerPlugin(options) {
|
|
|
919
1419
|
if (shouldSkip(url)) {
|
|
920
1420
|
return next();
|
|
921
1421
|
}
|
|
922
|
-
// ---
|
|
923
|
-
|
|
924
|
-
|
|
1422
|
+
// --- Establish request context for all server-side handlers ---
|
|
1423
|
+
// This enables cookies(), headers(), getPlatform() during dev.
|
|
1424
|
+
const webRequest = nodeReqToWebRequest(req);
|
|
1425
|
+
// Ensure runtime context APIs are loaded before first use
|
|
1426
|
+
const contextReady = server.__catmintContextReady;
|
|
1427
|
+
if (contextReady) {
|
|
1428
|
+
await contextReady;
|
|
1429
|
+
}
|
|
1430
|
+
// Wait for adapter dev platform initialization if still pending
|
|
1431
|
+
const initPromise = server.__catmintDevPlatformInit;
|
|
1432
|
+
if (initPromise && !devPlatform) {
|
|
925
1433
|
try {
|
|
926
|
-
|
|
1434
|
+
devPlatform = await initPromise;
|
|
927
1435
|
}
|
|
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
|
-
}
|
|
1436
|
+
catch {
|
|
1437
|
+
// Already logged during init
|
|
936
1438
|
}
|
|
937
|
-
return;
|
|
938
1439
|
}
|
|
939
|
-
//
|
|
940
|
-
|
|
1440
|
+
// Get platform context from adapter (e.g. Cloudflare { env, ctx })
|
|
1441
|
+
// or fall back to Node.js platform shape
|
|
1442
|
+
let platform;
|
|
1443
|
+
if (devPlatform) {
|
|
941
1444
|
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);
|
|
1445
|
+
platform = await devPlatform.getPlatform(webRequest, req, res);
|
|
966
1446
|
}
|
|
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
|
-
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
server.config.logger.warn(`[catmint] Adapter getPlatform() failed, falling back to Node.js platform: ${err instanceof Error ? err.message : String(err)}`);
|
|
1449
|
+
platform = { req, res };
|
|
975
1450
|
}
|
|
976
|
-
return;
|
|
977
1451
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1452
|
+
else {
|
|
1453
|
+
platform = { req, res };
|
|
1454
|
+
}
|
|
1455
|
+
const store = createRequestStore(webRequest, platform);
|
|
1456
|
+
/**
|
|
1457
|
+
* Flush pending Set-Cookie headers and accumulated response headers
|
|
1458
|
+
* onto the Node.js ServerResponse before it's sent.
|
|
1459
|
+
*/
|
|
1460
|
+
const flushHeaders = () => {
|
|
1461
|
+
if (res.headersSent)
|
|
1462
|
+
return;
|
|
1463
|
+
store.responseHeaders.forEach((value, key) => {
|
|
1464
|
+
res.setHeader(key, value);
|
|
1465
|
+
});
|
|
1466
|
+
for (const pending of store.pendingCookies.values()) {
|
|
1467
|
+
res.appendHeader("Set-Cookie", pending.serialized);
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
return runWithRequestContext(store, async () => {
|
|
1471
|
+
// --- Server function RPC handling ---
|
|
1472
|
+
const pathname = url.split("?")[0];
|
|
1473
|
+
if (pathname.startsWith(SERVER_FN_PREFIX)) {
|
|
1474
|
+
try {
|
|
1475
|
+
await handleServerFn(server, req, res, pathname);
|
|
1000
1476
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
server.ssrFixStacktrace(error);
|
|
1479
|
+
server.config.logger.error(`[catmint] Server function error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1480
|
+
if (!res.headersSent) {
|
|
1481
|
+
res.statusCode = 500;
|
|
1482
|
+
res.setHeader("Content-Type", "application/json");
|
|
1483
|
+
res.end(JSON.stringify({ error: "Internal server function error" }));
|
|
1484
|
+
}
|
|
1003
1485
|
}
|
|
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`);
|
|
1486
|
+
flushHeaders();
|
|
1008
1487
|
return;
|
|
1009
1488
|
}
|
|
1010
|
-
// ---
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1489
|
+
// --- RSC flight stream for client-side navigation ---
|
|
1490
|
+
if (pathname === RSC_NAVIGATION_PREFIX) {
|
|
1491
|
+
try {
|
|
1492
|
+
const parsedUrl = new URL(url, "http://localhost");
|
|
1493
|
+
const targetPath = parsedUrl.searchParams.get("path");
|
|
1494
|
+
if (!targetPath) {
|
|
1495
|
+
res.statusCode = 400;
|
|
1496
|
+
res.setHeader("Content-Type", "application/json");
|
|
1497
|
+
res.end(JSON.stringify({ error: "Missing ?path= parameter" }));
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
await ensureMatchers();
|
|
1501
|
+
const match = pageMatcher.matchRoute(targetPath);
|
|
1502
|
+
if (!match) {
|
|
1503
|
+
res.statusCode = 404;
|
|
1504
|
+
res.setHeader("Content-Type", "application/json");
|
|
1505
|
+
res.end(JSON.stringify({ error: "No matching route" }));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (!hasRscEnvironments()) {
|
|
1509
|
+
// Without RSC, fall back to a full page reload signal
|
|
1510
|
+
res.statusCode = 406;
|
|
1511
|
+
res.setHeader("Content-Type", "application/json");
|
|
1512
|
+
res.end(JSON.stringify({ error: "RSC not available" }));
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
// Execute middleware for the TARGET page path (not /__catmint/rsc)
|
|
1516
|
+
const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
|
|
1517
|
+
if (middlewareResult.shortCircuit) {
|
|
1518
|
+
// Middleware short-circuited (e.g., returned 401)
|
|
1519
|
+
flushHeaders();
|
|
1520
|
+
await sendWebResponse(res, middlewareResult.shortCircuit);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// Apply middleware response headers (e.g., X-Response-Time)
|
|
1524
|
+
middlewareResult.headers.forEach((value, key) => {
|
|
1525
|
+
res.setHeader(key, value);
|
|
1526
|
+
});
|
|
1527
|
+
flushHeaders();
|
|
1528
|
+
await handleRscNavigation(server, req, res, match, appDir, i18nConfig);
|
|
1529
|
+
}
|
|
1530
|
+
catch (error) {
|
|
1531
|
+
server.ssrFixStacktrace(error);
|
|
1532
|
+
server.config.logger.error(`[catmint] RSC navigation error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1533
|
+
if (!res.headersSent) {
|
|
1534
|
+
res.statusCode = 500;
|
|
1535
|
+
res.setHeader("Content-Type", "application/json");
|
|
1536
|
+
res.end(JSON.stringify({ error: "RSC navigation error" }));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1017
1539
|
return;
|
|
1018
1540
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1541
|
+
try {
|
|
1542
|
+
await ensureMatchers();
|
|
1543
|
+
// --- API endpoint handling ---
|
|
1544
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1545
|
+
const endpointMatch = endpointMatcher.matchRoute(url);
|
|
1546
|
+
if (endpointMatch) {
|
|
1547
|
+
const epMethods = endpointMatch.route.methods ?? [];
|
|
1548
|
+
const hasHandler = epMethods.includes(method) ||
|
|
1549
|
+
epMethods.includes("ANY") ||
|
|
1550
|
+
epMethods.includes("default");
|
|
1551
|
+
// CORS auto-preflight: when OPTIONS is requested but no
|
|
1552
|
+
// OPTIONS handler is exported, generate a sensible preflight
|
|
1553
|
+
// response (PRD §26.3). Safe by default — no Allow-Origin.
|
|
1554
|
+
if (method === "OPTIONS" && !hasHandler) {
|
|
1555
|
+
const headers = buildPreflightHeaders(epMethods);
|
|
1556
|
+
res.statusCode = 204;
|
|
1557
|
+
res.setHeader("Allow", headers.allow);
|
|
1558
|
+
res.setHeader("Access-Control-Allow-Methods", headers.accessControlAllowMethods);
|
|
1559
|
+
res.setHeader("Access-Control-Allow-Headers", headers.accessControlAllowHeaders);
|
|
1560
|
+
res.setHeader("Access-Control-Max-Age", headers.accessControlMaxAge);
|
|
1561
|
+
res.end();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (hasHandler) {
|
|
1565
|
+
flushHeaders();
|
|
1566
|
+
return await handleEndpoint(server, req, res, endpointMatch, method);
|
|
1567
|
+
}
|
|
1568
|
+
res.statusCode = 405;
|
|
1569
|
+
res.setHeader("Content-Type", "text/plain");
|
|
1570
|
+
res.setHeader("Allow", epMethods.filter((m) => m !== "default").join(", ") || "GET");
|
|
1571
|
+
res.end(`Method ${method} not allowed`);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
// --- Page handling (GET only) ---
|
|
1575
|
+
if (method !== "GET") {
|
|
1576
|
+
return next();
|
|
1577
|
+
}
|
|
1578
|
+
const match = pageMatcher.matchRoute(url);
|
|
1579
|
+
if (!match) {
|
|
1580
|
+
await sendStatusPage(res, 404, url);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
// --- Execute middleware chain for this route ---
|
|
1584
|
+
const routeDir = dirname(match.route.filePath);
|
|
1585
|
+
const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
|
|
1586
|
+
if (middlewareResult.shortCircuit) {
|
|
1587
|
+
// Middleware short-circuited (e.g., returned 401)
|
|
1588
|
+
flushHeaders();
|
|
1589
|
+
await sendWebResponse(res, middlewareResult.shortCircuit);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
// Apply middleware response headers (e.g., X-Response-Time)
|
|
1593
|
+
// These will be set on the response before page rendering streams
|
|
1594
|
+
middlewareResult.headers.forEach((value, key) => {
|
|
1595
|
+
res.setHeader(key, value);
|
|
1596
|
+
});
|
|
1597
|
+
// Flush pending cookie/response headers before page rendering
|
|
1598
|
+
flushHeaders();
|
|
1599
|
+
// Check if RSC environments are available
|
|
1600
|
+
if (hasRscEnvironments()) {
|
|
1601
|
+
await handlePageWithRsc(server, req, res, match, appDir, softNav, i18nConfig, sendStatusPage);
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
await handlePageLegacy(server, req, res, match, appDir, hydrationScripts, hydrationCounter++, HYDRATE_VIRTUAL_PREFIX, i18nConfig);
|
|
1034
1605
|
}
|
|
1035
1606
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1607
|
+
catch (error) {
|
|
1608
|
+
server.ssrFixStacktrace(error);
|
|
1609
|
+
if (isStatusError(error)) {
|
|
1610
|
+
// User code threw statusResponse() — render the corresponding status page
|
|
1611
|
+
server.config.logger.info(`[catmint] Status ${error.statusCode} for ${url}`);
|
|
1612
|
+
if (!res.headersSent) {
|
|
1613
|
+
flushHeaders();
|
|
1614
|
+
await sendStatusPage(res, error.statusCode, url, undefined, error.data);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
// Unexpected error — render 500 page
|
|
1619
|
+
server.config.logger.error(`[catmint] SSR error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
1620
|
+
if (!res.headersSent) {
|
|
1621
|
+
await sendStatusPage(res, 500, url, {
|
|
1622
|
+
detail: error instanceof Error
|
|
1623
|
+
? (error.stack ?? error.message)
|
|
1624
|
+
: String(error),
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1045
1627
|
}
|
|
1046
1628
|
}
|
|
1047
|
-
}
|
|
1629
|
+
}); // end runWithRequestContext
|
|
1048
1630
|
});
|
|
1049
1631
|
};
|
|
1050
1632
|
},
|
|
1051
1633
|
handleHotUpdate({ file, server: hmrServer }) {
|
|
1052
1634
|
if (file.includes("/app/") &&
|
|
1053
1635
|
(file.endsWith("page.tsx") ||
|
|
1054
|
-
file.endsWith("page.jsx") ||
|
|
1055
1636
|
file.endsWith("page.mdx") ||
|
|
1056
|
-
file.endsWith("endpoint.ts")
|
|
1057
|
-
file.endsWith("endpoint.js"))) {
|
|
1637
|
+
file.endsWith("endpoint.ts"))) {
|
|
1058
1638
|
pageMatcher = null;
|
|
1059
1639
|
endpointMatcher = null;
|
|
1060
1640
|
}
|
|
@@ -1064,7 +1644,7 @@ export function devServerPlugin(options) {
|
|
|
1064
1644
|
// status page is picked up on the next error.
|
|
1065
1645
|
if (file.includes("/app/")) {
|
|
1066
1646
|
const basename = file.split("/").pop() ?? "";
|
|
1067
|
-
if (/^\d{3}\.(tsx|
|
|
1647
|
+
if (/^\d{3}\.(tsx|ts)$/.test(basename)) {
|
|
1068
1648
|
statusPages = scanStatusPages(appDir);
|
|
1069
1649
|
hmrServer.config.logger.info(`[catmint] Status page changed: ${basename} — triggering reload`);
|
|
1070
1650
|
hmrServer.hot.send({ type: "full-reload", path: "*" });
|
|
@@ -1144,7 +1724,11 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
|
|
|
1144
1724
|
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1145
1725
|
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1146
1726
|
// with the error boundary component inside the nearest layout.
|
|
1147
|
-
|
|
1727
|
+
const pageProps = {};
|
|
1728
|
+
if (match.params && Object.keys(match.params).length > 0) {
|
|
1729
|
+
pageProps.params = match.params;
|
|
1730
|
+
}
|
|
1731
|
+
let element = createElement(PageComponent, Object.keys(pageProps).length > 0 ? pageProps : null);
|
|
1148
1732
|
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1149
1733
|
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1150
1734
|
if (loadingPath) {
|
|
@@ -1172,10 +1756,24 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
|
|
|
1172
1756
|
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1173
1757
|
try {
|
|
1174
1758
|
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1175
|
-
const
|
|
1176
|
-
if (
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1759
|
+
const ErrorFallbackComponent = errorMod.default;
|
|
1760
|
+
if (ErrorFallbackComponent) {
|
|
1761
|
+
// Import the ErrorBoundary class component to properly catch errors
|
|
1762
|
+
const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
|
|
1763
|
+
const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
|
|
1764
|
+
errorBoundaryMod.default?.ErrorBoundary;
|
|
1765
|
+
if (ErrorBoundary) {
|
|
1766
|
+
element = createElement(ErrorBoundary, {
|
|
1767
|
+
fallback: ErrorFallbackComponent,
|
|
1768
|
+
children: element,
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
else {
|
|
1772
|
+
// Fallback: wrap page with error fallback component directly
|
|
1773
|
+
element = createElement(ErrorFallbackComponent, {
|
|
1774
|
+
children: element,
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1179
1777
|
}
|
|
1180
1778
|
}
|
|
1181
1779
|
catch {
|
|
@@ -1348,7 +1946,11 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
|
|
|
1348
1946
|
// If a loading.tsx is found, wrap the page with a Suspense boundary.
|
|
1349
1947
|
// If an error.tsx is found via walk-up resolution, wrap the page
|
|
1350
1948
|
// with the error boundary component inside the nearest layout.
|
|
1351
|
-
|
|
1949
|
+
const navPageProps = {};
|
|
1950
|
+
if (match.params && Object.keys(match.params).length > 0) {
|
|
1951
|
+
navPageProps.params = match.params;
|
|
1952
|
+
}
|
|
1953
|
+
let element = createElement(PageComponent, Object.keys(navPageProps).length > 0 ? navPageProps : null);
|
|
1352
1954
|
// Resolve loading.tsx from the page directory for Suspense fallback
|
|
1353
1955
|
const loadingPath = resolveLoadingComponent(match.route.filePath);
|
|
1354
1956
|
if (loadingPath) {
|
|
@@ -1376,10 +1978,24 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
|
|
|
1376
1978
|
const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
|
|
1377
1979
|
try {
|
|
1378
1980
|
const errorMod = await rscEnv.runner.import(errorBoundaryImportPath);
|
|
1379
|
-
const
|
|
1380
|
-
if (
|
|
1381
|
-
//
|
|
1382
|
-
|
|
1981
|
+
const ErrorFallbackComponent = errorMod.default;
|
|
1982
|
+
if (ErrorFallbackComponent) {
|
|
1983
|
+
// Import the ErrorBoundary class component to properly catch errors
|
|
1984
|
+
const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
|
|
1985
|
+
const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
|
|
1986
|
+
errorBoundaryMod.default?.ErrorBoundary;
|
|
1987
|
+
if (ErrorBoundary) {
|
|
1988
|
+
element = createElement(ErrorBoundary, {
|
|
1989
|
+
fallback: ErrorFallbackComponent,
|
|
1990
|
+
children: element,
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
else {
|
|
1994
|
+
// Fallback: wrap page with error fallback component directly
|
|
1995
|
+
element = createElement(ErrorFallbackComponent, {
|
|
1996
|
+
children: element,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1383
1999
|
}
|
|
1384
2000
|
}
|
|
1385
2001
|
catch {
|
|
@@ -1459,7 +2075,9 @@ async function handlePageLegacy(server, _req, res, match, appDir, hydrScripts, h
|
|
|
1459
2075
|
}
|
|
1460
2076
|
// Resolve generateMetadata exports from page + layout modules
|
|
1461
2077
|
const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
|
|
1462
|
-
let element = React.createElement(PageComponent,
|
|
2078
|
+
let element = React.createElement(PageComponent, match.params && Object.keys(match.params).length > 0
|
|
2079
|
+
? { params: match.params }
|
|
2080
|
+
: null);
|
|
1463
2081
|
for (let i = layoutComponents.length - 1; i >= 0; i--) {
|
|
1464
2082
|
element = React.createElement(layoutComponents[i], null, element);
|
|
1465
2083
|
}
|
|
@@ -1629,6 +2247,31 @@ hydrate().then(() => {
|
|
|
1629
2247
|
setupClientNavigation(root);
|
|
1630
2248
|
}
|
|
1631
2249
|
});
|
|
2250
|
+
|
|
2251
|
+
// HMR: When server components (page.tsx, layout.tsx) are edited, the RSC
|
|
2252
|
+
// plugin sends an "rsc:update" event. Re-fetch the flight stream for the
|
|
2253
|
+
// current page and re-render so the update appears without a full reload.
|
|
2254
|
+
if (import.meta.hot) {
|
|
2255
|
+
import.meta.hot.on("rsc:update", async () => {
|
|
2256
|
+
if (!root) return;
|
|
2257
|
+
try {
|
|
2258
|
+
var rscUrl = "/__catmint/rsc?path=" + encodeURIComponent(
|
|
2259
|
+
window.location.pathname + window.location.search
|
|
2260
|
+
);
|
|
2261
|
+
var response = await fetch(rscUrl);
|
|
2262
|
+
if (!response.ok) {
|
|
2263
|
+
window.location.reload();
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
var { createFromReadableStream } = await import("@vitejs/plugin-rsc/browser");
|
|
2267
|
+
var newRoot = await createFromReadableStream(response.body);
|
|
2268
|
+
root.render(newRoot);
|
|
2269
|
+
} catch (err) {
|
|
2270
|
+
console.error("[catmint] HMR update failed, reloading:", err);
|
|
2271
|
+
window.location.reload();
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
1632
2275
|
`;
|
|
1633
2276
|
}
|
|
1634
2277
|
// --------------------------------------------------------------------------
|