Three bugs found by driving the dev server rather than reading code: 1. An island used inside a .wrn component still emitted a component mount — only the page and nested-page render paths were covered. 2. Editing an island .tsx never rebuilt in dev. The bundle cache was keyed on source path alone, and page modules are cached after the first request so no compile runs to notice the change. The cache key now includes mtime, and the file watcher rebuilds islands whose .tsx changed. 3. A .wrn cache hit skipped island building entirely, so after a restart with a warm cache no island bundle was ever produced. Island inputs are now persisted beside the other artifacts and rebuilt on a cache hit. The islands manifest is deliberately excluded from the artifact completeness check: only the async compile path writes it, so requiring it made the sync path miss the cache on every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
809 lines
30 KiB
TypeScript
809 lines
30 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,
|
|
buildIslands,
|
|
islandNamesFrom,
|
|
resolveWrnImports,
|
|
type PageAst,
|
|
type ViewNode,
|
|
} 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>();
|
|
const islandArtifactPaths = 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 }));
|
|
}
|
|
|
|
/** Island bundles already built this dev session, keyed by resolved source. */
|
|
const builtIslands = new Map<string, string>();
|
|
/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */
|
|
const islandSources = new Map<string, string>();
|
|
let islandAppRoot: string | null = null;
|
|
let islandRuntimeBuilt = false;
|
|
|
|
/**
|
|
* Builds island bundles on demand in dev and registers them under
|
|
* /__wrnexus/island/. Without this the browser bootstrap 404s and no island
|
|
* ever mounts.
|
|
*/
|
|
async function ensureIslandArtifacts(
|
|
islands: Array<{ name: string; sourcePath: string }>,
|
|
appRoot: string,
|
|
): Promise<void> {
|
|
if (islands.length === 0) return;
|
|
const outDir = join(appRoot, ".wrnexus", "island");
|
|
islandAppRoot = appRoot;
|
|
for (const island of islands) islandSources.set(island.name, island.sourcePath);
|
|
|
|
// Keyed by source AND mtime: keying on the path alone serves a stale bundle
|
|
// forever once an island .tsx is edited.
|
|
const stamp = (island: { name: string; sourcePath: string }) => {
|
|
let mtime: number;
|
|
try {
|
|
mtime = statSync(island.sourcePath).mtimeMs;
|
|
} catch {
|
|
mtime = 0;
|
|
}
|
|
return `${island.sourcePath}:${mtime}`;
|
|
};
|
|
const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island));
|
|
if (pending.length === 0 && islandRuntimeBuilt) return;
|
|
|
|
// The runtime is built alongside the islands so they share one React copy.
|
|
const result = await buildIslands({
|
|
islands: pending.length ? pending : islands,
|
|
outDir,
|
|
appRoot,
|
|
});
|
|
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
|
|
islandRuntimeBuilt = true;
|
|
for (const asset of result.assets) {
|
|
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
|
|
const rebuilt = pending.find((island) => island.name === asset.name);
|
|
if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt));
|
|
}
|
|
for (const chunk of result.sharedChunks) {
|
|
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Island imports in this file: names so codegen emits placeholders instead of
|
|
* component mounts, and sources so the bundles can be built.
|
|
*/
|
|
/**
|
|
* Rebuilds islands whose .tsx source changed.
|
|
*
|
|
* Page modules are cached after the first request, so no compile runs on a
|
|
* later request and nothing else would notice an island edit.
|
|
*/
|
|
export async function rebuildChangedIslands(changed: string[]): Promise<boolean> {
|
|
if (!islandAppRoot) return false;
|
|
const touched = new Set(changed.map((file) => resolve(file)));
|
|
const affected = [...islandSources]
|
|
.filter(([, sourcePath]) => touched.has(resolve(sourcePath)))
|
|
.map(([name, sourcePath]) => ({ name, sourcePath }));
|
|
if (affected.length === 0) return false;
|
|
await ensureIslandArtifacts(affected, islandAppRoot);
|
|
return true;
|
|
}
|
|
|
|
function islandsForFile(
|
|
ast: PageAst,
|
|
importer: string,
|
|
): { names: Set<string>; inputs: Array<{ name: string; sourcePath: string }> } {
|
|
if (!ast.structuredImports.length) return { names: new Set(), inputs: [] };
|
|
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,
|
|
});
|
|
const inputs = resolved
|
|
.filter((entry) => entry.kind === "island" && entry.resolved && entry.declaration.defaultImport)
|
|
.map((entry) => ({ name: entry.declaration.defaultImport!, sourcePath: entry.resolved! }));
|
|
return { names: islandNamesFrom(resolved), inputs };
|
|
}
|
|
|
|
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 = compileWrnArtifacts(
|
|
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 compileWrnArtifactsAsync(
|
|
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>> {
|
|
file = resolve(file);
|
|
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 compileWrnArtifactsAsync(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 viewComponentNames(nodes: ViewNode[], names = new Set<string>()): Set<string> {
|
|
for (const node of nodes) {
|
|
if (node.type === "element") {
|
|
if (/^[A-Z]/.test(node.tag)) names.add(node.tag);
|
|
viewComponentNames(node.children, names);
|
|
} else if (node.type === "each") {
|
|
viewComponentNames(node.body, names);
|
|
viewComponentNames(node.empty, names);
|
|
} else if (node.type === "if") {
|
|
for (const branch of node.branches) viewComponentNames(branch.body, names);
|
|
}
|
|
}
|
|
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 = viewComponentNames(ast.view);
|
|
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;
|
|
/** Island inputs for this file, so a cache hit can still build islands. */
|
|
islands: 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 compileWrnArtifactsAsync(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 = compileWrnArtifacts(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`),
|
|
islands: join(cacheDir, `${stem}.islands.json`),
|
|
};
|
|
const result = compile(source, file);
|
|
validateConfiguredImports(source, result.ast, file);
|
|
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
|
|
const { names: islands, inputs: islandInputs } = islandsForFile(ast, file);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
writeFileSync(artifacts.islands, JSON.stringify(islandInputs), "utf8");
|
|
await ensureIslandArtifacts(islandInputs, projectRootForFile(file));
|
|
const targets = generateTargets(ast);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const browserPath = `/__wrnexus/client/${stem}.mjs`;
|
|
const outputs = {
|
|
main: `// compiled from .wrn\n${generate(ast, { islands })}`.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 compileWrnArtifacts(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`),
|
|
islands: join(cacheDir, `${stem}.islands.json`),
|
|
};
|
|
compileInProgress.set(file, artifacts);
|
|
try {
|
|
try {
|
|
// The islands manifest is written only by the async compile path, so it
|
|
// is not part of the completeness check — a missing manifest means "no
|
|
// islands known for this file", not a stale cache.
|
|
const requiredArtifacts = Object.entries(artifacts)
|
|
.filter(([key]) => key !== "islands")
|
|
.map(([, path]) => path);
|
|
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
|
|
compileMetrics.hits++;
|
|
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
|
|
// A cached .wrn still needs its island bundles: the .tsx may have changed
|
|
// since, and after a restart with a warm cache nothing else would build them.
|
|
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
|
|
try {
|
|
cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{
|
|
name: string;
|
|
sourcePath: string;
|
|
}>;
|
|
} catch {
|
|
cachedIslands = [];
|
|
}
|
|
// This variant is synchronous, so the rebuild is kicked off rather than
|
|
// awaited. The async compile path awaits it before serving a page.
|
|
void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => {
|
|
console.warn("[wrnexus] island rebuild failed", error);
|
|
});
|
|
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 = compileWrnArtifacts(file, version).server;
|
|
return import(pathToFileURL(artifact).href) as Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
export function wrnBrowserArtifact(file: string): string {
|
|
return compileWrnArtifacts(file, moduleVersions.get(file) ?? 0).browser;
|
|
}
|
|
|
|
export function wrnBrowserArtifactUrl(file: string): string {
|
|
const artifact = compileWrnArtifacts(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 compileWrnArtifactsAsync(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",
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Registers a built island asset for serving under `/__wrnexus/island/`. */
|
|
export function registerIslandArtifact(pathname: string, artifact: string): void {
|
|
islandArtifactPaths.set(pathname, artifact);
|
|
}
|
|
|
|
/** Serves a built island bundle, chunk, or the island mount runtime. */
|
|
export function serveIslandArtifact(pathname: string): Response | null {
|
|
const artifact = islandArtifactPaths.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 {
|
|
file = resolve(file);
|
|
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();
|
|
}
|