release: WRNexusJS 0.8.0
This commit is contained in:
@@ -15,7 +15,13 @@ import {
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, basename, extname, resolve } from "node:path";
|
||||
import { compile, generateTargets, resolveWrnImports, type PageAst } from "@wrnexus/compiler";
|
||||
import {
|
||||
compile,
|
||||
generate,
|
||||
generateTargets,
|
||||
resolveWrnImports,
|
||||
type PageAst,
|
||||
} from "@wrnexus/compiler";
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
/**
|
||||
@@ -60,6 +66,18 @@ interface CompileImportOptions {
|
||||
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 } = {},
|
||||
@@ -142,34 +160,93 @@ function rewriteArtifactImports(
|
||||
return output;
|
||||
}
|
||||
|
||||
export function loadModule(file: string): Promise<Record<string, unknown>> {
|
||||
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, "\\$&");
|
||||
output = output.replace(
|
||||
new RegExp(`(["'])${escaped}\\1`, "g"),
|
||||
JSON.stringify(pathToFileURL(replacement).href),
|
||||
);
|
||||
}
|
||||
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 (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/"))
|
||||
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 = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`;
|
||||
} else replacement = target === "server" ? dependency.server : dependency.main;
|
||||
}
|
||||
const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const specifier = 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) {
|
||||
const version = moduleVersions.get(file) ?? 0;
|
||||
// `.wrn` files are compiled to TypeScript first, then imported.
|
||||
let target = file.endsWith(".wrn") ? compileWireArtifacts(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.
|
||||
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 */
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
@@ -228,7 +305,18 @@ function validateConfiguredImports(source: string, ast: PageAst, file: string):
|
||||
const usedComponents = new Set(
|
||||
Array.from(source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g), (match) => match[1]!),
|
||||
);
|
||||
const missing = [...usedComponents].filter((name) => !imported.has(name));
|
||||
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)];
|
||||
@@ -250,6 +338,98 @@ export interface WrnCompileArtifacts {
|
||||
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>>();
|
||||
|
||||
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 () => {
|
||||
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 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);
|
||||
writeFileSync(
|
||||
artifacts[target],
|
||||
await devCompilerPipeline!.transformCode(rewritten, file),
|
||||
"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;
|
||||
@@ -270,39 +450,52 @@ export function compileWireArtifacts(file: string, version = 0): WrnCompileArtif
|
||||
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.
|
||||
}
|
||||
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");
|
||||
return artifacts;
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user