/** * Request pipeline helpers: middleware execution and safe module loading. * These are deliberately runtime-agnostic (no Bun APIs) so they could run on * Node too. */ import { pathToFileURL } from "node:url"; import { copyFileSync, readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, } from "node:fs"; import { dirname, join, basename, extname } from "node:path"; import { compileWireFile } from "@wrnexus/compiler"; import type { Context, Middleware } from "@wrnexus/core"; /** * Run an onion-style middleware chain, ending in `final` (the route handler). * Each middleware receives `next`; calling it advances the chain. A middleware * may short-circuit by returning a Response without calling `next`. */ export function runMiddleware( middlewares: Middleware[], ctx: Context, final: () => Promise | Response, ): Promise { let lastIndex = -1; const dispatch = (index: number): Promise => { if (index <= lastIndex) { return Promise.reject(new Error("next() called multiple times")); } lastIndex = index; const mw = middlewares[index]; if (!mw) return Promise.resolve(final()); return Promise.resolve(mw(ctx, () => dispatch(index + 1))); }; return dispatch(0); } /** * Cache of imported route modules. Modules are only ever loaded from absolute * paths discovered during the startup scan — never from request input. */ const moduleCache = new Map>>(); const moduleVersions = new Map(); export function loadModule(file: string): Promise> { let mod = moduleCache.get(file); if (!mod) { const version = moduleVersions.get(file) ?? 0; // `.wrn` files are compiled to TypeScript first, then imported. let target = file.endsWith(".wrn") ? compileWireToTs(file, version) : file; let temporary = false; // Bun intentionally caches local TS/JS modules by filesystem path and ignores // URL query strings. A short-lived versioned sibling keeps relative imports // correct while giving the changed module a genuinely new import identity. if (version && !file.endsWith(".wrn")) { const extension = extname(file); const stem = basename(file, extension); target = join(dirname(file), `${stem}.wrnexus-hmr-${version}${extension}`); copyFileSync(file, target); temporary = true; } // pathToFileURL handles Windows drive letters and spaces correctly. mod = import(pathToFileURL(target).href) as Promise>; if (temporary) { mod = mod.finally(() => { try { unlinkSync(target); } catch { /* best-effort cleanup after Bun has loaded the module */ } }); } moduleCache.set(file, mod); } return mod; } /** * A single cache dir for ALL `.wrn` compilation (set once at server start). * When unset, compilation falls back to a sibling `.wrnexus/` next to each file. */ let compileCacheDir: string | null = null; /** * Point all `.wrn` compilation at ONE cache dir (typically `/.wrnexus`) * instead of scattering a `.wrnexus/` folder next to every `.wrn` source. Called * once by the dev server at startup. */ export function setCompileCacheDir(dir: string): void { compileCacheDir = dir; } /** FNV-1a hash of a string → short base36, to make unique flat cache filenames. */ function hashPath(s: string): string { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36); } /** * Compile a `.wrn` file to a `.ts` file inside the shared `.wrnexus/` cache dir and * return the generated path. The cache dir is hidden, so the router never re-scans * it and the dev watcher ignores it. Output names are flat + hash-suffixed by the * absolute source path, so `.wrn` files from anywhere (the app AND node_modules UI * components) share one cache dir without colliding. Generated modules are * self-contained (no relative imports), so the cache location doesn't affect them. */ function compileWireToTs(file: string, version = 0): string { const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus"); const name = basename(file).replace(/\.wrn$/, ""); const suffix = version ? `-hmr-${version}` : ""; const out = join(cacheDir, `${name}-${hashPath(file)}${suffix}.wrn.ts`); // Skip recompiling when the on-disk cache is already newer than the source // (e.g. reused across dev restarts) — avoids a read + compile + write. try { if (statSync(out).mtimeMs >= statSync(file).mtimeMs) return out; } catch { /* cache missing → compile below */ } const code = compileWireFile(readFileSync(file, "utf8"), file); mkdirSync(cacheDir, { recursive: true }); writeFileSync(out, code, "utf8"); return out; } /** Forget one module and force its next dynamic import to bypass Bun's import cache. */ export function invalidateModule(file: string): void { moduleCache.delete(file); moduleVersions.set(file, (moduleVersions.get(file) ?? 0) + 1); } /** Forget cached modules (used by build/dev tooling if needed). */ export function clearModuleCache(): void { for (const file of moduleCache.keys()) { moduleVersions.set(file, (moduleVersions.get(file) ?? 0) + 1); } moduleCache.clear(); }