Files
WRNexusJS/packages/dev-server/src/pipeline.ts
T
2026-07-18 19:33:57 +05:30

151 lines
5.3 KiB
TypeScript

/**
* 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> | Response,
): Promise<Response> {
let lastIndex = -1;
const dispatch = (index: number): Promise<Response> => {
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<string, Promise<Record<string, unknown>>>();
const moduleVersions = new Map<string, number>();
export function loadModule(file: string): Promise<Record<string, unknown>> {
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<Record<string, unknown>>;
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 `<appRoot>/.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();
}