@catmint/vite 0.0.0-prealpha.1 → 0.0.0-prealpha.11

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.
@@ -14,6 +14,7 @@
14
14
  // calls createFromReadableStream → React VDOM → hydrateRoot
15
15
  import { join, dirname, relative } from "node:path";
16
16
  import { existsSync } 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";
19
20
  import { errorPageHtml } from "./error-page.js";
@@ -94,7 +95,7 @@ function collectLayouts(pageFilePath, appDir) {
94
95
  const layouts = [];
95
96
  let dir = dirname(pageFilePath);
96
97
  while (dir.startsWith(appDir)) {
97
- for (const name of ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]) {
98
+ for (const name of ["layout.tsx", "layout.ts"]) {
98
99
  const layoutPath = join(dir, name);
99
100
  if (existsSync(layoutPath)) {
100
101
  layouts.unshift(layoutPath);
@@ -107,6 +108,154 @@ function collectLayouts(pageFilePath, appDir) {
107
108
  }
108
109
  return layouts;
109
110
  }
111
+ /**
112
+ * File names checked for middleware, in priority order.
113
+ */
114
+ const MIDDLEWARE_FILENAMES = ["middleware.ts", "middleware.tsx"];
115
+ /**
116
+ * Resolve the middleware chain for a given route directory.
117
+ * Walks from the page's directory up to appDir, collecting middleware files.
118
+ * Returns paths in root-to-tip execution order.
119
+ */
120
+ function collectMiddleware(pageFilePath, appDir) {
121
+ const middlewareFiles = [];
122
+ let dir = dirname(pageFilePath);
123
+ while (dir.startsWith(appDir)) {
124
+ for (const name of MIDDLEWARE_FILENAMES) {
125
+ const middlewarePath = join(dir, name);
126
+ if (existsSync(middlewarePath)) {
127
+ middlewareFiles.unshift(middlewarePath); // prepend for root-to-tip order
128
+ break; // Only one middleware file per directory
129
+ }
130
+ }
131
+ if (dir === appDir)
132
+ break;
133
+ dir = dirname(dir);
134
+ }
135
+ return middlewareFiles;
136
+ }
137
+ /**
138
+ * Convert a Node.js IncomingMessage to a Web Request object.
139
+ */
140
+ function nodeReqToWebRequest(req) {
141
+ const protocol = "http";
142
+ const host = req.headers.host ?? "localhost";
143
+ const url = new URL(req.originalUrl ?? req.url ?? "/", `${protocol}://${host}`);
144
+ const headers = new Headers();
145
+ for (const [key, value] of Object.entries(req.headers)) {
146
+ if (value) {
147
+ if (Array.isArray(value)) {
148
+ for (const v of value) {
149
+ headers.append(key, v);
150
+ }
151
+ }
152
+ else {
153
+ headers.set(key, value);
154
+ }
155
+ }
156
+ }
157
+ return new Request(url.href, {
158
+ method: req.method ?? "GET",
159
+ headers,
160
+ });
161
+ }
162
+ /**
163
+ * Send a Web Response back through a Node.js ServerResponse.
164
+ */
165
+ async function sendWebResponse(res, webResponse) {
166
+ res.statusCode = webResponse.status;
167
+ webResponse.headers.forEach((value, key) => {
168
+ res.setHeader(key, value);
169
+ });
170
+ const body = await webResponse.text();
171
+ res.end(body);
172
+ }
173
+ /**
174
+ * Execute the middleware chain for a route. Returns a Response from middleware
175
+ * if middleware short-circuits (e.g., 401), or null if middleware calls next()
176
+ * and page rendering should proceed. Also returns any response headers that
177
+ * middleware added (e.g., X-Response-Time) so they can be merged into the
178
+ * final page response.
179
+ */
180
+ async function executeMiddleware(server, req, pageFilePath, appDir) {
181
+ const middlewarePaths = collectMiddleware(pageFilePath, appDir);
182
+ if (middlewarePaths.length === 0) {
183
+ return { shortCircuit: null, headers: new Headers() };
184
+ }
185
+ const handlers = [];
186
+ for (const mwPath of middlewarePaths) {
187
+ try {
188
+ const importPath = "/" + relative(server.config.root, mwPath);
189
+ const mod = await server.ssrLoadModule(importPath);
190
+ const handler = mod.default;
191
+ if (typeof handler === "function") {
192
+ handlers.push(handler);
193
+ }
194
+ }
195
+ catch (err) {
196
+ server.config.logger.warn(`[catmint] Failed to load middleware ${mwPath}: ${err}`);
197
+ }
198
+ }
199
+ if (handlers.length === 0) {
200
+ return { shortCircuit: null, headers: new Headers() };
201
+ }
202
+ // Apply inherit boundary: walk backwards to find inherit: false
203
+ let boundaryIndex = 0;
204
+ for (let i = handlers.length - 1; i >= 0; i--) {
205
+ const opts = handlers[i].__catmintMiddleware;
206
+ if (opts && opts.inherit === false) {
207
+ boundaryIndex = i;
208
+ break;
209
+ }
210
+ }
211
+ const effectiveHandlers = handlers.slice(boundaryIndex);
212
+ // Create the Web Request
213
+ const webRequest = nodeReqToWebRequest(req);
214
+ // Compose and execute middleware chain
215
+ let middlewareCalledNext = false;
216
+ const capturedHeaders = new Headers();
217
+ const executeChain = (index) => {
218
+ if (index >= effectiveHandlers.length) {
219
+ // End of chain — middleware called next(), page rendering should proceed
220
+ middlewareCalledNext = true;
221
+ return Promise.resolve(new Response(null, { status: 200 }));
222
+ }
223
+ const handler = effectiveHandlers[index];
224
+ return Promise.resolve(handler(webRequest, () => {
225
+ const nextResult = executeChain(index + 1);
226
+ return nextResult.then((response) => {
227
+ // Capture headers from inner middleware/next
228
+ response.headers.forEach((value, key) => {
229
+ capturedHeaders.set(key, value);
230
+ });
231
+ return response;
232
+ });
233
+ })).then((response) => {
234
+ // Capture headers from this middleware
235
+ response.headers.forEach((value, key) => {
236
+ capturedHeaders.set(key, value);
237
+ });
238
+ return response;
239
+ });
240
+ };
241
+ try {
242
+ const response = await executeChain(0);
243
+ if (!middlewareCalledNext) {
244
+ // Middleware short-circuited (returned a response without calling next)
245
+ return { shortCircuit: response, headers: capturedHeaders };
246
+ }
247
+ // Middleware called next() — proceed with page rendering
248
+ // but pass along any headers middleware added
249
+ return { shortCircuit: null, headers: capturedHeaders };
250
+ }
251
+ catch (err) {
252
+ server.config.logger.error(`[catmint] Middleware error: ${err}`);
253
+ return {
254
+ shortCircuit: new Response("Internal Server Error", { status: 500 }),
255
+ headers: new Headers(),
256
+ };
257
+ }
258
+ }
110
259
  /**
111
260
  * CSS file extensions that Vite processes.
112
261
  */
@@ -349,6 +498,52 @@ const SERVER_FN_PREFIX = "/__catmint/fn/";
349
498
  * RSC flight stream prefix for client-side navigation.
350
499
  */
351
500
  const RSC_NAVIGATION_PREFIX = "/__catmint/rsc";
501
+ // ---------------------------------------------------------------------------
502
+ // Server function error detection helpers (duck-typing)
503
+ //
504
+ // The dev-server cannot import from `catmint` directly (it's in the
505
+ // `@catmint/vite` package). Instead, we detect error types by checking
506
+ // for characteristic properties — this matches instances loaded at
507
+ // runtime via Vite's SSR module loader.
508
+ // ---------------------------------------------------------------------------
509
+ /**
510
+ * Check if a value looks like a ClientSafeError (duck-typing).
511
+ * Matches: instance has `name === "ClientSafeError"` OR has `statusCode`
512
+ * property and the constructor chain includes a class with name "ClientSafeError".
513
+ */
514
+ function isClientSafeErrorLike(value) {
515
+ if (!value || typeof value !== "object")
516
+ return false;
517
+ // Direct instance check by name (supports subclasses via prototype chain)
518
+ if (value instanceof Error && typeof value.statusCode === "number") {
519
+ let proto = Object.getPrototypeOf(value);
520
+ while (proto && proto !== Error.prototype) {
521
+ if (proto.constructor?.name === "ClientSafeError")
522
+ return true;
523
+ proto = Object.getPrototypeOf(proto);
524
+ }
525
+ }
526
+ return false;
527
+ }
528
+ /**
529
+ * Check if a value looks like a RedirectError (duck-typing).
530
+ */
531
+ function isRedirectErrorLike(value) {
532
+ if (!value || typeof value !== "object")
533
+ return false;
534
+ return (value instanceof Error &&
535
+ value.name === "RedirectError" &&
536
+ typeof value.url === "string" &&
537
+ typeof value.status === "number");
538
+ }
539
+ /**
540
+ * Generate a short correlation hash for error tracking.
541
+ * Used in production to link client error messages to server logs.
542
+ */
543
+ function errorRef(err) {
544
+ const content = `${Date.now()}:${err instanceof Error ? err.message : String(err)}`;
545
+ return createHash("sha256").update(content).digest("hex").slice(0, 8);
546
+ }
352
547
  /**
353
548
  * Handle a server function RPC call.
354
549
  *
@@ -441,17 +636,63 @@ async function handleServerFn(server, req, res, pathname) {
441
636
  // Call the server function
442
637
  try {
443
638
  const result = await matchedFn(input);
639
+ // Check if the result IS a ClientSafeError (returned, not thrown).
640
+ // Use duck-typing since we can't import from `catmint` directly.
641
+ if (isClientSafeErrorLike(result)) {
642
+ res.statusCode = 200;
643
+ res.setHeader("Content-Type", "application/json");
644
+ res.end(JSON.stringify({
645
+ __clientSafeError: true,
646
+ error: result.message,
647
+ statusCode: result.statusCode,
648
+ data: result.data,
649
+ }));
650
+ return;
651
+ }
444
652
  res.statusCode = 200;
445
653
  res.setHeader("Content-Type", "application/json");
446
654
  res.end(JSON.stringify(result));
447
655
  }
448
656
  catch (err) {
657
+ // Always log the full error server-side
449
658
  server.config.logger.error(`[catmint] Server function error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
450
- res.statusCode = 500;
451
659
  res.setHeader("Content-Type", "application/json");
452
- res.end(JSON.stringify({
453
- error: err instanceof Error ? err.message : "Server function error",
454
- }));
660
+ // RedirectError — send redirect envelope to client
661
+ if (isRedirectErrorLike(err)) {
662
+ res.statusCode = 200;
663
+ res.end(JSON.stringify({
664
+ __redirect: true,
665
+ url: err.url,
666
+ status: err.status,
667
+ }));
668
+ return;
669
+ }
670
+ // ClientSafeError — developer opted in, expose to client
671
+ if (isClientSafeErrorLike(err)) {
672
+ res.statusCode = err.statusCode;
673
+ res.end(JSON.stringify({
674
+ error: err.message,
675
+ data: err.data,
676
+ }));
677
+ return;
678
+ }
679
+ // Default: sanitize error — in dev mode, leak with warning;
680
+ // in prod mode, use generic message with correlation hash
681
+ const isDev = server.config.mode === "development" || !server.config.isProduction;
682
+ res.statusCode = 500;
683
+ if (isDev) {
684
+ const originalMessage = err instanceof Error ? err.message : String(err);
685
+ res.end(JSON.stringify({
686
+ error: `[DEV ONLY] ${originalMessage}`,
687
+ }));
688
+ }
689
+ else {
690
+ const ref = errorRef(err);
691
+ server.config.logger.error(`[catmint] Error reference [ref: ${ref}] — see above for full error`);
692
+ res.end(JSON.stringify({
693
+ error: `Internal Server Error [ref: ${ref}]`,
694
+ }));
695
+ }
455
696
  }
456
697
  }
457
698
  /**
@@ -894,6 +1135,28 @@ export function devServerPlugin(options) {
894
1135
  server = viteServer;
895
1136
  appDir = join(server.config.root, "app");
896
1137
  statusPages = scanStatusPages(appDir);
1138
+ // Invalidate route matchers when route files are added or removed.
1139
+ // Vite's handleHotUpdate only fires for files already in the module
1140
+ // graph, so newly created or deleted files need this watcher to
1141
+ // trigger a re-scan on the next request.
1142
+ const isRouteFile = (f) => f.includes("/app/") &&
1143
+ (f.endsWith("page.tsx") ||
1144
+ f.endsWith("page.mdx") ||
1145
+ f.endsWith("endpoint.ts"));
1146
+ server.watcher.on("add", (file) => {
1147
+ if (isRouteFile(file)) {
1148
+ pageMatcher = null;
1149
+ endpointMatcher = null;
1150
+ server.config.logger.info(`[catmint] Route file added: ${file} — invalidating route cache`);
1151
+ }
1152
+ });
1153
+ server.watcher.on("unlink", (file) => {
1154
+ if (isRouteFile(file)) {
1155
+ pageMatcher = null;
1156
+ endpointMatcher = null;
1157
+ server.config.logger.info(`[catmint] Route file removed: ${file} — invalidating route cache`);
1158
+ }
1159
+ });
897
1160
  return () => {
898
1161
  server.middlewares.use(async (req, res, next) => {
899
1162
  const url = req.originalUrl ?? req.url ?? "/";
@@ -962,6 +1225,17 @@ export function devServerPlugin(options) {
962
1225
  res.end(JSON.stringify({ error: "RSC not available" }));
963
1226
  return;
964
1227
  }
1228
+ // Execute middleware for the TARGET page path (not /__catmint/rsc)
1229
+ const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
1230
+ if (middlewareResult.shortCircuit) {
1231
+ // Middleware short-circuited (e.g., returned 401)
1232
+ await sendWebResponse(res, middlewareResult.shortCircuit);
1233
+ return;
1234
+ }
1235
+ // Apply middleware response headers (e.g., X-Response-Time)
1236
+ middlewareResult.headers.forEach((value, key) => {
1237
+ res.setHeader(key, value);
1238
+ });
965
1239
  await handleRscNavigation(server, req, res, match, appDir, i18nConfig);
966
1240
  }
967
1241
  catch (error) {
@@ -1016,6 +1290,19 @@ export function devServerPlugin(options) {
1016
1290
  await sendStatusPage(res, 404, url);
1017
1291
  return;
1018
1292
  }
1293
+ // --- Execute middleware chain for this route ---
1294
+ const routeDir = dirname(match.route.filePath);
1295
+ const middlewareResult = await executeMiddleware(server, req, match.route.filePath, appDir);
1296
+ if (middlewareResult.shortCircuit) {
1297
+ // Middleware short-circuited (e.g., returned 401)
1298
+ await sendWebResponse(res, middlewareResult.shortCircuit);
1299
+ return;
1300
+ }
1301
+ // Apply middleware response headers (e.g., X-Response-Time)
1302
+ // These will be set on the response before page rendering streams
1303
+ middlewareResult.headers.forEach((value, key) => {
1304
+ res.setHeader(key, value);
1305
+ });
1019
1306
  // Check if RSC environments are available
1020
1307
  if (hasRscEnvironments()) {
1021
1308
  await handlePageWithRsc(server, req, res, match, appDir, softNav, i18nConfig, sendStatusPage);
@@ -1051,10 +1338,8 @@ export function devServerPlugin(options) {
1051
1338
  handleHotUpdate({ file, server: hmrServer }) {
1052
1339
  if (file.includes("/app/") &&
1053
1340
  (file.endsWith("page.tsx") ||
1054
- file.endsWith("page.jsx") ||
1055
1341
  file.endsWith("page.mdx") ||
1056
- file.endsWith("endpoint.ts") ||
1057
- file.endsWith("endpoint.js"))) {
1342
+ file.endsWith("endpoint.ts"))) {
1058
1343
  pageMatcher = null;
1059
1344
  endpointMatcher = null;
1060
1345
  }
@@ -1064,7 +1349,7 @@ export function devServerPlugin(options) {
1064
1349
  // status page is picked up on the next error.
1065
1350
  if (file.includes("/app/")) {
1066
1351
  const basename = file.split("/").pop() ?? "";
1067
- if (/^\d{3}\.(tsx|jsx|ts|js)$/.test(basename)) {
1352
+ if (/^\d{3}\.(tsx|ts)$/.test(basename)) {
1068
1353
  statusPages = scanStatusPages(appDir);
1069
1354
  hmrServer.config.logger.info(`[catmint] Status page changed: ${basename} — triggering reload`);
1070
1355
  hmrServer.hot.send({ type: "full-reload", path: "*" });
@@ -1144,7 +1429,11 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
1144
1429
  // If a loading.tsx is found, wrap the page with a Suspense boundary.
1145
1430
  // If an error.tsx is found via walk-up resolution, wrap the page
1146
1431
  // with the error boundary component inside the nearest layout.
1147
- let element = createElement(PageComponent, null);
1432
+ const pageProps = {};
1433
+ if (match.params && Object.keys(match.params).length > 0) {
1434
+ pageProps.params = match.params;
1435
+ }
1436
+ let element = createElement(PageComponent, Object.keys(pageProps).length > 0 ? pageProps : null);
1148
1437
  // Resolve loading.tsx from the page directory for Suspense fallback
1149
1438
  const loadingPath = resolveLoadingComponent(match.route.filePath);
1150
1439
  if (loadingPath) {
@@ -1172,10 +1461,24 @@ async function handlePageWithRsc(server, req, res, match, appDir, softNavigation
1172
1461
  const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
1173
1462
  try {
1174
1463
  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 });
1464
+ const ErrorFallbackComponent = errorMod.default;
1465
+ if (ErrorFallbackComponent) {
1466
+ // Import the ErrorBoundary class component to properly catch errors
1467
+ const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
1468
+ const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
1469
+ errorBoundaryMod.default?.ErrorBoundary;
1470
+ if (ErrorBoundary) {
1471
+ element = createElement(ErrorBoundary, {
1472
+ fallback: ErrorFallbackComponent,
1473
+ children: element,
1474
+ });
1475
+ }
1476
+ else {
1477
+ // Fallback: wrap page with error fallback component directly
1478
+ element = createElement(ErrorFallbackComponent, {
1479
+ children: element,
1480
+ });
1481
+ }
1179
1482
  }
1180
1483
  }
1181
1484
  catch {
@@ -1348,7 +1651,11 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
1348
1651
  // If a loading.tsx is found, wrap the page with a Suspense boundary.
1349
1652
  // If an error.tsx is found via walk-up resolution, wrap the page
1350
1653
  // with the error boundary component inside the nearest layout.
1351
- let element = createElement(PageComponent, null);
1654
+ const navPageProps = {};
1655
+ if (match.params && Object.keys(match.params).length > 0) {
1656
+ navPageProps.params = match.params;
1657
+ }
1658
+ let element = createElement(PageComponent, Object.keys(navPageProps).length > 0 ? navPageProps : null);
1352
1659
  // Resolve loading.tsx from the page directory for Suspense fallback
1353
1660
  const loadingPath = resolveLoadingComponent(match.route.filePath);
1354
1661
  if (loadingPath) {
@@ -1376,10 +1683,24 @@ async function handleRscNavigation(server, _req, res, match, appDir, i18nConfig
1376
1683
  const errorBoundaryImportPath = "/" + relative(root, errorBoundaryPath);
1377
1684
  try {
1378
1685
  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 });
1686
+ const ErrorFallbackComponent = errorMod.default;
1687
+ if (ErrorFallbackComponent) {
1688
+ // Import the ErrorBoundary class component to properly catch errors
1689
+ const errorBoundaryMod = await rscEnv.runner.import("catmint/error");
1690
+ const ErrorBoundary = errorBoundaryMod.ErrorBoundary ??
1691
+ errorBoundaryMod.default?.ErrorBoundary;
1692
+ if (ErrorBoundary) {
1693
+ element = createElement(ErrorBoundary, {
1694
+ fallback: ErrorFallbackComponent,
1695
+ children: element,
1696
+ });
1697
+ }
1698
+ else {
1699
+ // Fallback: wrap page with error fallback component directly
1700
+ element = createElement(ErrorFallbackComponent, {
1701
+ children: element,
1702
+ });
1703
+ }
1383
1704
  }
1384
1705
  }
1385
1706
  catch {
@@ -1459,7 +1780,9 @@ async function handlePageLegacy(server, _req, res, match, appDir, hydrScripts, h
1459
1780
  }
1460
1781
  // Resolve generateMetadata exports from page + layout modules
1461
1782
  const headConfig = await resolveGenerateMetadata(pageMod, layoutMods, match.params ?? {}, url);
1462
- let element = React.createElement(PageComponent, null);
1783
+ let element = React.createElement(PageComponent, match.params && Object.keys(match.params).length > 0
1784
+ ? { params: match.params }
1785
+ : null);
1463
1786
  for (let i = layoutComponents.length - 1; i >= 0; i--) {
1464
1787
  element = React.createElement(layoutComponents[i], null, element);
1465
1788
  }
@@ -1629,6 +1952,31 @@ hydrate().then(() => {
1629
1952
  setupClientNavigation(root);
1630
1953
  }
1631
1954
  });
1955
+
1956
+ // HMR: When server components (page.tsx, layout.tsx) are edited, the RSC
1957
+ // plugin sends an "rsc:update" event. Re-fetch the flight stream for the
1958
+ // current page and re-render so the update appears without a full reload.
1959
+ if (import.meta.hot) {
1960
+ import.meta.hot.on("rsc:update", async () => {
1961
+ if (!root) return;
1962
+ try {
1963
+ var rscUrl = "/__catmint/rsc?path=" + encodeURIComponent(
1964
+ window.location.pathname + window.location.search
1965
+ );
1966
+ var response = await fetch(rscUrl);
1967
+ if (!response.ok) {
1968
+ window.location.reload();
1969
+ return;
1970
+ }
1971
+ var { createFromReadableStream } = await import("@vitejs/plugin-rsc/browser");
1972
+ var newRoot = await createFromReadableStream(response.body);
1973
+ root.render(newRoot);
1974
+ } catch (err) {
1975
+ console.error("[catmint] HMR update failed, reloading:", err);
1976
+ window.location.reload();
1977
+ }
1978
+ });
1979
+ }
1632
1980
  `;
1633
1981
  }
1634
1982
  // --------------------------------------------------------------------------