swift-rust 1.0.1 → 1.2.1

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, statSync, readFileSync, readdirSync, watch as fsWatch } from "node:fs";
3
- import { join, resolve, extname, relative, dirname, sep } from "node:path";
2
+ import { existsSync, statSync, readFileSync, readdirSync, writeFileSync, unlinkSync, watch as fsWatch } from "node:fs";
3
+ import { join, resolve, extname, relative, dirname, basename, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { errorOverlayHTML as renderErrorOverlay } from "./error-overlay.mjs";
@@ -17,7 +17,7 @@ function getArg(name, fallback) {
17
17
  return fallback;
18
18
  }
19
19
 
20
- const port = parseInt(getArg("port", process.env.PORT || "3000"), 10);
20
+ const port = parseInt(getArg("port", process.env.PORT || "3210"), 10);
21
21
  const hostname = getArg("hostname", "0.0.0.0");
22
22
 
23
23
  const c = {
@@ -36,13 +36,20 @@ const useColor = process.stdout.isTTY !== false && !process.env.NO_COLOR;
36
36
  const paint = (color, s) => (useColor ? `${c[color]}${s}${c.reset}` : s);
37
37
 
38
38
  const VERSION = "0.1.0";
39
- const APP_DIR_CANDIDATES = [resolve(cwd, "app", "src"), resolve(cwd, "app")];
39
+ const APP_DIR_CANDIDATES = [resolve(cwd, "src", "app"), resolve(cwd, "app")];
40
40
  const APP_DIR = APP_DIR_CANDIDATES.find((p) => existsSync(p)) ?? resolve(cwd, "app");
41
41
  const PUBLIC_DIR = resolve(cwd, "public");
42
42
  const SWIFT_RUST_CONFIG = resolve(cwd, "swift-rust.config.json");
43
43
 
44
44
  const PAGE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
45
- const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found", "template", "default", "route"]);
45
+ const SPECIAL_FILES = new Set([
46
+ "layout", "loading", "error", "not-found", "template", "default", "route",
47
+ // RFC 0001 routing files
48
+ "guard", "loader", "action", "config", "schema", "proxy", "pending",
49
+ "revalidate", "shell", "fragment", "transition", "fallback", "prefetch",
50
+ "error-recovery", "i18n", "rpc", "stream", "edge", "worker", "query",
51
+ "state", "seo", "variant", "global-error",
52
+ ]);
46
53
 
47
54
  const moduleCache = new Map();
48
55
  const compileTimings = new Map();
@@ -148,22 +155,22 @@ function findFile(dir, basename) {
148
155
  function resolvePageRoute(segments) {
149
156
  if (segments.length === 0) {
150
157
  const file = findFile(APP_DIR, "page");
151
- if (file) return { file, params: {}, segments: [] };
158
+ if (file) return { file, params: {}, segments: [], dirChain: [APP_DIR] };
152
159
  return null;
153
160
  }
154
- return resolveRoute(APP_DIR, segments, 0, {});
161
+ return resolveRoute(APP_DIR, segments, 0, {}, [APP_DIR]);
155
162
  }
156
163
 
157
- function resolveRoute(dir, segments, idx, params) {
164
+ function resolveRoute(dir, segments, idx, params, chain) {
158
165
  if (idx === segments.length) {
159
166
  const file = findFile(dir, "page");
160
- if (file) return { file, params, segments: segments.slice(0, idx) };
167
+ if (file) return { file, params, segments: segments.slice(0, idx), dirChain: chain };
161
168
  return null;
162
169
  }
163
170
  const seg = segments[idx];
164
171
  const directDir = join(dir, seg);
165
172
  if (existsSync(directDir) && statSync(directDir).isDirectory()) {
166
- const result = resolveRoute(directDir, segments, idx + 1, params);
173
+ const result = resolveRoute(directDir, segments, idx + 1, params, [...chain, directDir]);
167
174
  if (result) return result;
168
175
  }
169
176
  if (existsSync(dir) && statSync(dir).isDirectory()) {
@@ -175,7 +182,7 @@ function resolveRoute(dir, segments, idx, params) {
175
182
  if (paramName.startsWith("...")) continue;
176
183
  const paramDir = join(dir, e.name);
177
184
  const newParams = { ...params, [paramName]: seg };
178
- const result = resolveRoute(paramDir, segments, idx + 1, newParams);
185
+ const result = resolveRoute(paramDir, segments, idx + 1, newParams, [...chain, paramDir]);
179
186
  if (result) return result;
180
187
  }
181
188
  }
@@ -183,6 +190,42 @@ function resolveRoute(dir, segments, idx, params) {
183
190
  return null;
184
191
  }
185
192
 
193
+ /** Resolve the leaf directory for URL segments (incl. dynamic), even when the
194
+ * segment has no page.tsx — used for stream.ts / rpc.ts handlers. */
195
+ function resolveLeafDir(segments) {
196
+ return walkLeafDir(APP_DIR, segments, 0, {});
197
+ }
198
+ function walkLeafDir(dir, segments, idx, params) {
199
+ if (idx === segments.length) return { dir, params };
200
+ const seg = segments[idx];
201
+ const directDir = join(dir, seg);
202
+ if (existsSync(directDir) && statSync(directDir).isDirectory()) {
203
+ const r = walkLeafDir(directDir, segments, idx + 1, params);
204
+ if (r) return r;
205
+ }
206
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
207
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
208
+ if (e.isDirectory() && e.name.startsWith("[") && e.name.endsWith("]") && !e.name.includes("...")) {
209
+ const pn = e.name.slice(1, -1);
210
+ const r = walkLeafDir(join(dir, e.name), segments, idx + 1, { ...params, [pn]: seg });
211
+ if (r) return r;
212
+ }
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /** Collect a routing file (guard/loader/action/config/schema/…) along the
219
+ * matched directory chain, outermost → innermost. */
220
+ function collectRouteFiles(dirChain, basename) {
221
+ const out = [];
222
+ for (const dir of dirChain || []) {
223
+ const f = findFile(dir, basename);
224
+ if (f) out.push({ file: f, dir });
225
+ }
226
+ return out;
227
+ }
228
+
186
229
  function resolveApiRoute(segments) {
187
230
  if (segments.length === 0 || segments[0] !== "api") return null;
188
231
  const apiDir = join(APP_DIR, "api");
@@ -256,6 +299,16 @@ function findLoading(segments) {
256
299
  return findFile(APP_DIR, "loading");
257
300
  }
258
301
 
302
+ /** Generic: find the nearest `<basename>` file walking the URL segments up. */
303
+ function findRouteFileUp(segments, basename) {
304
+ for (let i = segments?.length ?? 0; i >= 0; i--) {
305
+ const dir = i === 0 ? APP_DIR : join(APP_DIR, ...segments.slice(0, i));
306
+ const file = findFile(dir, basename);
307
+ if (file) return file;
308
+ }
309
+ return findFile(APP_DIR, basename);
310
+ }
311
+
259
312
  function findRouteSegmentsForDir(dir) {
260
313
  const rel = relative(APP_DIR, dir);
261
314
  if (rel === "" || rel === ".") return [];
@@ -271,6 +324,57 @@ function bustCache(file) {
271
324
  }
272
325
  }
273
326
 
327
+ // Bumped on every file change. Used to invalidate the per-render SSR bundles
328
+ // below so that edits to *transitively imported* modules (components/, lib/)
329
+ // are picked up — Bun's module cache is path-keyed and ignores ?query busting,
330
+ // so a plain re-import of the page would still serve stale child modules.
331
+ let buildGeneration = 1;
332
+
333
+ // Externalize everything that isn't the app's own source (relative imports and
334
+ // the @/ alias). React and the framework stay shared/cached; only app code is
335
+ // re-bundled, so every render reflects the latest component/lib edits.
336
+ const externalizeDepsPlugin = {
337
+ name: "sr-externalize-deps",
338
+ setup(build) {
339
+ build.onResolve({ filter: /.*/ }, (args) => {
340
+ const p = args.path;
341
+ // App's own source (relative, absolute, or the @/ alias) → bundle fresh.
342
+ if (p.startsWith(".") || p.startsWith("/") || p.startsWith("@/")) return undefined;
343
+ // Everything else (react, swift-rust, node:*, @scope/*) → keep external.
344
+ return { path: p, external: true };
345
+ });
346
+ },
347
+ };
348
+
349
+ const ssrBundleCache = new Map(); // file -> { gen, mod }
350
+
351
+ async function loadModuleFresh(filePath) {
352
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
353
+ return loadModule(filePath, { bust: true });
354
+ }
355
+ const cached = ssrBundleCache.get(filePath);
356
+ if (cached && cached.gen === buildGeneration) return cached.mod;
357
+
358
+ const result = await Bun.build({
359
+ entrypoints: [filePath],
360
+ target: "bun",
361
+ plugins: [externalizeDepsPlugin],
362
+ });
363
+ if (!result.success) {
364
+ throw new Error(result.logs.map((l) => l.message).join("\n"));
365
+ }
366
+ const code = await result.outputs[0].text();
367
+ const tmp = join(dirname(filePath), `.__sr_ssr_${buildGeneration}_${Math.random().toString(36).slice(2)}.mjs`);
368
+ writeFileSync(tmp, code);
369
+ try {
370
+ const mod = await import(pathToFileURL(tmp).href);
371
+ ssrBundleCache.set(filePath, { gen: buildGeneration, mod });
372
+ return mod;
373
+ } finally {
374
+ try { unlinkSync(tmp); } catch {}
375
+ }
376
+ }
377
+
274
378
  async function loadModule(filePath, { bust = false } = {}) {
275
379
  const baseUrl = pathToFileURL(filePath).href;
276
380
  const url = bust ? `${baseUrl}?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : baseUrl;
@@ -513,6 +617,7 @@ function mergeMetadata(...metas) {
513
617
  if (m.description) out.description = m.description;
514
618
  if (m.keywords) out.keywords = m.keywords;
515
619
  if (m.openGraph) out.openGraph = { ...(out.openGraph || {}), ...m.openGraph };
620
+ if (m.twitter) out.twitter = { ...(out.twitter || {}), ...m.twitter };
516
621
  }
517
622
  return out;
518
623
  }
@@ -537,6 +642,24 @@ function metadataToHead(meta) {
537
642
  for (const img of meta.openGraph.images) {
538
643
  const url = typeof img === "string" ? img : img.url;
539
644
  if (url) parts.push(`<meta property="og:image" content="${escapeHtml(url)}" />`);
645
+ if (typeof img === "object" && img) {
646
+ if (img.width) parts.push(`<meta property="og:image:width" content="${escapeHtml(img.width)}" />`);
647
+ if (img.height) parts.push(`<meta property="og:image:height" content="${escapeHtml(img.height)}" />`);
648
+ if (img.alt) parts.push(`<meta property="og:image:alt" content="${escapeHtml(img.alt)}" />`);
649
+ }
650
+ }
651
+ }
652
+ }
653
+ if (meta.twitter) {
654
+ if (meta.twitter.card) parts.push(`<meta name="twitter:card" content="${escapeHtml(meta.twitter.card)}" />`);
655
+ if (meta.twitter.site) parts.push(`<meta name="twitter:site" content="${escapeHtml(meta.twitter.site)}" />`);
656
+ if (meta.twitter.creator) parts.push(`<meta name="twitter:creator" content="${escapeHtml(meta.twitter.creator)}" />`);
657
+ if (meta.twitter.title) parts.push(`<meta name="twitter:title" content="${escapeHtml(meta.twitter.title)}" />`);
658
+ if (meta.twitter.description) parts.push(`<meta name="twitter:description" content="${escapeHtml(meta.twitter.description)}" />`);
659
+ if (meta.twitter.images) {
660
+ for (const img of meta.twitter.images) {
661
+ const url = typeof img === "string" ? img : img.url;
662
+ if (url) parts.push(`<meta name="twitter:image" content="${escapeHtml(url)}" />`);
540
663
  }
541
664
  }
542
665
  }
@@ -605,7 +728,405 @@ async function renderToStringCompat(tree) {
605
728
  return renderToString(tree);
606
729
  }
607
730
 
608
- async function renderRoute(urlPath) {
731
+ // ── Client islands: page-level "use client" hydration ──────────────────────
732
+ // A page whose first meaningful line is `"use client"` is bundled for the
733
+ // browser and hydrated, so interactive components (e.g. the Pdf viewer) run.
734
+ // Static pages are untouched and ship zero JS.
735
+
736
+ const islandBundleCache = new Map(); // pageFile -> { mtime, code }
737
+
738
+ export function isClientPage(file) {
739
+ try {
740
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
741
+ const t = raw.trim();
742
+ if (!t || t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
743
+ return /^["']use client["'];?$/.test(t);
744
+ }
745
+ } catch {}
746
+ return false;
747
+ }
748
+
749
+ async function buildIslandBundle(pageFile) {
750
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
751
+ throw new Error("client islands require the Bun runtime");
752
+ }
753
+ const cached = islandBundleCache.get(pageFile);
754
+ if (cached && cached.gen === buildGeneration) return cached.code;
755
+
756
+ // Temp hydration entry, written next to the page so module resolution works.
757
+ const entryPath = join(dirname(pageFile), `.__sr_island_${process.pid}_${Date.now()}.js`);
758
+ const entry = `import { createRoot } from "react-dom/client";
759
+ import { createElement } from "react";
760
+ import Page from ${JSON.stringify(pageFile)};
761
+ const el = document.getElementById("__sr_island_root");
762
+ if (el) {
763
+ let params = {};
764
+ try { params = JSON.parse(el.getAttribute("data-sr-params") || "{}"); } catch {}
765
+ // Client-render (not hydrate): "use client" pages contain client-only
766
+ // widgets (e.g. the pdf.js viewer) whose SSR output intentionally differs
767
+ // from the first client render, so hydration would mismatch.
768
+ el.innerHTML = "";
769
+ createRoot(el).render(createElement(Page, { params }));
770
+ }
771
+ `;
772
+ writeFileSync(entryPath, entry);
773
+ try {
774
+ const result = await Bun.build({
775
+ entrypoints: [entryPath],
776
+ target: "browser",
777
+ minify: true,
778
+ define: { "process.env.NODE_ENV": '"production"' },
779
+ });
780
+ if (!result.success) {
781
+ throw new Error(result.logs.map((l) => l.message).join("\n"));
782
+ }
783
+ const code = await result.outputs[0].text();
784
+ islandBundleCache.set(pageFile, { gen: buildGeneration, code });
785
+ return code;
786
+ } finally {
787
+ try { unlinkSync(entryPath); } catch {}
788
+ }
789
+ }
790
+
791
+ // ── Routing-file error handling & convention validation ─────────────────────
792
+
793
+ class RoutingFileError extends Error {
794
+ constructor(kind, file, cause) {
795
+ super(`Error in ${kind} (${relative(cwd, file)}): ${cause?.message ?? cause}`);
796
+ this.name = "RoutingFileError";
797
+ this.kind = kind;
798
+ this.file = file;
799
+ this.cause = cause;
800
+ if (cause?.stack) this.stack = cause.stack;
801
+ }
802
+ }
803
+
804
+ const warnedRouting = new Set();
805
+ function warnRoutingFile(file, msg) {
806
+ const key = `${file}::${msg}`;
807
+ if (warnedRouting.has(key)) return;
808
+ warnedRouting.add(key);
809
+ process.stderr.write(` ${paint("yellow", "⚠")} ${paint("dim", "[routing]")} ${msg}\n`);
810
+ }
811
+
812
+ /** Warn when routing files are placed where the router will never find them
813
+ * (e.g. proxy.ts at the project root, or routing files in app/ when the app
814
+ * uses an app/src/ directory). Helps catch a very common mistake early. */
815
+ function validateRoutingConventions() {
816
+ const names = [...SPECIAL_FILES, "page"];
817
+ const appRel = relative(cwd, APP_DIR) || "app";
818
+ const srcRoot = dirname(APP_DIR);
819
+ const usesSrc = basename(APP_DIR) === "app" && basename(srcRoot) === "src";
820
+ const scan = (dir, label, allow = []) => {
821
+ if (!existsSync(dir) || resolve(dir) === resolve(APP_DIR)) return;
822
+ for (const name of names) {
823
+ if (allow.includes(name)) continue;
824
+ const f = findFile(dir, name);
825
+ if (f) {
826
+ warnRoutingFile(
827
+ f,
828
+ `"${name}" found in ${label} — routing files belong under "${appRel}/". ` +
829
+ `Move ${relative(cwd, f)} into ${appRel}/ (the router only scans there).`,
830
+ );
831
+ }
832
+ }
833
+ };
834
+ scan(cwd, "the project root");
835
+ // proxy.ts is allowed to live at the src/ root (sibling of src/app).
836
+ if (usesSrc) scan(srcRoot, `"src/" (outside "app/")`, ["proxy"]);
837
+ }
838
+
839
+ // ── RFC 0001 route pipeline: config → schema → guard → loader/action ────────
840
+
841
+ let routerRuntimePromise = null;
842
+ function routerRuntime() {
843
+ if (!routerRuntimePromise) routerRuntimePromise = import("swift-rust/router").catch(() => null);
844
+ return routerRuntimePromise;
845
+ }
846
+
847
+ function parseCookieHeader(header) {
848
+ const map = new Map();
849
+ for (const part of (header || "").split(";")) {
850
+ const i = part.indexOf("=");
851
+ if (i < 0) continue;
852
+ const k = part.slice(0, i).trim();
853
+ if (k) map.set(k, decodeURIComponent(part.slice(i + 1).trim()));
854
+ }
855
+ return map;
856
+ }
857
+ function serializeCookie(name, value, opts = {}) {
858
+ let s = `${name}=${encodeURIComponent(value)}`;
859
+ if (opts.maxAge != null) s += `; Max-Age=${opts.maxAge}`;
860
+ if (opts.path) s += `; Path=${opts.path}`;
861
+ else s += "; Path=/";
862
+ if (opts.domain) s += `; Domain=${opts.domain}`;
863
+ if (opts.httpOnly) s += "; HttpOnly";
864
+ if (opts.secure) s += "; Secure";
865
+ if (opts.sameSite) s += `; SameSite=${opts.sameSite[0].toUpperCase()}${opts.sameSite.slice(1)}`;
866
+ return s;
867
+ }
868
+
869
+ function buildRouteCtx(req, url, params, searchParams) {
870
+ const cookieMap = parseCookieHeader(req?.headers?.get?.("cookie") || "");
871
+ const setCookies = [];
872
+ const cookies = {
873
+ get: (n) => cookieMap.get(n),
874
+ set: (n, v, o) => { cookieMap.set(n, v); setCookies.push(serializeCookie(n, v, o)); },
875
+ delete: (n, o) => { cookieMap.delete(n); setCookies.push(serializeCookie(n, "", { ...o, maxAge: 0 })); },
876
+ all: () => cookieMap,
877
+ };
878
+ const localsMap = new Map();
879
+ return {
880
+ url,
881
+ method: req?.method || "GET",
882
+ headers: req?.headers || new Headers(),
883
+ cookies,
884
+ params,
885
+ searchParams,
886
+ runtime: "node",
887
+ locals: { get: (k) => localsMap.get(k), set: (k, v) => localsMap.set(k, v) },
888
+ request: req,
889
+ __setCookies: setCookies,
890
+ };
891
+ }
892
+
893
+ // Turn a returned RouteControl object into a thrown control the catch handles.
894
+ function applyControl(c) {
895
+ if (!c || typeof c !== "object" || !c.kind) return;
896
+ if (c.kind === "next") return;
897
+ const e = new Error(`route control: ${c.kind}`);
898
+ if (c.kind === "redirect") e.digest = `REDIRECT;${c.status || 307};${c.to}`;
899
+ else if (c.kind === "rewrite") e.digest = `REWRITE;${c.to}`;
900
+ else if (c.kind === "notFound") e.digest = "NOT_FOUND";
901
+ else if (c.kind === "response") e.__response = c.response;
902
+ else if (c.kind === "error") { throw c.error ?? new Error("route error"); }
903
+ throw e;
904
+ }
905
+
906
+ /** Merge config.ts along the chain (inner overrides outer). */
907
+ async function readMergedConfig(chain) {
908
+ let config = {};
909
+ for (const { file } of collectRouteFiles(chain, "config")) {
910
+ const mod = await loadModuleFresh(file);
911
+ const c = mod.config ?? mod.default;
912
+ if (c && typeof c === "object") config = { ...config, ...c, headers: { ...config.headers, ...c.headers } };
913
+ }
914
+ // edge.ts / worker.ts force a runtime.
915
+ const edge = await collectFirst(chain, "edge");
916
+ if (edge && (edge.edge || edge.default)) config.runtime = "edge";
917
+ const worker = await collectFirst(chain, "worker");
918
+ if (worker && (worker.default || worker.bindings)) config.runtime = "worker";
919
+ return config;
920
+ }
921
+
922
+ async function runRoutePipeline(route, ctx) {
923
+ const chain = route.dirChain || [];
924
+
925
+ // proxy.ts — phase 1, outer → inner. Cheap, data-free interception
926
+ // (Next.js calls this "proxy"; "middleware" is accepted with a warning).
927
+ // When using a src/ directory, a root proxy lives at src/proxy.ts (sibling
928
+ // of src/app), so it runs before any in-app proxy.
929
+ const proxyFiles = [];
930
+ const srcRoot = dirname(APP_DIR);
931
+ if (basename(APP_DIR) === "app" && basename(srcRoot) === "src") {
932
+ const rootProxy = findFile(srcRoot, "proxy");
933
+ if (rootProxy) proxyFiles.push({ file: rootProxy, dir: srcRoot });
934
+ }
935
+ proxyFiles.push(...collectRouteFiles(chain, "proxy"));
936
+ for (const { file } of proxyFiles) {
937
+ const mod = await loadModuleFresh(file);
938
+ const fn = mod.default ?? mod.proxy;
939
+ if (typeof fn === "function") {
940
+ const matcher = mod.matcher;
941
+ const matched = !matcher || matchesMatcher(ctx.url.pathname, matcher);
942
+ if (matched) {
943
+ try {
944
+ applyControl(await fn(ctx));
945
+ } catch (e) {
946
+ if (e?.digest || e?.__response) throw e; // control flow, re-throw
947
+ throw new RoutingFileError("proxy", file, e);
948
+ }
949
+ }
950
+ }
951
+ }
952
+ // Back-compat: warn if the old name is used.
953
+ for (const { file } of collectRouteFiles(chain, "middleware")) {
954
+ warnRoutingFile(file, `"middleware.ts" was renamed to "proxy.ts" — rename ${relative(cwd, file)}.`);
955
+ const mod = await loadModuleFresh(file);
956
+ const fn = mod.default ?? mod.middleware;
957
+ if (typeof fn === "function") {
958
+ const matcher = mod.matcher;
959
+ if (!matcher || matchesMatcher(ctx.url.pathname, matcher)) applyControl(await fn(ctx));
960
+ }
961
+ }
962
+
963
+ // config.ts — merged, applied to the response by the caller.
964
+ const config = await readMergedConfig(chain);
965
+
966
+ // i18n.ts — resolve the active locale into locals (cookie/header/default).
967
+ const i18nMod = await collectFirst(chain, "i18n");
968
+ const i18nCfg = i18nMod?.i18n ?? i18nMod?.default;
969
+ if (i18nCfg && Array.isArray(i18nCfg.locales)) {
970
+ let locale = i18nCfg.defaultLocale ?? i18nCfg.locales[0];
971
+ if (typeof i18nCfg.resolve === "function") locale = (await i18nCfg.resolve(ctx)) || locale;
972
+ else if (i18nCfg.strategy === "cookie") locale = ctx.cookies.get("locale") || locale;
973
+ else if (i18nCfg.strategy === "header") {
974
+ const al = ctx.headers.get?.("accept-language")?.split(",")[0]?.split("-")[0];
975
+ if (al && i18nCfg.locales.includes(al)) locale = al;
976
+ }
977
+ ctx.locals.set("locale", locale);
978
+ ctx.__locale = locale;
979
+ }
980
+
981
+ // schema.ts — validate/brand params + searchParams (Standard-Schema/Zod-like)
982
+ for (const { file } of collectRouteFiles(chain, "schema")) {
983
+ const mod = await loadModuleFresh(file);
984
+ if (mod.params?.safeParse) {
985
+ const r = mod.params.safeParse(ctx.params);
986
+ if (!r.success) { const e = new Error("Invalid params"); e.__response = jsonResponse({ error: "Invalid params", issues: r.error?.issues ?? r.error }, 400); throw e; }
987
+ Object.assign(ctx.params, r.data);
988
+ }
989
+ const querySpec = (await collectFirst(chain, "query"))?.query;
990
+ const searchSchema = querySpec?.parse ?? mod.searchParams;
991
+ if (searchSchema?.safeParse) {
992
+ const r = searchSchema.safeParse(ctx.searchParams);
993
+ if (r.success) Object.assign(ctx.searchParams, r.data);
994
+ }
995
+ }
996
+
997
+ // guard.ts — outer → inner
998
+ for (const { file } of collectRouteFiles(chain, "guard")) {
999
+ const mod = await loadModuleFresh(file);
1000
+ const fn = mod.default ?? mod.guard;
1001
+ if (typeof fn === "function") {
1002
+ try {
1003
+ applyControl(await fn(ctx));
1004
+ } catch (e) {
1005
+ if (e?.digest || e?.__response) throw e;
1006
+ throw new RoutingFileError("guard", file, e);
1007
+ }
1008
+ } else if (mod.default !== undefined || mod.guard !== undefined) {
1009
+ warnRoutingFile(file, `guard.ts must export a function (default export). ${relative(cwd, file)}`);
1010
+ }
1011
+ }
1012
+
1013
+ let actionData;
1014
+ // action.ts on mutating requests (leaf only)
1015
+ if (ctx.method !== "GET" && ctx.method !== "HEAD") {
1016
+ const actions = collectRouteFiles(chain, "action");
1017
+ const leaf = actions[actions.length - 1];
1018
+ if (leaf) {
1019
+ const mod = await loadModuleFresh(leaf.file);
1020
+ const fn = mod.default ?? mod.action;
1021
+ if (typeof fn === "function") {
1022
+ const actx = Object.assign({}, ctx, {
1023
+ formData: () => ctx.request.formData(),
1024
+ json: () => ctx.request.json(),
1025
+ });
1026
+ try {
1027
+ const result = await fn(actx);
1028
+ applyControl(result);
1029
+ actionData = result;
1030
+ } catch (e) {
1031
+ if (e?.digest || e?.__response) throw e;
1032
+ throw new RoutingFileError("action", leaf.file, e);
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ // loader.ts — run in parallel along the chain
1039
+ const loaders = collectRouteFiles(chain, "loader");
1040
+ const loaded = await Promise.all(
1041
+ loaders.map(async ({ file, dir }) => {
1042
+ const mod = await loadModuleFresh(file);
1043
+ const fn = mod.default ?? mod.loader;
1044
+ if (typeof fn !== "function") return [dir, undefined];
1045
+ const lctx = Object.assign({}, ctx, { parent: () => undefined });
1046
+ try {
1047
+ return [dir, await fn(lctx)];
1048
+ } catch (e) {
1049
+ if (e?.digest || e?.__response) throw e;
1050
+ throw new RoutingFileError("loader", file, e);
1051
+ }
1052
+ }),
1053
+ );
1054
+ const loadersMap = {};
1055
+ for (const [dir, data] of loaded) loadersMap[relative(cwd, dir)] = data;
1056
+ const loaderData = loaded.length ? loaded[loaded.length - 1][1] : undefined;
1057
+
1058
+ // state.ts — server-side state to hydrate a client store.
1059
+ let serverState;
1060
+ const stateMod = await collectFirst(chain, "state");
1061
+ const stateFn = stateMod?.default ?? stateMod?.state;
1062
+ if (typeof stateFn === "function") serverState = await stateFn(ctx);
1063
+
1064
+ // seo.tsx — structured data / head injection (has loader data).
1065
+ let seoHead = "";
1066
+ const seoMod = await collectFirst(chain, "seo");
1067
+ const seoFn = seoMod?.default ?? seoMod?.seo;
1068
+ if (typeof seoFn === "function") {
1069
+ seoHead = buildSeoHead(await seoFn(Object.assign({}, ctx, { data: loaderData })));
1070
+ }
1071
+
1072
+ // revalidate.ts — leaf decides cache TTL / tags.
1073
+ let revalidatePlan = config.revalidate != null ? { ttl: config.revalidate } : undefined;
1074
+ const rev = await collectFirst(chain, "revalidate");
1075
+ const revFn = rev?.default ?? rev?.revalidate;
1076
+ if (typeof revFn === "function") {
1077
+ const plan = await revFn(Object.assign({}, ctx, { data: loaderData, afterAction: ctx.method !== "GET" }));
1078
+ if (plan && typeof plan === "object") revalidatePlan = { ...revalidatePlan, ...plan };
1079
+ }
1080
+
1081
+ return { actionData, loaderData, loaders: loadersMap, setCookies: ctx.__setCookies, config, revalidatePlan, serverState, seoHead };
1082
+ }
1083
+
1084
+ function escAttr(s) {
1085
+ return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
1086
+ }
1087
+ function buildSeoHead(r) {
1088
+ if (!r || typeof r !== "object") return "";
1089
+ const out = [];
1090
+ if (r.title) out.push(`<title>${escAttr(r.title)}</title>`);
1091
+ if (r.description) out.push(`<meta name="description" content="${escAttr(r.description)}" />`);
1092
+ if (r.canonical) out.push(`<link rel="canonical" href="${escAttr(r.canonical)}" />`);
1093
+ if (r.robots) out.push(`<meta name="robots" content="${escAttr(r.robots)}" />`);
1094
+ for (const [k, v] of Object.entries(r.openGraph || {})) out.push(`<meta property="og:${escAttr(k)}" content="${escAttr(v)}" />`);
1095
+ for (const a of r.alternates || []) out.push(`<link rel="alternate" hreflang="${escAttr(a.hreflang)}" href="${escAttr(a.href)}" />`);
1096
+ const jsonLd = r.jsonLd ? (Array.isArray(r.jsonLd) ? r.jsonLd : [r.jsonLd]) : [];
1097
+ for (const ld of jsonLd) out.push(`<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, "\\u003c")}</script>`);
1098
+ return out.join("\n");
1099
+ }
1100
+
1101
+ async function collectFirst(chain, basename) {
1102
+ const files = collectRouteFiles(chain, basename);
1103
+ if (files.length === 0) return null;
1104
+ return await loadModuleFresh(files[files.length - 1].file);
1105
+ }
1106
+
1107
+ function matchesMatcher(pathname, matcher) {
1108
+ const list = Array.isArray(matcher) ? matcher : [matcher];
1109
+ return list.some((m) => {
1110
+ if (typeof m !== "string") return false;
1111
+ // simple glob: * → [^/]*, ** → .*
1112
+ const re = new RegExp("^" + m.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::").replace(/\*/g, "[^/]*").replace(/::/g, ".*") + "$");
1113
+ return re.test(pathname);
1114
+ });
1115
+ }
1116
+
1117
+ function jsonResponse(obj, status = 200) {
1118
+ return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
1119
+ }
1120
+
1121
+ function cacheControlFromPlan(plan) {
1122
+ if (!plan) return null;
1123
+ if (plan.ttl === false) return "public, max-age=31536000, immutable";
1124
+ if (plan.ttl === 0) return "no-store";
1125
+ if (typeof plan.ttl === "number") return `public, max-age=0, s-maxage=${plan.ttl}, stale-while-revalidate`;
1126
+ return null;
1127
+ }
1128
+
1129
+ async function renderRoute(urlPath, req) {
609
1130
  const segments = urlToRouteSegments(urlPath);
610
1131
  const route = resolvePageRoute(segments);
611
1132
  if (!route) {
@@ -618,8 +1139,48 @@ async function renderRoute(urlPath) {
618
1139
 
619
1140
  try {
620
1141
  const React = await import("react");
621
- const pageMod = await loadModule(route.file, { bust: true });
622
- const Page = pageMod.default ?? pageMod.Page ?? pageMod.page;
1142
+ const url = req ? new URL(req.url) : new URL(urlPath, "http://localhost");
1143
+ const searchParams = Object.fromEntries(url.searchParams.entries());
1144
+ const ctx = buildRouteCtx(req, url, { ...route.params }, searchParams);
1145
+
1146
+ // Run the RFC 0001 pipeline (config/schema/guard/loader/action).
1147
+ const pipeline = await runRoutePipeline(route, ctx);
1148
+ route.params = ctx.params; // validated/branded params flow to the page
1149
+
1150
+ const runtime = await routerRuntime();
1151
+ if (runtime?.__setRouteContext) {
1152
+ runtime.__setRouteContext({
1153
+ request: ctx,
1154
+ loaderData: pipeline.loaderData,
1155
+ actionData: pipeline.actionData,
1156
+ loaders: pipeline.loaders,
1157
+ });
1158
+ }
1159
+
1160
+ // variant.tsx — pick an A/B variant component (bucket from middleware/
1161
+ // cookie/assign), else fall back to page.tsx.
1162
+ let Page;
1163
+ const leafDir = (route.dirChain || [])[(route.dirChain || []).length - 1];
1164
+ const variantFile = leafDir && findFile(leafDir, "variant");
1165
+ if (variantFile) {
1166
+ const vmod = await loadModuleFresh(variantFile);
1167
+ if (vmod.variants && typeof vmod.variants === "object") {
1168
+ const bucket =
1169
+ (typeof vmod.assign === "function" ? vmod.assign(ctx) : null) ||
1170
+ ctx.locals.get("bucket") ||
1171
+ ctx.cookies.get("bucket") ||
1172
+ Object.keys(vmod.variants)[0];
1173
+ const loader = vmod.variants[bucket];
1174
+ if (typeof loader === "function") {
1175
+ const m = await loader();
1176
+ Page = m.default ?? m;
1177
+ }
1178
+ }
1179
+ }
1180
+ if (!Page) {
1181
+ const pageMod = await loadModuleFresh(route.file);
1182
+ Page = pageMod.default ?? pageMod.Page ?? pageMod.page;
1183
+ }
623
1184
  if (!Page) {
624
1185
  return { status: 500, html: null, error: new Error(`page ${route.file} has no default export`) };
625
1186
  }
@@ -628,19 +1189,44 @@ async function renderRoute(urlPath) {
628
1189
  get: (t, k) => (k in t ? t[k] : dynamicParams.get(String(k))),
629
1190
  });
630
1191
 
1192
+ const clientPage = isClientPage(route.file);
631
1193
  let tree = React.createElement(Page, { params: paramsProxy });
1194
+ if (clientPage) {
1195
+ // Wrap in a hydration root so the client bundle can mount into it.
1196
+ tree = React.createElement(
1197
+ "div",
1198
+ { id: "__sr_island_root", "data-sr-params": JSON.stringify(route.params || {}) },
1199
+ tree,
1200
+ );
1201
+ }
632
1202
  for (let i = layouts.length - 1; i >= 0; i--) {
633
- const layoutMod = await loadModule(layouts[i].file, { bust: true });
1203
+ const layoutMod = await loadModuleFresh(layouts[i].file);
634
1204
  const Layout = layoutMod.default ?? layoutMod.Layout ?? layoutMod.layout;
635
1205
  if (Layout) tree = React.createElement(Layout, null, tree);
636
1206
  }
637
1207
  const html = await renderToStringCompat(tree);
1208
+ if (runtime?.__setRouteContext) runtime.__setRouteContext(null);
638
1209
  const metadata = await resolveMetadata(layouts.map((l) => l.file), route.file, route.params, segments);
639
- return { status: 200, html, metadata, error: null, pageFile: route.file, layoutFiles: layouts.map((l) => l.file), notFoundFile, errorFile, loadingFile, segments };
1210
+ return { status: 200, html, metadata, error: null, clientPage, pageFile: route.file, layoutFiles: layouts.map((l) => l.file), notFoundFile, errorFile, loadingFile, segments, setCookies: pipeline.setCookies, actionData: pipeline.actionData, config: pipeline.config, revalidatePlan: pipeline.revalidatePlan, seoHead: pipeline.seoHead, serverState: pipeline.serverState };
640
1211
  } catch (err) {
1212
+ const rt = await routerRuntime();
1213
+ if (rt?.__setRouteContext) rt.__setRouteContext(null);
1214
+ if (err?.__response instanceof Response) {
1215
+ return { status: err.__response.status, rawResponse: err.__response, html: null, error: null, segments };
1216
+ }
641
1217
  if (err?.digest === "NOT_FOUND" || err?.name === "NotFoundError") {
642
1218
  return await renderNotFound(segments);
643
1219
  }
1220
+ if (err?.digest === "FORBIDDEN" || err?.name === "ForbiddenError") {
1221
+ return { status: 403, html: null, error: null, segments, rawResponse: new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } }) };
1222
+ }
1223
+ if (err?.digest === "UNAUTHORIZED" || err?.name === "UnauthorizedError") {
1224
+ return { status: 401, html: null, error: null, segments, rawResponse: new Response("Unauthorized", { status: 401, headers: { "Content-Type": "text/plain" } }) };
1225
+ }
1226
+ if (err?.digest && String(err.digest).startsWith("REWRITE")) {
1227
+ const to = String(err.digest).slice("REWRITE;".length);
1228
+ return await renderRoute(to, req);
1229
+ }
644
1230
  if (err?.digest && String(err.digest).startsWith("REDIRECT")) {
645
1231
  const [, statusStr, ...rest] = String(err.digest).split(";");
646
1232
  return { status: parseInt(statusStr, 10) || 307, redirect: rest.join(";"), html: null, error: null, segments };
@@ -656,6 +1242,21 @@ async function renderRoute(urlPath) {
656
1242
  }
657
1243
  } catch {}
658
1244
  }
1245
+ // error-recovery.tsx — richer boundary with retry/reset (SSR: stub callbacks).
1246
+ const recoveryFile = findRouteFileUp(segments, "error-recovery");
1247
+ if (recoveryFile) {
1248
+ try {
1249
+ const React = await import("react");
1250
+ const mod = await loadModule(recoveryFile, { bust: true });
1251
+ const Recovery = mod.default ?? mod.ErrorRecovery;
1252
+ if (Recovery) {
1253
+ const html = await renderToStringCompat(
1254
+ React.createElement(Recovery, { error: err, attempt: 1, retry: () => {}, reset: () => {} }),
1255
+ );
1256
+ return { status: 500, html, metadata: null, error: err, segments, pageFile: route.file };
1257
+ }
1258
+ } catch {}
1259
+ }
659
1260
  return { status: 500, html: null, error: err, segments, pageFile: route.file };
660
1261
  }
661
1262
  }
@@ -800,6 +1401,7 @@ function setupWatcher() {
800
1401
  }
801
1402
  } catch {}
802
1403
  logEvent("change", full);
1404
+ buildGeneration++;
803
1405
  bustCache(full);
804
1406
  if (full.includes(`${sep}globals.${"css"}`)) {
805
1407
  globalsCssCache = { file: null, mtime: 0, css: "" };
@@ -823,6 +1425,12 @@ function setupWatcher() {
823
1425
  }
824
1426
  }
825
1427
  walk(APP_DIR);
1428
+ // Also watch sibling source dirs that pages/layouts import from — without
1429
+ // this, edits to components/, lib/, etc. never trigger a reload.
1430
+ for (const extra of ["components", "lib", "app"]) {
1431
+ const p = resolve(cwd, extra);
1432
+ if (existsSync(p)) walk(p);
1433
+ }
826
1434
  }
827
1435
 
828
1436
  const networkUrls = [];
@@ -1050,7 +1658,7 @@ async function handleRequest(req, res) {
1050
1658
  }
1051
1659
 
1052
1660
  const renderStart = performance.now();
1053
- const renderResult = await renderRoute(pathname);
1661
+ const renderResult = await renderRoute(pathname, req);
1054
1662
  const renderMs = performance.now() - renderStart;
1055
1663
  const total = performance.now() - reqStart;
1056
1664
 
@@ -1165,10 +1773,48 @@ async function handleFetch(req) {
1165
1773
  return new Response("Image not found", { status: 404 });
1166
1774
  }
1167
1775
 
1776
+ // Client-island hydration bundle for a "use client" page.
1777
+ if (pathname === "/_swift-rust/island.js") {
1778
+ const p = url.searchParams.get("p");
1779
+ if (p && existsSync(p)) {
1780
+ try {
1781
+ const code = await buildIslandBundle(p);
1782
+ return new Response(code, {
1783
+ headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache" },
1784
+ });
1785
+ } catch (e) {
1786
+ logError(e, `island bundle failed for ${p}`);
1787
+ return new Response(`/* island build error: ${String(e?.message || e).replace(/\*\//g, "* /")} */`, {
1788
+ status: 500,
1789
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
1790
+ });
1791
+ }
1792
+ }
1793
+ return new Response("// island not found", { status: 404, headers: { "Content-Type": "application/javascript" } });
1794
+ }
1795
+
1168
1796
  if (pathname.startsWith("/_swift-rust/")) {
1169
1797
  return new Response("Not found", { status: 404 });
1170
1798
  }
1171
1799
 
1800
+ // App-directory metadata icons (app/favicon.ico, app/favicon.svg, …) served
1801
+ // from the site root, Next.js style.
1802
+ {
1803
+ const iconName = pathname.replace(/^\/+/, "");
1804
+ if (APP_ICON_FILES.includes(iconName)) {
1805
+ const candidate = join(APP_DIR, iconName);
1806
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
1807
+ const ext = extname(candidate).toLowerCase();
1808
+ const mime =
1809
+ { ".ico": "image/x-icon", ".svg": "image/svg+xml", ".png": "image/png" }[ext] ||
1810
+ "application/octet-stream";
1811
+ const file = Bun?.file ? Bun.file(candidate) : readFileSync(candidate);
1812
+ logRequest({ method, url: pathname, status: 200, duration: performance.now() - reqStart, compileMs: 0 });
1813
+ return new Response(file, { headers: { "Content-Type": mime } });
1814
+ }
1815
+ }
1816
+ }
1817
+
1172
1818
  if (existsSync(PUBLIC_DIR)) {
1173
1819
  const safe = pathname.replace(/\.\.+/g, "").replace(/^\/+/, "");
1174
1820
  const candidate = join(PUBLIC_DIR, safe);
@@ -1187,7 +1833,59 @@ async function handleFetch(req) {
1187
1833
  return await handleApiRoute(req, segments, method, reqStart);
1188
1834
  }
1189
1835
 
1190
- if (method !== "GET" && method !== "HEAD") {
1836
+ // rpc.ts / stream.ts handlers (leaf, route.ts-like).
1837
+ {
1838
+ const leaf = resolveLeafDir(segments);
1839
+ if (leaf) {
1840
+ const sp = Object.fromEntries(url.searchParams.entries());
1841
+ const baseCtx = buildRouteCtx(req, url, leaf.params, sp);
1842
+ const rpcFile = findFile(leaf.dir, "rpc");
1843
+ if (rpcFile) {
1844
+ try {
1845
+ const mod = await loadModuleFresh(rpcFile);
1846
+ const procs = mod.procedures ?? mod.default;
1847
+ if (procs && typeof procs === "object") {
1848
+ if (method === "POST") {
1849
+ const body = await req.json().catch(() => ({}));
1850
+ const proc = procs[body.procedure];
1851
+ if (!proc) return jsonResponse({ error: `Unknown procedure: ${body.procedure}` }, 404);
1852
+ let input = body.input;
1853
+ if (proc.input?.safeParse) {
1854
+ const r = proc.input.safeParse(input);
1855
+ if (!r.success) return jsonResponse({ error: "Invalid input", issues: r.error?.issues ?? r.error }, 400);
1856
+ input = r.data;
1857
+ }
1858
+ return jsonResponse({ data: await proc.handler(input, baseCtx) });
1859
+ }
1860
+ if (method === "GET") return jsonResponse({ procedures: Object.keys(procs) });
1861
+ }
1862
+ } catch (e) {
1863
+ return jsonResponse({ error: String(e?.message || e) }, 500);
1864
+ }
1865
+ }
1866
+ const streamFile = findFile(leaf.dir, "stream");
1867
+ if (streamFile && !findFile(leaf.dir, "page")) {
1868
+ try {
1869
+ const mod = await loadModuleFresh(streamFile);
1870
+ const fn = mod.default ?? mod.stream;
1871
+ if (typeof fn === "function") {
1872
+ const result = await fn(baseCtx);
1873
+ if (result instanceof Response) return result;
1874
+ return new Response(result, {
1875
+ headers: { "Content-Type": mod.contentType || "text/event-stream", "Cache-Control": "no-cache" },
1876
+ });
1877
+ }
1878
+ } catch (e) {
1879
+ return new Response(String(e?.message || e), { status: 500 });
1880
+ }
1881
+ }
1882
+ }
1883
+ }
1884
+
1885
+ // Non-GET requests are allowed to reach the page render so action.ts can run;
1886
+ // pages without an action simply ignore the body.
1887
+ const ALLOWED_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
1888
+ if (!ALLOWED_METHODS.has(method)) {
1191
1889
  return new Response("Method not allowed", { status: 405 });
1192
1890
  }
1193
1891
 
@@ -1232,7 +1930,7 @@ async function handleFetch(req) {
1232
1930
  }
1233
1931
 
1234
1932
  const renderStart = performance.now();
1235
- const renderResult = await renderRoute(pathname);
1933
+ const renderResult = await renderRoute(pathname, req);
1236
1934
  const renderMs = performance.now() - renderStart;
1237
1935
  const total = performance.now() - reqStart;
1238
1936
 
@@ -1264,18 +1962,43 @@ async function handleFetch(req) {
1264
1962
 
1265
1963
  if (renderResult.redirect) {
1266
1964
  logRequest({ method, url: pathname, status: renderResult.status, duration: total, compileMs });
1267
- return new Response(null, {
1268
- status: renderResult.status,
1269
- headers: { Location: renderResult.redirect },
1270
- });
1965
+ const rh = new Headers({ Location: renderResult.redirect });
1966
+ for (const c of renderResult.setCookies || []) rh.append("Set-Cookie", c);
1967
+ return new Response(null, { status: renderResult.status, headers: rh });
1968
+ }
1969
+
1970
+ // Raw Response from a guard/action (RouteControl response, 401, 403, …).
1971
+ if (renderResult.rawResponse instanceof Response) {
1972
+ logRequest({ method, url: pathname, status: renderResult.rawResponse.status, duration: total, compileMs });
1973
+ const r = renderResult.rawResponse;
1974
+ for (const c of renderResult.setCookies || []) r.headers.append("Set-Cookie", c);
1975
+ return r;
1271
1976
  }
1272
1977
 
1273
1978
  logRequest({ method, url: pathname, status: 200, duration: total, compileMs });
1274
1979
  await scanFontsFromLayouts();
1275
- return new Response(await wrapInDocumentAsync({ head: metadataToHead(renderResult.metadata), body: renderResult.html || "" }), {
1276
- status: 200,
1277
- headers: { "Content-Type": "text/html; charset=utf-8" },
1278
- });
1980
+ // seo.tsx head + metadata head
1981
+ const headExtra = [metadataToHead(renderResult.metadata), renderResult.seoHead || ""].filter(Boolean).join("\n");
1982
+ let doc = await wrapInDocumentAsync({ head: headExtra, body: renderResult.html || "" });
1983
+ // state.ts → window.__SR_STATE__ for client stores
1984
+ if (renderResult.serverState !== undefined) {
1985
+ const json = JSON.stringify(renderResult.serverState).replace(/</g, "\\u003c");
1986
+ doc = doc.replace("</body>", `<script>window.__SR_STATE__=${json}</script>\n</body>`);
1987
+ }
1988
+ if (renderResult.clientPage && renderResult.pageFile) {
1989
+ const src = `/_swift-rust/island.js?p=${encodeURIComponent(renderResult.pageFile)}`;
1990
+ doc = doc.replace("</body>", `<script type="module" src="${src}"></script>\n</body>`);
1991
+ }
1992
+ const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
1993
+ for (const c of renderResult.setCookies || []) headers.append("Set-Cookie", c);
1994
+ // config.ts headers
1995
+ for (const [k, v] of Object.entries(renderResult.config?.headers || {})) headers.set(k, String(v));
1996
+ // revalidate.ts → Cache-Control + cache tags
1997
+ const cc = cacheControlFromPlan(renderResult.revalidatePlan);
1998
+ if (cc) headers.set("Cache-Control", cc);
1999
+ const tags = renderResult.revalidatePlan?.tags || renderResult.revalidatePlan?.invalidate;
2000
+ if (Array.isArray(tags) && tags.length) headers.set("x-vercel-cache-tags", tags.join(","));
2001
+ return new Response(doc, { status: 200, headers });
1279
2002
  }
1280
2003
 
1281
2004
  async function handleApiRoute(req, segments, method, reqStart) {
@@ -1352,6 +2075,7 @@ const MIME = {
1352
2075
  };
1353
2076
 
1354
2077
  await checkAppDir();
2078
+ try { validateRoutingConventions(); } catch {}
1355
2079
  await detectNetworkUrls();
1356
2080
  logStartupBanner(`http://localhost:${port}`, networkUrls);
1357
2081
  logLine([` ${paint("dim", "› setupWatcher…")}`]);