151 lines
5.3 KiB
TypeScript
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();
|
|
}
|