1/**
2 * Fresh ESM module loader for files that must reflect disk changes in a long-lived process.
3 *
4 * Bun currently caches ESM modules by path and ignores URL query/hash. This helper
5 * loads a module via `Bun.build()` (in-memory) and imports the resulting bundle as
6 * a `data:` URL, while caching by file mtime/size to avoid rebuilding unnecessarily.
7 */
8
9import { mkdir, rm, stat, writeFile } from "node:fs/promises";
10import { createHash } from "node:crypto";
11import { tmpdir } from "node:os";
12import * as path from "node:path";
13import { pathToFileURL } from "node:url";
14
15type CachedModule = {
16 mtimeMs: number;
17 size: number;
18 exports: any;
19 outputPath?: string;
20};
21
22const moduleCache = new Map<string, CachedModule>();
23
24const cacheRoot = path.join(tmpdir(), "webette-module-cache");
25
26function buildCachePath(absPath: string, stamp: string): string {
27 const hash = createHash("sha1").update(absPath).digest("hex").slice(0, 12);
28 const baseName = path.parse(absPath).name || "module";
29 return path.join(cacheRoot, hash, `${baseName}.${stamp}.mjs`);
30}
31
32export async function loadDefaultExportFresh<T = unknown>(
33 filePath: string
34): Promise<T> {
35 const absPath = path.resolve(filePath);
36
37 const s = await stat(absPath);
38 const key = path.normalize(absPath);
39 const cached = moduleCache.get(key);
40 if (cached && cached.mtimeMs === s.mtimeMs && cached.size === s.size) {
41 return cached.exports as T;
42 }
43 if (cached?.outputPath) {
44 await rm(cached.outputPath, { force: true }).catch(() => undefined);
45 }
46
47 // Note: Bun's TS types for `Bun.build()` vary by version; keep this config runtime-safe.
48 const result = await Bun.build({
49 entrypoints: [absPath],
50 format: "esm",
51 target: "bun",
52 splitting: false,
53 sourcemap: "none",
54 minify: false,
55 write: false,
56 throw: false,
57 });
58
59 if (!result.success || !result.outputs?.length) {
60 const details =
61 result.logs?.length
62 ? result.logs.map((l) => l.message).join("\n")
63 : "unknown build error";
64 throw new Error(`Failed to load module ${absPath}: ${details}`);
65 }
66
67 const first = result.outputs[0];
68 if (!first) {
69 throw new Error(`Failed to load module ${absPath}: build produced no output`);
70 }
71
72 const js = await first.text();
73 const stamp = `${s.mtimeMs}-${s.size}`;
74 const outputPath = buildCachePath(absPath, stamp);
75 await mkdir(path.dirname(outputPath), { recursive: true });
76 await writeFile(outputPath, js, "utf8");
77 const mod = await import(pathToFileURL(outputPath).href);
78 const exportsValue = (mod as any)?.default ?? mod;
79
80 moduleCache.set(key, {
81 mtimeMs: s.mtimeMs,
82 size: s.size,
83 exports: exportsValue,
84 outputPath,
85 });
86 return exportsValue as T;
87}