645 lines
23 KiB
TypeScript
645 lines
23 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,
|
|
existsSync,
|
|
} from "node:fs";
|
|
import { dirname, join, basename, extname, relative, resolve } from "node:path";
|
|
import {
|
|
compile,
|
|
generate,
|
|
generateTargets,
|
|
resolveWrnImports,
|
|
type PageAst,
|
|
} 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>();
|
|
const browserArtifactPaths = new Map<string, string>();
|
|
|
|
type ImportMode = "legacy" | "compatible" | "explicit";
|
|
interface CompileImportOptions {
|
|
mode: ImportMode;
|
|
aliases: Record<string, string>;
|
|
autoImport: boolean;
|
|
}
|
|
const compileImportOptions = new Map<string, CompileImportOptions>();
|
|
const warnedImportDiagnostics = new Set<string>();
|
|
|
|
interface DevCompilerPipeline {
|
|
transformAst(ast: PageAst, file: string): Promise<PageAst>;
|
|
transformCode(code: string, file: string): Promise<string>;
|
|
virtualModules: Map<string, string>;
|
|
}
|
|
let devCompilerPipeline: DevCompilerPipeline | null = null;
|
|
|
|
/** Install the configured plugin compiler pipeline for development compilation. */
|
|
export function setDevCompilerPipeline(pipeline: DevCompilerPipeline | null): void {
|
|
devCompilerPipeline = pipeline;
|
|
}
|
|
|
|
export function setCompileImportOptions(
|
|
appRoot: string,
|
|
options: { mode?: ImportMode; aliases?: Record<string, string>; autoImport?: boolean } = {},
|
|
): void {
|
|
compileImportOptions.set(resolve(appRoot), {
|
|
mode: options.mode ?? "compatible",
|
|
aliases: { "@": "./app", ...(options.aliases ?? {}) },
|
|
autoImport: options.autoImport ?? true,
|
|
});
|
|
}
|
|
|
|
const compileInProgress = new Map<string, WrnCompileArtifacts>();
|
|
|
|
function isApplicationImportSource(
|
|
source: string,
|
|
aliases: Record<string, string> | undefined,
|
|
): boolean {
|
|
if (source.startsWith(".")) return true;
|
|
return Object.keys(aliases ?? {}).some(
|
|
(alias) => source === alias || source.startsWith(`${alias}/`),
|
|
);
|
|
}
|
|
|
|
function projectRootForFile(file: string): string {
|
|
let current = dirname(resolve(file));
|
|
while (true) {
|
|
if (existsSync(join(current, "app"))) return current;
|
|
const parent = dirname(current);
|
|
if (parent === current) return dirname(resolve(file));
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
function importOptionsHash(file: string): string {
|
|
const root = resolve(projectRootForFile(file));
|
|
const options = compileImportOptions.get(root) ?? {
|
|
mode: "compatible" as const,
|
|
aliases: { "@": "./app" },
|
|
autoImport: true,
|
|
};
|
|
const aliases = Object.fromEntries(
|
|
Object.entries(options.aliases).sort(([left], [right]) => left.localeCompare(right)),
|
|
);
|
|
return hashPath(JSON.stringify({ ...options, aliases }));
|
|
}
|
|
|
|
function rewriteArtifactImports(
|
|
code: string,
|
|
ast: PageAst,
|
|
importer: string,
|
|
target: "main" | "server" | "browser",
|
|
): string {
|
|
if (!ast.structuredImports.length) return code;
|
|
const root = projectRootForFile(importer);
|
|
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
|
mode: "compatible" as const,
|
|
aliases: { "@": "./app" },
|
|
autoImport: true,
|
|
};
|
|
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
|
appRoot: root,
|
|
mode: importOptions.mode,
|
|
aliases: importOptions.aliases,
|
|
});
|
|
let output = code;
|
|
for (const entry of resolved) {
|
|
if (entry.diagnostic) {
|
|
const key = `${importer}:${entry.diagnostic.code}:${entry.declaration.source}`;
|
|
if (entry.diagnostic.severity === "error") {
|
|
throw new Error(`${entry.diagnostic.code}: ${entry.diagnostic.message}`);
|
|
}
|
|
if (!warnedImportDiagnostics.has(key)) {
|
|
warnedImportDiagnostics.add(key);
|
|
console.warn(`[wrnexus] ${entry.diagnostic.code}: ${entry.diagnostic.message}`);
|
|
}
|
|
}
|
|
if (!entry.resolved || !entry.declaration.source) continue;
|
|
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
|
let replacement = entry.resolved;
|
|
if (entry.resolved.endsWith(".wrn")) {
|
|
const dependencySource = readFileSync(entry.resolved, "utf8");
|
|
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
|
|
const dependency = compileWireArtifacts(
|
|
entry.resolved,
|
|
moduleVersions.get(entry.resolved) ?? 0,
|
|
);
|
|
if (target === "browser") {
|
|
if (!isStore) {
|
|
// Components and layouts are compile-time dependencies in browser modules.
|
|
output = output.replace(entry.declaration.raw, "");
|
|
continue;
|
|
}
|
|
replacement = dependency.browser;
|
|
} else {
|
|
replacement = target === "server" ? dependency.server : dependency.main;
|
|
}
|
|
}
|
|
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const specifier =
|
|
target === "browser"
|
|
? (() => {
|
|
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
|
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
|
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
|
})()
|
|
: replacement.startsWith("/")
|
|
? replacement
|
|
: pathToFileURL(replacement).href;
|
|
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function rewriteArtifactImportsAsync(
|
|
code: string,
|
|
ast: PageAst,
|
|
importer: string,
|
|
target: "main" | "server" | "browser",
|
|
): Promise<string> {
|
|
let output = code;
|
|
for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) {
|
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const specifier =
|
|
target === "browser"
|
|
? (() => {
|
|
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
|
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
|
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
|
})()
|
|
: pathToFileURL(replacement).href;
|
|
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
|
}
|
|
if (!ast.structuredImports.length) return output;
|
|
const root = projectRootForFile(importer);
|
|
const importOptions = compileImportOptions.get(resolve(root)) ?? {
|
|
mode: "compatible" as const,
|
|
aliases: { "@": "./app" },
|
|
autoImport: true,
|
|
};
|
|
const resolved = resolveWrnImports(ast.structuredImports, importer, {
|
|
appRoot: root,
|
|
mode: importOptions.mode,
|
|
aliases: importOptions.aliases,
|
|
});
|
|
for (const entry of resolved) {
|
|
if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message);
|
|
if (!entry.resolved || !entry.declaration.source) continue;
|
|
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
|
let replacement = entry.resolved;
|
|
if (replacement.endsWith(".wrn")) {
|
|
const dependencySource = readFileSync(replacement, "utf8");
|
|
const isStore = /\b(?:global|page)\s+store\s+[A-Za-z_$][\w$]*\s*\{/.test(dependencySource);
|
|
const dependency = await compileWireArtifactsAsync(
|
|
replacement,
|
|
moduleVersions.get(replacement) ?? 0,
|
|
);
|
|
if (target === "browser") {
|
|
if (!isStore) {
|
|
output = output.replace(entry.declaration.raw, "");
|
|
continue;
|
|
}
|
|
replacement = dependency.browser;
|
|
} else replacement = target === "server" ? dependency.server : dependency.main;
|
|
}
|
|
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const specifier =
|
|
target === "browser"
|
|
? (() => {
|
|
const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus");
|
|
const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/");
|
|
return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
|
})()
|
|
: replacement.startsWith("/")
|
|
? replacement
|
|
: pathToFileURL(replacement).href;
|
|
output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier));
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export async function loadModule(file: string): Promise<Record<string, unknown>> {
|
|
let mod = moduleCache.get(file);
|
|
if (!mod) {
|
|
mod = (async () => {
|
|
const version = moduleVersions.get(file) ?? 0;
|
|
// `.wrn` files are compiled to TypeScript first, then imported.
|
|
let target = file.endsWith(".wrn")
|
|
? (await compileWireArtifactsAsync(file, version)).main
|
|
: 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.
|
|
let imported = import(pathToFileURL(target).href) as Promise<Record<string, unknown>>;
|
|
if (temporary) {
|
|
imported = imported.finally(() => {
|
|
try {
|
|
unlinkSync(target);
|
|
} catch {
|
|
/* best-effort cleanup after Bun has loaded the module */
|
|
}
|
|
});
|
|
}
|
|
return imported;
|
|
})();
|
|
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;
|
|
const WRN_COMPILE_CACHE_VERSION = "v3";
|
|
|
|
/**
|
|
* 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 | null): 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 importedValueBindings(ast: PageAst): Set<string> {
|
|
const names = new Set<string>();
|
|
for (const entry of ast.structuredImports) {
|
|
if (entry.typeOnly) continue;
|
|
if (entry.defaultImport) names.add(entry.defaultImport);
|
|
if (entry.namespaceImport) names.add(entry.namespaceImport);
|
|
for (const item of entry.namedImports) if (!item.typeOnly) names.add(item.local);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function validateConfiguredImports(source: string, ast: PageAst, file: string): void {
|
|
const root = projectRootForFile(file);
|
|
const options = compileImportOptions.get(resolve(root));
|
|
if (!options || options.mode === "legacy") return;
|
|
const imported = importedValueBindings(ast);
|
|
const usedComponents = new Set(
|
|
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
|
|
);
|
|
const compilerBuiltins = new Set([
|
|
"Async",
|
|
"Component",
|
|
"Error",
|
|
"Loading",
|
|
"Portal",
|
|
"Success",
|
|
"Transition",
|
|
]);
|
|
const missing = [...usedComponents].filter(
|
|
(name) => !compilerBuiltins.has(name) && !imported.has(name),
|
|
);
|
|
if (ast.layoutIsSymbol && ast.layout && !imported.has(ast.layout)) missing.push(ast.layout);
|
|
if (!missing.length) return;
|
|
const unique = [...new Set(missing)];
|
|
const message = `WRN-IMPORT-IMPLICIT: ${file} uses ${unique.join(", ")} without explicit imports.`;
|
|
if (options.mode === "explicit") throw new Error(message);
|
|
const key = `${file}:WRN-IMPORT-IMPLICIT:${unique.join(",")}`;
|
|
if (!warnedImportDiagnostics.has(key)) {
|
|
warnedImportDiagnostics.add(key);
|
|
console.warn(`[wrnexus] ${message}`);
|
|
}
|
|
}
|
|
|
|
export interface WrnCompileArtifacts {
|
|
main: string;
|
|
browser: string;
|
|
server: string;
|
|
declarations: string;
|
|
contract: string;
|
|
rpc: string;
|
|
}
|
|
|
|
export interface WrnCompileMetrics {
|
|
hits: number;
|
|
misses: number;
|
|
compilations: number;
|
|
errors: number;
|
|
totalDurationMs: number;
|
|
lastDurationMs: number;
|
|
}
|
|
|
|
const compileMetrics: WrnCompileMetrics = {
|
|
hits: 0,
|
|
misses: 0,
|
|
compilations: 0,
|
|
errors: 0,
|
|
totalDurationMs: 0,
|
|
lastDurationMs: 0,
|
|
};
|
|
const asyncCompileInProgress = new Map<string, Promise<WrnCompileArtifacts>>();
|
|
|
|
async function bundleBrowserArtifact(
|
|
code: string,
|
|
file: string,
|
|
cacheDir: string,
|
|
stem: string,
|
|
): Promise<string> {
|
|
const hasModuleImport = /(?:^|\n)\s*import(?:\s|["'])|\bimport\s*\(/m.test(code);
|
|
if (!hasModuleImport || code.includes("wrnexus-client-bundled")) return code;
|
|
const bun = (globalThis as any).Bun;
|
|
if (!bun?.build) {
|
|
throw new Error(
|
|
`WRN-CLIENT-BUNDLE: ${file} has browser imports, but the Bun bundler is unavailable.`,
|
|
);
|
|
}
|
|
const entry = join(cacheDir, `${stem}.browser-entry.mjs`);
|
|
writeFileSync(entry, code, "utf8");
|
|
try {
|
|
const result = await bun.build({
|
|
entrypoints: [entry],
|
|
target: "browser",
|
|
format: "esm",
|
|
splitting: false,
|
|
minify: false,
|
|
sourcemap: "inline",
|
|
});
|
|
if (!result.success || !result.outputs?.length) {
|
|
const detail = (result.logs ?? []).map(String).join("\n");
|
|
throw new Error(`WRN-CLIENT-BUNDLE: failed to bundle ${file}${detail ? `\n${detail}` : ""}`);
|
|
}
|
|
return `// wrnexus-client-bundled\n${await result.outputs[0].text()}`;
|
|
} finally {
|
|
try {
|
|
unlinkSync(entry);
|
|
} catch {
|
|
// Best-effort cleanup; cache pruning removes stale temporary entries.
|
|
}
|
|
}
|
|
}
|
|
|
|
export function compileWireArtifactsAsync(file: string, version = 0): Promise<WrnCompileArtifacts> {
|
|
const key = `${file}:${version}`;
|
|
const active = asyncCompileInProgress.get(key);
|
|
if (active) return active;
|
|
const task = (async () => {
|
|
if (!devCompilerPipeline) {
|
|
const artifacts = compileWireArtifacts(file, version);
|
|
const code = readFileSync(artifacts.browser, "utf8");
|
|
const bundled = await bundleBrowserArtifact(
|
|
code,
|
|
file,
|
|
dirname(artifacts.browser),
|
|
basename(artifacts.browser, ".client.mjs"),
|
|
);
|
|
if (bundled !== code) writeFileSync(artifacts.browser, bundled, "utf8");
|
|
return artifacts;
|
|
}
|
|
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
|
const name = basename(file).replace(/\.wrn$/, "");
|
|
const suffix = version ? `-hmr-${version}` : "";
|
|
const source = readFileSync(file, "utf8");
|
|
// Plugin output affects the artifact, so use a separate cache generation.
|
|
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}-${importOptionsHash(file)}${suffix}`;
|
|
const artifacts: WrnCompileArtifacts = {
|
|
main: join(cacheDir, `${stem}.wrn.ts`),
|
|
browser: join(cacheDir, `${stem}.client.mjs`),
|
|
server: join(cacheDir, `${stem}.server.ts`),
|
|
declarations: join(cacheDir, `${stem}.d.ts`),
|
|
contract: join(cacheDir, `${stem}.contract.json`),
|
|
rpc: join(cacheDir, `${stem}.rpc.json`),
|
|
};
|
|
const result = compile(source, file);
|
|
validateConfiguredImports(source, result.ast, file);
|
|
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
|
const targets = generateTargets(ast);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
|
const outputs = {
|
|
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
|
|
"__WRNEXUS_CLIENT_MODULE__",
|
|
browserPath,
|
|
),
|
|
browser: targets.browser,
|
|
server: targets.server,
|
|
declarations: targets.declarations,
|
|
};
|
|
for (const target of ["main", "browser", "server"] as const) {
|
|
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
|
let transformed = await devCompilerPipeline!.transformCode(rewritten, file);
|
|
if (target === "browser") {
|
|
transformed = await bundleBrowserArtifact(transformed, file, cacheDir, stem);
|
|
}
|
|
writeFileSync(artifacts[target], transformed, "utf8");
|
|
}
|
|
writeFileSync(
|
|
artifacts.declarations,
|
|
await devCompilerPipeline!.transformCode(outputs.declarations, file),
|
|
"utf8",
|
|
);
|
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
|
browserArtifactPaths.set(browserPath, artifacts.browser);
|
|
compileMetrics.compilations++;
|
|
return artifacts;
|
|
})().finally(() => asyncCompileInProgress.delete(key));
|
|
asyncCompileInProgress.set(key, task);
|
|
return task;
|
|
}
|
|
|
|
export function getWrnCompileMetrics(): Readonly<WrnCompileMetrics> {
|
|
return { ...compileMetrics };
|
|
}
|
|
|
|
export function resetWrnCompileMetrics(): void {
|
|
Object.assign(compileMetrics, {
|
|
hits: 0,
|
|
misses: 0,
|
|
compilations: 0,
|
|
errors: 0,
|
|
totalDurationMs: 0,
|
|
lastDurationMs: 0,
|
|
});
|
|
}
|
|
|
|
export function compileWireArtifacts(file: string, version = 0): WrnCompileArtifacts {
|
|
const active = compileInProgress.get(file);
|
|
if (active) return active;
|
|
const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus");
|
|
const name = basename(file).replace(/\.wrn$/, "");
|
|
const suffix = version ? `-hmr-${version}` : "";
|
|
const source = readFileSync(file, "utf8");
|
|
const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-${hashPath(file)}-${hashPath(source)}-${importOptionsHash(file)}${suffix}`;
|
|
const artifacts: WrnCompileArtifacts = {
|
|
main: join(cacheDir, `${stem}.wrn.ts`),
|
|
browser: join(cacheDir, `${stem}.client.mjs`),
|
|
server: join(cacheDir, `${stem}.server.ts`),
|
|
declarations: join(cacheDir, `${stem}.d.ts`),
|
|
contract: join(cacheDir, `${stem}.contract.json`),
|
|
rpc: join(cacheDir, `${stem}.rpc.json`),
|
|
};
|
|
compileInProgress.set(file, artifacts);
|
|
try {
|
|
try {
|
|
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
|
|
compileMetrics.hits++;
|
|
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
|
return artifacts;
|
|
}
|
|
} catch {
|
|
// Compile missing artifact set below.
|
|
}
|
|
compileMetrics.misses++;
|
|
const started = performance.now();
|
|
try {
|
|
const result = compile(source, file);
|
|
validateConfiguredImports(source, result.ast, file);
|
|
const targets = generateTargets(result.ast);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
|
const mainCode = rewriteArtifactImports(
|
|
result.code.replaceAll("__WRNEXUS_CLIENT_MODULE__", browserPath),
|
|
result.ast,
|
|
file,
|
|
"main",
|
|
);
|
|
writeFileSync(artifacts.main, mainCode, "utf8");
|
|
writeFileSync(
|
|
artifacts.browser,
|
|
rewriteArtifactImports(targets.browser, result.ast, file, "browser"),
|
|
"utf8",
|
|
);
|
|
browserArtifactPaths.set(browserPath, artifacts.browser);
|
|
writeFileSync(
|
|
artifacts.server,
|
|
rewriteArtifactImports(targets.server, result.ast, file, "server"),
|
|
"utf8",
|
|
);
|
|
writeFileSync(artifacts.declarations, targets.declarations, "utf8");
|
|
writeFileSync(artifacts.contract, JSON.stringify(targets.contract, null, 2) + "\n", "utf8");
|
|
writeFileSync(artifacts.rpc, JSON.stringify(targets.rpc, null, 2) + "\n", "utf8");
|
|
compileMetrics.compilations++;
|
|
return artifacts;
|
|
} catch (error) {
|
|
compileMetrics.errors++;
|
|
throw error;
|
|
} finally {
|
|
const duration = performance.now() - started;
|
|
compileMetrics.lastDurationMs = duration;
|
|
compileMetrics.totalDurationMs += duration;
|
|
}
|
|
} finally {
|
|
compileInProgress.delete(file);
|
|
}
|
|
}
|
|
|
|
export async function loadWrnServerModule(file: string): Promise<Record<string, unknown>> {
|
|
const version = moduleVersions.get(file) ?? 0;
|
|
const artifact = compileWireArtifacts(file, version).server;
|
|
return import(pathToFileURL(artifact).href) as Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
export function wrnBrowserArtifact(file: string): string {
|
|
return compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
|
}
|
|
|
|
export function wrnBrowserArtifactUrl(file: string): string {
|
|
const artifact = compileWireArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
|
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
|
|
}
|
|
|
|
/** Async browser artifact URL used by HMR so imported client modules are bundled before delivery. */
|
|
export async function wrnBrowserArtifactUrlAsync(file: string): Promise<string> {
|
|
const artifact = (await compileWireArtifactsAsync(file, moduleVersions.get(file) ?? 0)).browser;
|
|
return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`;
|
|
}
|
|
|
|
export function serveWrnBrowserArtifact(pathname: string): Response | null {
|
|
const artifact = browserArtifactPaths.get(pathname);
|
|
if (!artifact || !existsSync(artifact)) return null;
|
|
return new Response(readFileSync(artifact, "utf8"), {
|
|
headers: {
|
|
"content-type": "text/javascript; charset=utf-8",
|
|
"cache-control": "no-store, max-age=0",
|
|
pragma: "no-cache",
|
|
expires: "0",
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 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();
|
|
}
|