@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.
@@ -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,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 aliases = buildRscAliases();
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: aliases,
1247
+ alias: [...tsconfigAliases, ...rscAliases],
846
1248
  },
847
1249
  ssr: {
848
- external: ["catmint/routing", "catmint/status"],
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
- // --- Server function RPC handling ---
923
- const pathname = url.split("?")[0];
924
- if (pathname.startsWith(SERVER_FN_PREFIX)) {
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
- await handleServerFn(server, req, res, pathname);
1434
+ devPlatform = await initPromise;
927
1435
  }
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
- }
1436
+ catch {
1437
+ // Already logged during init
936
1438
  }
937
- return;
938
1439
  }
939
- // --- RSC flight stream for client-side navigation ---
940
- if (pathname === RSC_NAVIGATION_PREFIX) {
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
- 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);
1445
+ platform = await devPlatform.getPlatform(webRequest, req, res);
966
1446
  }
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
- }
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
- 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;
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
- if (hasHandler) {
1002
- return await handleEndpoint(server, req, res, endpointMatch, method);
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
- 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`);
1486
+ flushHeaders();
1008
1487
  return;
1009
1488
  }
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);
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
- // 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);
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
- 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
- });
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|jsx|ts|js)$/.test(basename)) {
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
- let element = createElement(PageComponent, null);
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 ErrorBoundaryComponent = errorMod.default;
1176
- if (ErrorBoundaryComponent) {
1177
- // Wrap page with error boundary it renders inside the nearest layout
1178
- element = createElement(ErrorBoundaryComponent, { children: element });
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
- let element = createElement(PageComponent, null);
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 ErrorBoundaryComponent = errorMod.default;
1380
- if (ErrorBoundaryComponent) {
1381
- // Wrap page with error boundary it renders inside the nearest layout
1382
- element = createElement(ErrorBoundaryComponent, { children: element });
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, null);
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
  // --------------------------------------------------------------------------