|
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
|
|
unlinkSync,
|
|
|
|
|
existsSync,
|
|
|
|
|
} from "node:fs";
|
|
|
|
|
import { dirname, join, basename, extname, resolve } from "node:path";
|
|
|
|
|
import { dirname, join, basename, extname, relative, resolve } from "node:path";
|
|
|
|
|
import {
|
|
|
|
|
compile,
|
|
|
|
|
generate,
|
|
|
|
@@ -91,6 +91,16 @@ export function setCompileImportOptions(
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
@@ -101,6 +111,19 @@ function projectRootForFile(file: string): string {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
@@ -132,8 +155,7 @@ function rewriteArtifactImports(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!entry.resolved || !entry.declaration.source) continue;
|
|
|
|
|
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
|
|
|
|
continue;
|
|
|
|
|
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
|
|
|
|
let replacement = entry.resolved;
|
|
|
|
|
if (entry.resolved.endsWith(".wrn")) {
|
|
|
|
|
const dependencySource = readFileSync(entry.resolved, "utf8");
|
|
|
|
@@ -148,13 +170,22 @@ function rewriteArtifactImports(
|
|
|
|
|
output = output.replace(entry.declaration.raw, "");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
|
|
|
|
replacement = dependency.browser;
|
|
|
|
|
} else {
|
|
|
|
|
replacement = target === "server" ? dependency.server : dependency.main;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
|
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
|
|
|
|
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;
|
|
|
|
@@ -169,10 +200,15 @@ async function rewriteArtifactImportsAsync(
|
|
|
|
|
let output = code;
|
|
|
|
|
for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) {
|
|
|
|
|
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
|
output = output.replace(
|
|
|
|
|
new RegExp(`(["'])${escaped}\\1`, "g"),
|
|
|
|
|
JSON.stringify(pathToFileURL(replacement).href),
|
|
|
|
|
);
|
|
|
|
|
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);
|
|
|
|
@@ -189,8 +225,7 @@ async function rewriteArtifactImportsAsync(
|
|
|
|
|
for (const entry of resolved) {
|
|
|
|
|
if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message);
|
|
|
|
|
if (!entry.resolved || !entry.declaration.source) continue;
|
|
|
|
|
if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
|
|
|
|
continue;
|
|
|
|
|
if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue;
|
|
|
|
|
let replacement = entry.resolved;
|
|
|
|
|
if (replacement.endsWith(".wrn")) {
|
|
|
|
|
const dependencySource = readFileSync(replacement, "utf8");
|
|
|
|
@@ -204,11 +239,20 @@ async function rewriteArtifactImportsAsync(
|
|
|
|
|
output = output.replace(entry.declaration.raw, "");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
|
|
|
|
replacement = dependency.browser;
|
|
|
|
|
} else replacement = target === "server" ? dependency.server : dependency.main;
|
|
|
|
|
}
|
|
|
|
|
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
|
const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href;
|
|
|
|
|
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;
|
|
|
|
@@ -257,14 +301,14 @@ export async function loadModule(file: string): Promise<Record<string, unknown>>
|
|
|
|
|
* When unset, compilation falls back to a sibling `.wrnexus/` next to each file.
|
|
|
|
|
*/
|
|
|
|
|
let compileCacheDir: string | null = null;
|
|
|
|
|
const WRN_COMPILE_CACHE_VERSION = "v2";
|
|
|
|
|
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): void {
|
|
|
|
|
export function setCompileCacheDir(dir: string | null): void {
|
|
|
|
|
compileCacheDir = dir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -357,18 +401,68 @@ const compileMetrics: WrnCompileMetrics = {
|
|
|
|
|
};
|
|
|
|
|
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> {
|
|
|
|
|
if (!devCompilerPipeline) return Promise.resolve(compileWireArtifacts(file, version));
|
|
|
|
|
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)}${suffix}`;
|
|
|
|
|
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`),
|
|
|
|
@@ -394,11 +488,11 @@ export function compileWireArtifactsAsync(file: string, version = 0): Promise<Wr
|
|
|
|
|
};
|
|
|
|
|
for (const target of ["main", "browser", "server"] as const) {
|
|
|
|
|
const rewritten = await rewriteArtifactImportsAsync(outputs[target], ast, file, target);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
artifacts[target],
|
|
|
|
|
await devCompilerPipeline!.transformCode(rewritten, file),
|
|
|
|
|
"utf8",
|
|
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
@@ -437,7 +531,7 @@ export function compileWireArtifacts(file: string, version = 0): WrnCompileArtif
|
|
|
|
|
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)}${suffix}`;
|
|
|
|
|
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`),
|
|
|
|
@@ -516,6 +610,12 @@ export function wrnBrowserArtifactUrl(file: string): string {
|
|
|
|
|
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;
|
|
|
|
|