feat: keep dev server alive during HMR updates

This commit is contained in:
2026-07-13 15:17:49 +05:30
parent cf4c3723c5
commit 577c38a965
62 changed files with 281 additions and 169 deletions
+44 -5
View File
@@ -5,8 +5,15 @@
*/
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { dirname, join, basename } from "node:path";
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";
@@ -40,14 +47,36 @@ export function runMiddleware(
* 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.
const target = file.endsWith(".wrn") ? compileWireToTs(file) : file;
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;
@@ -86,10 +115,11 @@ function hashPath(s: string): string {
* 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): string {
function compileWireToTs(file: string, version = 0): string {
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
const name = basename(file).replace(/\.wrn$/, "");
const out = join(cacheDir, `${name}-${hashPath(file)}.wrn.ts`);
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.
@@ -105,7 +135,16 @@ function compileWireToTs(file: string): string {
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();
}