feat: keep dev server alive during HMR updates
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user