@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.
@@ -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.jsx", "layout.ts", "layout.js"]) {
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
- res.end(JSON.stringify({
453
- error: err instanceof Error ? err.message : "Server function error",
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
- const response = await handler(webRequest, ctx);
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 aliases = buildRscAliases();
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: aliases,
1182
+ alias: [...tsconfigAliases, ...rscAliases],
846
1183
  },
847
1184
  ssr: {
848
- external: ["catmint/routing", "catmint/status"],
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
- // --- Server function RPC handling ---
923
- const pathname = url.split("?")[0];
924
- if (pathname.startsWith(SERVER_FN_PREFIX)) {
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
- await handleServerFn(server, req, res, pathname);
1330
+ devPlatform = await initPromise;
927
1331
  }
928
- catch (error) {
929
- server.ssrFixStacktrace(error);
930
- server.config.logger.error(`[catmint] Server function error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
931
- if (!res.headersSent) {
932
- res.statusCode = 500;
933
- res.setHeader("Content-Type", "application/json");
934
- res.end(JSON.stringify({ error: "Internal server function error" }));
935
- }
1332
+ catch {
1333
+ // Already logged during init
936
1334
  }
937
- return;
938
1335
  }
939
- // --- RSC flight stream for client-side navigation ---
940
- if (pathname === RSC_NAVIGATION_PREFIX) {
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
- const parsedUrl = new URL(url, "http://localhost");
943
- const targetPath = parsedUrl.searchParams.get("path");
944
- if (!targetPath) {
945
- res.statusCode = 400;
946
- res.setHeader("Content-Type", "application/json");
947
- res.end(JSON.stringify({ error: "Missing ?path= parameter" }));
948
- return;
949
- }
950
- await ensureMatchers();
951
- const match = pageMatcher.matchRoute(targetPath);
952
- if (!match) {
953
- res.statusCode = 404;
954
- res.setHeader("Content-Type", "application/json");
955
- res.end(JSON.stringify({ error: "No matching route" }));
956
- return;
957
- }
958
- if (!hasRscEnvironments()) {
959
- // Without RSC, fall back to a full page reload signal
960
- res.statusCode = 406;
961
- res.setHeader("Content-Type", "application/json");
962
- res.end(JSON.stringify({ error: "RSC not available" }));
963
- return;
964
- }
965
- await handleRscNavigation(server, req, res, match, appDir, i18nConfig);
1341
+ platform = await devPlatform.getPlatform(webRequest, req, res);
966
1342
  }
967
- catch (error) {
968
- server.ssrFixStacktrace(error);
969
- server.config.logger.error(`[catmint] RSC navigation error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
970
- if (!res.headersSent) {
971
- res.statusCode = 500;
972
- res.setHeader("Content-Type", "application/json");
973
- res.end(JSON.stringify({ error: "RSC navigation error" }));
974
- }
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
- try {
979
- await ensureMatchers();
980
- // --- API endpoint handling ---
981
- const method = (req.method ?? "GET").toUpperCase();
982
- const endpointMatch = endpointMatcher.matchRoute(url);
983
- if (endpointMatch) {
984
- const epMethods = endpointMatch.route.methods ?? [];
985
- const hasHandler = epMethods.includes(method) ||
986
- epMethods.includes("ANY") ||
987
- epMethods.includes("default");
988
- // CORS auto-preflight: when OPTIONS is requested but no
989
- // OPTIONS handler is exported, generate a sensible preflight
990
- // response (PRD §26.3). Safe by default — no Allow-Origin.
991
- if (method === "OPTIONS" && !hasHandler) {
992
- const headers = buildPreflightHeaders(epMethods);
993
- res.statusCode = 204;
994
- res.setHeader("Allow", headers.allow);
995
- res.setHeader("Access-Control-Allow-Methods", headers.accessControlAllowMethods);
996
- res.setHeader("Access-Control-Allow-Headers", headers.accessControlAllowHeaders);
997
- res.setHeader("Access-Control-Max-Age", headers.accessControlMaxAge);
998
- res.end();
999
- return;
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
- if (hasHandler) {
1002
- return await handleEndpoint(server, req, res, endpointMatch, method);
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
- res.statusCode = 405;
1005
- res.setHeader("Content-Type", "text/plain");
1006
- res.setHeader("Allow", epMethods.filter((m) => m !== "default").join(", ") || "GET");
1007
- res.end(`Method ${method} not allowed`);
1382
+ flushHeaders();
1008
1383
  return;
1009
1384
  }
1010
- // --- Page handling (GET only) ---
1011
- if (method !== "GET") {
1012
- return next();
1013
- }
1014
- const match = pageMatcher.matchRoute(url);
1015
- if (!match) {
1016
- await sendStatusPage(res, 404, url);
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
- // Check if RSC environments are available
1020
- if (hasRscEnvironments()) {
1021
- await handlePageWithRsc(server, req, res, match, appDir, softNav, i18nConfig, sendStatusPage);
1022
- }
1023
- else {
1024
- await handlePageLegacy(server, req, res, match, appDir, hydrationScripts, hydrationCounter++, HYDRATE_VIRTUAL_PREFIX, i18nConfig);
1025
- }
1026
- }
1027
- catch (error) {
1028
- server.ssrFixStacktrace(error);
1029
- if (isStatusError(error)) {
1030
- // User code threw statusResponse() render the corresponding status page
1031
- server.config.logger.info(`[catmint] Status ${error.statusCode} for ${url}`);
1032
- if (!res.headersSent) {
1033
- await sendStatusPage(res, error.statusCode, url, undefined, error.data);
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
- else {
1037
- // Unexpected error — render 500 page
1038
- server.config.logger.error(`[catmint] SSR error for ${url}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
1039
- if (!res.headersSent) {
1040
- await sendStatusPage(res, 500, url, {
1041
- detail: error instanceof Error
1042
- ? (error.stack ?? error.message)
1043
- : String(error),
1044
- });
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|jsx|ts|js)$/.test(basename)) {
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
- let element = createElement(PageComponent, null);
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 ErrorBoundaryComponent = errorMod.default;
1176
- if (ErrorBoundaryComponent) {
1177
- // Wrap page with error boundary it renders inside the nearest layout
1178
- element = createElement(ErrorBoundaryComponent, { children: element });
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
- let element = createElement(PageComponent, null);
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 ErrorBoundaryComponent = errorMod.default;
1380
- if (ErrorBoundaryComponent) {
1381
- // Wrap page with error boundary it renders inside the nearest layout
1382
- element = createElement(ErrorBoundaryComponent, { children: element });
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, null);
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
  // --------------------------------------------------------------------------