release: WRNexusJS 0.8.0
This commit is contained in:
+185
-8
@@ -21,15 +21,19 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, extname, join, resolve } from "node:path";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { buildRouter, type Route } from "@wrnexus/router";
|
||||
import { getReactiveRuntime } from "@wrnexus/csr";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
assertValidAst,
|
||||
generate,
|
||||
parse,
|
||||
type RuntimeRequirements,
|
||||
type DeploymentRuntime,
|
||||
runtimeCapabilities,
|
||||
resolveWrnImports,
|
||||
} from "@wrnexus/compiler";
|
||||
import {
|
||||
loadAppConfig,
|
||||
@@ -64,6 +68,38 @@ const INLINE_CSS_LIMIT_BYTES = 4096;
|
||||
|
||||
const fwd = (p: string) => p.replace(/\\/g, "/");
|
||||
|
||||
function deploymentRuntime(adapter: string | undefined): DeploymentRuntime | undefined {
|
||||
if (!adapter) return "bun";
|
||||
if (["bun", "node", "edge", "worker", "service-worker", "browser"].includes(adapter))
|
||||
return adapter as DeploymentRuntime;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateRuntimeCapabilities(appRoot: string, adapter?: string): void {
|
||||
const runtime = deploymentRuntime(adapter);
|
||||
if (!runtime || runtime === "bun" || runtime === "node") return;
|
||||
const appDir = join(resolve(appRoot), "app");
|
||||
if (!existsSync(appDir)) return;
|
||||
const files: string[] = [];
|
||||
const walk = (directory: string) => {
|
||||
for (const name of readdirSync(directory)) {
|
||||
const file = join(directory, name);
|
||||
const stat = statSync(file);
|
||||
if (stat.isDirectory()) walk(file);
|
||||
else if (/\.(?:[cm]?[jt]s|wrn)$/.test(file)) files.push(file);
|
||||
}
|
||||
};
|
||||
walk(appDir);
|
||||
const diagnostics = files.flatMap((file) =>
|
||||
analyzeRuntimeImports(readFileSync(file, "utf8"), runtime).map(
|
||||
(diagnostic) =>
|
||||
`${fwd(file.slice(resolve(appRoot).length + 1))}: ${diagnostic.code} ${diagnostic.message}`,
|
||||
),
|
||||
);
|
||||
if (diagnostics.length)
|
||||
throw new Error(`Runtime capability validation failed:\n${diagnostics.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function runBuild(appRoot: string): Promise<void> {
|
||||
const root = resolve(appRoot);
|
||||
const appDir = join(root, "app");
|
||||
@@ -72,6 +108,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const reactivePath = join(distDir, "reactive.js");
|
||||
const publicDir = join(root, "public");
|
||||
const distPublicDir = join(distDir, "public");
|
||||
const config = await loadAppConfig(root);
|
||||
validateRuntimeCapabilities(root, config.build?.adapter);
|
||||
|
||||
console.log(`Building ${appDir} -> ${distDir}`);
|
||||
|
||||
@@ -83,11 +121,14 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
console.log(`✓ Public: ${distPublicDir}`);
|
||||
}
|
||||
|
||||
const config = await loadAppConfig(root);
|
||||
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
|
||||
includeDevDependencies: true,
|
||||
strict: true,
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
runtime: deploymentRuntime(config.build?.adapter),
|
||||
capabilities: [...runtimeCapabilities(deploymentRuntime(config.build?.adapter) ?? "bun")],
|
||||
enforcePermissions: config.pluginPermissions?.enforce,
|
||||
grantedPermissions: config.pluginPermissions?.grants,
|
||||
});
|
||||
const pluginRunner = createPluginRunner(discoveredPlugins, {
|
||||
root,
|
||||
@@ -100,8 +141,27 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
await pluginRunner.configure(config as Record<string, unknown>);
|
||||
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
|
||||
const pluginContributions = await pluginRunner.contributions();
|
||||
const { generateApplicationTypes } = await import("./types.ts");
|
||||
generateApplicationTypes(root, pluginContributions);
|
||||
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
|
||||
await pluginRunner.hook("buildStart");
|
||||
const virtualModules = new Map<string, string>();
|
||||
for (const [index, module] of pluginContributions.virtualModules.entries()) {
|
||||
const output = join(compiledDir, `virtual-${index}.ts`);
|
||||
writeFileSync(
|
||||
output,
|
||||
await module.load({
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
virtualModules.set(module.id, output);
|
||||
}
|
||||
|
||||
// `.wrn` route files are compiled once into deterministic intermediate modules.
|
||||
// Plugin AST/code transforms run only when configured, so existing applications
|
||||
@@ -109,12 +169,18 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
let compiledCount = 0;
|
||||
const compiledFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const partialStaticFiles = new Set<string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
// Reserve the artifact before resolving imports so cycles terminate and
|
||||
// mutually dependent generated modules can point at deterministic paths.
|
||||
compiledFiles.set(file, out);
|
||||
const source = readFileSync(file, "utf8");
|
||||
let ast = parse(source);
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
@@ -126,11 +192,46 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
const out = join(compiledDir, `route${compiledCount++}.ts`);
|
||||
writeFileSync(out, code, "utf8");
|
||||
compiledFiles.set(file, out);
|
||||
try {
|
||||
let code = `// compiled from .wrn\n${generate(ast)}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
for (const [id, target] of virtualModules) {
|
||||
const sourcePattern = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
for (const imported of resolvedImports) {
|
||||
if (imported.diagnostic?.severity === "error") {
|
||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
||||
}
|
||||
if (!imported.resolved || !imported.declaration.source.startsWith(".")) continue;
|
||||
let target = imported.resolved;
|
||||
if (target.endsWith(".wrn")) {
|
||||
await compileWrn(target);
|
||||
target = compiledFiles.get(target)!;
|
||||
}
|
||||
const sourcePattern = imported.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/");
|
||||
const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`;
|
||||
code = code.replace(
|
||||
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
|
||||
JSON.stringify(specifier),
|
||||
);
|
||||
}
|
||||
writeFileSync(out, code, "utf8");
|
||||
} catch (error) {
|
||||
compiledFiles.delete(file);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const importPathFor = (file: string): string => fwd(compiledFiles.get(file) ?? file);
|
||||
// Regenerate typed DB queries (app/db/queries/*.sql → queries.gen.ts) first, so
|
||||
@@ -235,6 +336,37 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
...router.layouts.map((layout) => layout.file),
|
||||
]);
|
||||
for (const file of wrnFiles) await compileWrn(file);
|
||||
const partialShells = new Map<string, { shell: string; regions: number }>();
|
||||
const partialPages = router.pages.filter((route) => partialStaticFiles.has(route.file));
|
||||
if (partialPages.length > 0) {
|
||||
const { precomputePartialStaticShell } = await import("@wrnexus/dev-server");
|
||||
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||
const loadCompiled = async (file: string): Promise<Record<string, unknown>> => {
|
||||
const existing = moduleCache.get(file);
|
||||
if (existing) return existing;
|
||||
const output = compiledFiles.get(file);
|
||||
if (!output) throw new Error(`WRN-PARTIAL-STATIC-MODULE: ${file} was not compiled`);
|
||||
const loaded = (await import(pathToFileURL(output).href)) as Record<string, unknown>;
|
||||
moduleCache.set(file, loaded);
|
||||
return loaded;
|
||||
};
|
||||
const components = await Promise.all(
|
||||
router.components.map(async (component) => ({
|
||||
name: component.name,
|
||||
mod: await loadCompiled(component.file),
|
||||
})),
|
||||
);
|
||||
for (const route of partialPages) {
|
||||
const result = await precomputePartialStaticShell(await loadCompiled(route.file), components);
|
||||
partialShells.set(route.raw, result);
|
||||
}
|
||||
writeFileSync(
|
||||
join(distDir, "partial-shells.json"),
|
||||
JSON.stringify(Object.fromEntries(partialShells), null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
console.log(`✓ Partial shells: ${partialShells.size} build-time route shell(s)`);
|
||||
}
|
||||
const assetHash = createHash("sha256");
|
||||
const emittedPluginAssets = await emitPluginAssets(
|
||||
pluginContributions,
|
||||
@@ -339,7 +471,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const parts = routes.map((r) => {
|
||||
const v = `m${counter++}`;
|
||||
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(r.file))};`);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v} },`;
|
||||
const partial = partialShells.get(r.raw);
|
||||
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v}${partial ? `, staticShell: ${JSON.stringify(partial.shell)}` : ""} },`;
|
||||
});
|
||||
return parts.length ? `\n${parts.join("\n")}\n ` : "";
|
||||
};
|
||||
@@ -428,6 +561,7 @@ await createProductionServer(
|
||||
observability: ${JSON.stringify(config.observability ?? {})},
|
||||
tenancy: ${JSON.stringify(config.tenancy ?? {})},
|
||||
navigation: ${JSON.stringify(config.navigation ?? {})},
|
||||
developmentRuntime: process.env.WRNEXUS_PRODUCTION_DEV === "1",
|
||||
},
|
||||
);
|
||||
`;
|
||||
@@ -485,12 +619,32 @@ await createProductionServer(
|
||||
source: migration.entry ?? "inline",
|
||||
}));
|
||||
report.componentDirs = componentDirs.map(fwd);
|
||||
report.partialStaticShells = [...partialShells].map(([route, value]) => ({
|
||||
route,
|
||||
regions: value.regions,
|
||||
bytes: Buffer.byteLength(value.shell, "utf8"),
|
||||
}));
|
||||
const violations = checkPerformanceBudgets(
|
||||
config.performance?.budgets ?? {},
|
||||
report.measurements,
|
||||
);
|
||||
report.budgetViolations = violations;
|
||||
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
||||
const contributedAdapter = pluginContributions.deploymentAdapters.find(
|
||||
(adapter) => adapter.name === config.build?.adapter,
|
||||
);
|
||||
if (contributedAdapter)
|
||||
await contributedAdapter.build(
|
||||
{ distDir, report },
|
||||
{
|
||||
root,
|
||||
mode: "production",
|
||||
command: "build",
|
||||
profile: process.env.WRNEXUS_PROFILE,
|
||||
metadata: new Map(),
|
||||
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
|
||||
},
|
||||
);
|
||||
await pluginRunner.hook("buildEnd", report);
|
||||
|
||||
console.log(`✓ Server: ${join(distDir, "server.js")}`);
|
||||
@@ -636,6 +790,7 @@ interface BuildReport {
|
||||
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
|
||||
migrations?: Array<{ id: string; database: string; source: string }>;
|
||||
componentDirs?: string[];
|
||||
partialStaticShells?: Array<{ route: string; regions: number; bytes: number }>;
|
||||
generatedAt: string;
|
||||
root: string;
|
||||
adapter: string;
|
||||
@@ -650,6 +805,10 @@ interface BuildReport {
|
||||
needsClientRuntime: boolean;
|
||||
needsServerRuntime: boolean;
|
||||
hydrationStrategy: string | null;
|
||||
reasons: string[];
|
||||
optimization: RuntimeRequirements["optimization"];
|
||||
cachePolicy: Record<string, string>;
|
||||
requiredPermission: string | null;
|
||||
}>;
|
||||
assets: Array<{ file: string; bytes: number }>;
|
||||
measurements: { routeJsBytes: number; routeCssBytes: number; imageBytes: number };
|
||||
@@ -709,6 +868,24 @@ function createBuildReport(input: {
|
||||
needsClientRuntime: input.runtimeAnalysis.get(route.file)?.needsClientRuntime ?? true,
|
||||
needsServerRuntime: input.runtimeAnalysis.get(route.file)?.needsServerRuntime ?? true,
|
||||
hydrationStrategy: input.runtimeAnalysis.get(route.file)?.hydrationStrategy ?? null,
|
||||
reasons: input.runtimeAnalysis.get(route.file)?.reasons ?? [
|
||||
"runtime requirements unavailable",
|
||||
],
|
||||
optimization: input.runtimeAnalysis.get(route.file)?.optimization ?? {
|
||||
staticNodes: 0,
|
||||
reactiveRegions: 0,
|
||||
eliminatedBranches: 0,
|
||||
unusedState: [],
|
||||
unusedHandlers: [],
|
||||
constantProps: [],
|
||||
unusedLocalCssClasses: [],
|
||||
batchableStateUpdates: 0,
|
||||
memoizableComponents: [],
|
||||
preloadDependencies: [],
|
||||
serverOnlyModules: [],
|
||||
},
|
||||
cachePolicy: input.runtimeAnalysis.get(route.file)?.cachePolicy ?? {},
|
||||
requiredPermission: input.runtimeAnalysis.get(route.file)?.requiredPermission ?? null,
|
||||
})),
|
||||
assets,
|
||||
measurements: {
|
||||
|
||||
Reference in New Issue
Block a user