Files
WRNexusJS/packages/cli/src/build.ts
T
ClintchizandClaude Opus 5 949cf78636
Quality / quality (ubuntu-latest) (push) Failing after 13m40s
Quality / quality (windows-latest) (push) Canceled after 0s
feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs
DataTable replaces the 20-line Table scaffold entirely: columns, sorting,
filtering, pagination, selection, bulk actions, comparison layout, sticky
first column, custom HTML cells, and a remote source driven by a `request`
output rather than a function prop (props travel as HTML attributes, so a
function arrives as its own source text).

Toaster replaces the hand-rolled status div: tone icons, actions, hover
pause/resume and a progress bar.

Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing
ever moved focus into the panel, so the @keydown handler on their root
never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap
and a body scroll lock now live in the reactive runtime, shared by both.

ContextMenu placed pointer menus by subtracting a guessed 340x420 from the
viewport, which pushed every menu that was not that size away from the
pointer; it now positions at the pointer and lets the anchored clamp pull
it back once it can be measured.

The reactive runtime size budget moves 150k -> 175k to cover anchored
overlays, dialog behaviour, the toaster and the DataTable client half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:59:58 +05:30

1090 lines
43 KiB
TypeScript

/**
* `wrnexus build` — production build (Point 4).
*
* Emits into `<appRoot>/dist`:
* islands/<name>.js pre-built, minified island chunks
* server.js a self-contained Bun server with a STATIC manifest of
* every page/api/realtime/middleware module (no runtime
* filesystem scan, no on-the-fly bundling)
*
* Run it with: bun dist/server.js (PORT env var optional)
*/
import { createHash } from "node:crypto";
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
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,
generateTargets,
parse,
type RuntimeRequirements,
type DeploymentRuntime,
runtimeCapabilities,
resolveWrnImports,
} from "@wrnexus/compiler";
import {
loadAppConfig,
headToString,
renderProductionFontHead,
findStyleEntry,
bundleCss,
renderStyles,
resolveThemeConfig,
renderThemeCss,
renderThemeRuntime,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCss } from "@wrnexus/ui/registry";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
import { checkPerformanceBudgets } from "@wrnexus/core";
import { currentCliVersion } from "./update-notifier.ts";
import {
createPluginRunner,
discoverPlugins,
contentTypeForPath,
type ClientRuntimeDefinition,
type PackageAssetDefinition,
type PluginContributions,
} from "@wrnexus/plugin";
// Import the production server from the package specifier (not a source path) so
// the generated entry resolves whether @wrnexus/dev-server is a workspace or an
// installed dependency. Bun.build bundles it into a self-contained server.js.
const PROD_MODULE = "@wrnexus/dev-server";
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");
const distDir = join(root, "dist");
const compiledDir = join(distDir, "compiled");
const clientModulesDir = join(distDir, "client");
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}`);
// Clean output.
rmSync(distDir, { recursive: true, force: true });
mkdirSync(compiledDir, { recursive: true });
mkdirSync(clientModulesDir, { recursive: true });
if (existsSync(publicDir)) {
cpSync(publicDir, distPublicDir, { recursive: true });
console.log(`✓ Public: ${distPublicDir}`);
}
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,
mode: "production",
command: "build",
profile: process.env.WRNEXUS_PROFILE,
metadata: new Map(),
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
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
// keep the exact compiler path and output contract by default.
let compiledCount = 0;
const compiledFiles = new Map<string, string>();
const clientFiles = 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 source = readFileSync(file, "utf8");
const artifactId = compiledCount++;
const out = join(compiledDir, `route${artifactId}.ts`);
const clientHash = createHash("sha256")
.update(fwd(file))
.update("\0")
.update(source)
.digest("hex")
.slice(0, 16);
const clientOut = join(clientModulesDir, `${basename(file, ".wrn")}-${clientHash}.mjs`);
const clientUrl = `/__wrnexus/client/${basename(clientOut)}`;
const clientEntry = join(compiledDir, `client${artifactId}.entry.mjs`);
// Reserve both artifacts before resolving imports so dependency cycles
// terminate and every generated import has a deterministic target.
compiledFiles.set(file, out);
clientFiles.set(file, clientOut);
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");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
console.warn(`[${diagnostic.code}] ${file}: ${diagnostic.message}`);
}
if (errors.length) {
throw new Error(
errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"),
);
}
try {
const targets = generateTargets(ast);
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
code = await pluginRunner.transformCode(code, file);
browserCode = await pluginRunner.transformCode(browserCode, file);
for (const [id, target] of virtualModules) {
const sourcePattern = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mainRelative = relative(dirname(out), target).replace(/\\/g, "/");
const mainSpecifier = mainRelative.startsWith(".") ? mainRelative : `./${mainRelative}`;
code = code.replace(
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
JSON.stringify(mainSpecifier),
);
const browserRelative = relative(dirname(clientEntry), target).replace(/\\/g, "/");
const browserSpecifier = browserRelative.startsWith(".")
? browserRelative
: `./${browserRelative}`;
browserCode = browserCode.replace(
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
JSON.stringify(browserSpecifier),
);
}
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) continue;
const sourcePattern = imported.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const importAliases = { "@": "./app", ...(config.imports?.aliases ?? {}) };
const isApplicationImport =
imported.declaration.source.startsWith(".") ||
Object.keys(importAliases).some(
(alias) =>
imported.declaration.source === alias ||
imported.declaration.source.startsWith(`${alias}/`),
);
if (isApplicationImport) {
let target = imported.resolved;
if (target.endsWith(".wrn")) {
await compileWrn(target);
target = compiledFiles.get(target)!;
}
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),
);
}
// Browser modules keep only imports referenced by client/shared code.
// Resolve those imports relative to the original .wrn source before
// bundling so no filesystem or bare package specifier reaches browsers.
if (isApplicationImport && new RegExp(`(["'])${sourcePattern}\\1`).test(browserCode)) {
let browserTarget = imported.resolved;
if (browserTarget.endsWith(".wrn")) {
await compileWrn(browserTarget);
browserTarget = clientFiles.get(browserTarget)!;
}
const browserRelative = relative(dirname(clientEntry), browserTarget).replace(/\\/g, "/");
const browserSpecifier = browserRelative.startsWith(".")
? browserRelative
: `./${browserRelative}`;
browserCode = browserCode.replace(
new RegExp(`(["'])${sourcePattern}\\1`, "g"),
JSON.stringify(browserSpecifier),
);
}
}
writeFileSync(out, code, "utf8");
writeFileSync(clientEntry, browserCode, "utf8");
const browserResult = await Bun.build({
entrypoints: [clientEntry],
target: "browser",
format: "esm",
splitting: false,
minify: true,
sourcemap: config.build?.sourceMaps ? "inline" : "none",
});
if (!browserResult.success || !browserResult.outputs.length) {
throw new Error(
`WRN-CLIENT-BUNDLE: failed to bundle ${file}\n${browserResult.logs.map(String).join("\n")}`,
);
}
writeFileSync(clientOut, await browserResult.outputs[0]!.text(), "utf8");
rmSync(clientEntry, { force: true });
} catch (error) {
compiledFiles.delete(file);
clientFiles.delete(file);
rmSync(out, { force: true });
rmSync(clientOut, { force: true });
rmSync(clientEntry, { force: true });
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
// any page/API importing them is built against the current SQL.
const { regenerateQueries } = await import("./db.ts");
const generated = await regenerateQueries(appDir, config.db?.driver);
if (generated >= 0) console.log(`✓ Queries: ${generated} (db/queries.gen.ts)`);
for (const [name, cfg] of Object.entries(config.databases ?? {})) {
const n = await regenerateQueries(appDir, cfg.driver, name);
if (n >= 0) console.log(`✓ Queries: ${n} (db/${name}/queries.gen.ts)`);
}
// Bundle application and package-owned DB migrations into the build. Package
// files are namespaced by contribution id so independently installed systems
// cannot collide with application migration filenames.
const safeMigrationName = (value: string): string =>
value.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_") || "migration";
let hasDefaultMigrations = false;
const namedMigrationDbs = new Set<string>();
const migrationTarget = (database?: string): string => {
const target = database?.trim() || "default";
if (target === "default") {
if (!config.db) {
throw new Error(
"WRN-PLUGIN-MIGRATION-DATABASE: a package targets the default database, but config.db is not configured.",
);
}
hasDefaultMigrations = true;
return join(distDir, "migrations");
}
if (!config.databases?.[target]) {
throw new Error(
`WRN-PLUGIN-MIGRATION-DATABASE: package migration targets unknown database '${target}'.`,
);
}
namedMigrationDbs.add(target);
return join(distDir, "db", target, "migrations");
};
const defaultMigrationsSrc = join(appDir, "db", "migrations");
if (config.db && existsSync(defaultMigrationsSrc)) {
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
hasDefaultMigrations = true;
console.log("✓ Migrations: dist/migrations");
}
for (const name of Object.keys(config.databases ?? {})) {
const source = join(appDir, "db", name, "migrations");
if (!existsSync(source)) continue;
cpSync(source, join(distDir, "db", name, "migrations"), { recursive: true });
namedMigrationDbs.add(name);
console.log(`✓ Migrations: dist/db/${name}/migrations`);
}
for (const migration of pluginContributions.migrations) {
const destination = migrationTarget(migration.database);
mkdirSync(destination, { recursive: true });
const prefix = safeMigrationName(migration.id);
if (migration.source !== undefined) {
writeFileSync(join(destination, `${prefix}.sql`), migration.source, "utf8");
continue;
}
if (!migration.entry || !existsSync(migration.entry)) {
throw new Error(
`WRN-PLUGIN-MIGRATION-MISSING: ${migration.id} points to ${migration.entry ?? "<empty>"}.`,
);
}
const stat = statSync(migration.entry);
if (stat.isDirectory()) {
for (const file of readdirSync(migration.entry)
.filter((name) => name.endsWith(".sql"))
.sort()) {
cpSync(
join(migration.entry, file),
join(destination, `${prefix}__${safeMigrationName(file)}`),
);
}
} else if (stat.isFile() && migration.entry.endsWith(".sql")) {
cpSync(
migration.entry,
join(destination, `${prefix}__${safeMigrationName(basename(migration.entry))}`),
);
} else {
throw new Error(
`WRN-PLUGIN-MIGRATION-ENTRY: ${migration.id} must be a .sql file or directory.`,
);
}
}
if (pluginContributions.migrations.length) {
console.log(`✓ Package migrations: ${pluginContributions.migrations.length}`);
}
const router = buildRouter(appDir, {
componentDirs,
externalRoutes: pluginContributions.routes,
middlewareFiles: pluginContributions.middleware,
});
const wrnFiles = new Set([
...router.pages.map((route) => route.file),
...router.api.map((route) => route.file),
...router.realtime.map((route) => route.file),
...router.components.map((component) => component.file),
...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");
for (const file of clientFiles.values()) assetHash.update(readFileSync(file));
if (clientFiles.size) console.log(`✓ Client modules: ${clientFiles.size} bundled module(s)`);
const emittedPluginAssets = await emitPluginAssets(
pluginContributions,
distDir,
config.build?.sourceMaps === true,
);
for (const asset of emittedPluginAssets.assets) assetHash.update(readFileSync(asset.file));
if (emittedPluginAssets.assets.length) {
console.log(
`✓ Plugin assets: ${emittedPluginAssets.assets.length} (${emittedPluginAssets.runtimes.length} runtimes)`,
);
}
// 1) Components are `.wrn` modules rendered server-side — no browser chunks.
// They are compiled + statically imported into the manifest below.
const reactiveCode = await buildBrowserRuntime(
getReactiveRuntime(),
reactivePath,
join(compiledDir, "reactive.entry.js"),
);
assetHash.update(reactiveCode);
console.log(`✓ Runtime: ${reactivePath}`);
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
const theme = resolveThemeConfig(config.theme);
const themeCss = renderThemeCss(theme);
const themeJs = renderThemeRuntime(theme);
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
writeFileSync(join(distDir, "theme.js"), themeJs, "utf8");
assetHash.update(themeCss);
assetHash.update(themeJs);
console.log(`✓ Theme: ${theme.names.length} themes (default: ${theme.default})`);
// 1a2) Wire UI stylesheet (all component classes, themed via tokens).
const uiStyles = config.styles?.includeUi === false ? "" : uiCss();
writeFileSync(join(distDir, "ui.css"), uiStyles, "utf8");
const frameworkStyles = `${themeCss}\n${uiStyles}`;
writeFileSync(join(distDir, "framework.css"), frameworkStyles, "utf8");
assetHash.update(uiStyles);
console.log(
config.styles?.includeUi === false
? `✓ UI: omitted by styles.includeUi`
: `✓ UI: dist/ui.css + framework.css`,
);
// 1a3) Validation: bake schema descriptors into the client script.
const descriptors: Record<string, SchemaDescriptor> = {};
for (const s of router.schemas) {
const mod = (await import(pathToFileURL(s.file).href)) as { default?: ObjectSchema };
if (mod.default && typeof mod.default.describe === "function") {
descriptors[s.name] = mod.default.describe();
}
}
const schemasJs = renderSchemasScript(descriptors);
assetHash.update(schemasJs);
if (router.schemas.length) console.log(`✓ Schemas: ${router.schemas.length}`);
// 1a4) i18n: bake locale messages into the manifest (opt-in via app/locales).
const localeMessages = loadLocales(join(appDir, "locales"));
const i18n = Object.keys(localeMessages).length
? resolveI18n(localeMessages, config.i18n)
: undefined;
if (i18n) console.log(`✓ i18n: ${i18n.langs.length} locales (default: ${i18n.default})`);
// 1b) Build the global stylesheet, if any.
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
let hasStyles = false;
let inlineStyles = "";
const hasPackageStyleEntries = pluginContributions.styles.some((style) => !!style.entry);
if (styleEntry || hasPackageStyleEntries) {
const css = await renderStyles(
{
entryPath: styleEntry,
appDir,
appRoot: root,
mode: "production",
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
config.styles,
);
const combinedSource = `${frameworkStyles}\n${css}`;
const combinedInput = join(distDir, ".wrnexus-combined.css");
writeFileSync(combinedInput, combinedSource, "utf8");
const combinedCss = await bundleCss(combinedInput, "production");
rmSync(combinedInput, { force: true });
assetHash.update(combinedCss);
// One blocking CSS request in production: tokens → UI → application CSS.
// The standalone framework.css remains available for apps without global CSS.
writeFileSync(join(distDir, "styles.css"), combinedCss, "utf8");
hasStyles = true;
if (Buffer.byteLength(combinedCss, "utf8") <= INLINE_CSS_LIMIT_BYTES) {
inlineStyles = combinedCss;
}
console.log(`✓ Styles: ${join(distDir, "styles.css")}`);
}
const assetVersion = assetHash.digest("hex").slice(0, 12);
const headStr = [await renderProductionFontHead(config.fonts), headToString(config.head)]
.filter(Boolean)
.join("\n ");
// 2) Generate a server entry with STATIC imports + a manifest.
const imports: string[] = [];
let counter = 0;
// Authorization: emit a small side-effecting module that statically imports
// every app/authz/*.ts declaration and calls setAuthzCatalog EAGERLY, then
// import THAT MODULE FIRST — before pages/api/realtime/middleware/
// components/layouts — so it runs before any other static import's module
// body, including app middleware that reads getAuthzCatalog() at module
// scope (the same eager shape authzMiddleware({ catalog, ... }) itself
// requires; app/middleware/logger.ts's `export default requestLogger({...})`
// is the same pattern). ES modules evaluate every static import before the
// importing module's own body runs, and evaluate sibling imports in
// declaration order — so import POSITION is evaluation order, and this
// must be imports[0], strictly before every other push into `imports`
// below (in particular before any `mw*` import). This module is
// deliberately silent about a missing default export (see
// applyAuthzManifestEarly in @wrnexus/dev-server): createProductionHandlers
// performs the identical merge again, with its warnings, as an idempotent
// second pass — both for adapters that bypass this generated entry and to
// avoid warning twice about the same declaration in the normal path.
{
let authzSetupCounter = 0;
const authzSetupImports: string[] = [];
const authzSetupEntries = router.authz
.map((a) => {
const v = `d${authzSetupCounter++}`;
authzSetupImports.push(`import * as ${v} from ${JSON.stringify(fwd(a.file))};`);
return `{ source: ${JSON.stringify(fwd(a.file))}, module: ${v}.default }`;
})
.join(", ");
const authzSetupContent = `// AUTO-GENERATED authz catalog setup — do not edit.
// Imported FIRST by the production entry (see the "Authorization" comment
// there) so getAuthzCatalog() is populated before any other static import's
// module body runs.
import { applyAuthzManifestEarly } from "@wrnexus/dev-server";
${authzSetupImports.join("\n")}
applyAuthzManifestEarly([${authzSetupEntries}]);
`;
writeFileSync(join(distDir, ".authz-setup.ts"), authzSetupContent, "utf8");
imports.push(`import "./.authz-setup.ts";`);
}
const manifestRoutes = (routes: Route[]): string => {
const parts = routes.map((r) => {
const v = `m${counter++}`;
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(r.file))};`);
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 ` : "";
};
const pagesLit = manifestRoutes(router.pages);
const apiLit = manifestRoutes(router.api);
const realtimeLit = manifestRoutes(router.realtime);
const mwVars = router.middlewareFiles.map((file) => {
const v = `mw${counter++}`;
imports.push(`import ${v} from ${JSON.stringify(fwd(file))};`);
return v;
});
// Components: compile each `.wrn` to a module and statically import it,
// keyed by name so the production runtime can render it on demand.
const componentsLit = router.components
.map((c) => {
const v = `c${counter++}`;
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(c.file))};`);
return `{ name: ${JSON.stringify(c.name)}, mod: ${v} }`;
})
.join(", ");
console.log(`✓ Components: ${router.components.length}`);
// Named page layouts (app/layouts/*.wrn), compiled + imported like components.
const layoutsLit = router.layouts
.map((l) => {
const v = `c${counter++}`;
imports.push(`import * as ${v} from ${JSON.stringify(importPathFor(l.file))};`);
return `{ name: ${JSON.stringify(l.name)}, mod: ${v} }`;
})
.join(", ");
if (router.layouts.length) console.log(`✓ Layouts: ${router.layouts.length}`);
// RPC services are server-only modules. Production statically imports them
// so the runtime can dispatch private calls without filesystem discovery.
const servicesLit = router.services
.map((service) => {
const v = `s${counter++}`;
imports.push(`import * as ${v} from ${JSON.stringify(fwd(service.file))};`);
return `{ name: ${JSON.stringify(service.name)}, mod: ${v} }`;
})
.join(", ");
if (router.services.length) console.log(`✓ RPC services: ${router.services.length}`);
// Authorization declarations again, this time for ProdOptions.authz — a
// SEPARATE set of static imports of the exact same files (harmless; ES
// modules are evaluated once and shared across every importer), statically
// imported like components/layouts — NOT baked into JSON like schemasJs,
// because the catalog contains policy FUNCTIONS, which JSON.stringify
// cannot carry. Each module is passed through by reference and merged
// AGAIN into the process-wide catalog by createProductionHandlers's second
// pass (prod.ts) — see the ".authz-setup.ts" block above for the EARLY,
// eager pass that actually makes the catalog visible to app middleware. A
// file with no default export becomes `module: undefined` here;
// createProductionHandlers warns and skips it, matching the dev loader
// (authz-boot.ts).
const authzLit = router.authz
.map((a) => {
const v = `az${counter++}`;
imports.push(`import * as ${v} from ${JSON.stringify(fwd(a.file))};`);
return `{ source: ${JSON.stringify(fwd(a.file))}, module: ${v}.default }`;
})
.join(", ");
if (router.authz.length) console.log(`✓ Authz: ${router.authz.length} declaration(s)`);
const entry = `// AUTO-GENERATED production server entry — do not edit.
import { join } from "node:path";
import { createProductionServer } from ${JSON.stringify(PROD_MODULE)};
${imports.join("\n")}
await createProductionServer(
{
pages: [${pagesLit}],
api: [${apiLit}],
realtime: [${realtimeLit}],
middleware: [${mwVars.join(", ")}],
components: [${componentsLit}],
layouts: [${layoutsLit}],
services: [${servicesLit}],
},
{
reactivePath: join(import.meta.dir, "reactive.js"),
clientModulesDir: join(import.meta.dir, "client"),
themePath: join(import.meta.dir, "theme.css"),
themeJsPath: join(import.meta.dir, "theme.js"),
theme: ${JSON.stringify(theme)},
uiCssPath: join(import.meta.dir, "ui.css"),
frameworkCssPath: join(import.meta.dir, "framework.css"),
schemasJs: ${JSON.stringify(schemasJs)},
authz: [${authzLit}],
i18n: ${i18n ? JSON.stringify(i18n) : "undefined"},
db: ${config.db ? JSON.stringify(config.db) : "undefined"},
databases: ${config.databases ? JSON.stringify(config.databases) : "undefined"},
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
${
namedMigrationDbs.size
? `databaseMigrationDirs: { ${[...namedMigrationDbs]
.map(
(n) =>
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
)
.join(", ")} },`
: ""
}
realtime: ${config.realtime ? JSON.stringify(config.realtime) : "undefined"},
publicDir: join(import.meta.dir, "public"),
${hasStyles ? `stylesPath: join(import.meta.dir, "styles.css"),` : ""}
${hasStyles ? `stylesIncludeFramework: true,` : ""}
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
assetVersion: ${JSON.stringify(assetVersion)},
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
pluginAssets: ${renderProductionPluginAssets(emittedPluginAssets.assets)},
head: ${JSON.stringify(headStr)},
seo: ${JSON.stringify(config.seo ?? {})},
mobile: ${JSON.stringify(config.mobile ?? {})},
pwa: ${JSON.stringify(config.pwa ?? {})},
security: ${JSON.stringify(config.security ?? {})},
observability: ${JSON.stringify(config.observability ?? {})},
tenancy: ${JSON.stringify(config.tenancy ?? {})},
navigation: ${JSON.stringify(config.navigation ?? {})},
developmentRuntime: process.env.WRNEXUS_PRODUCTION_DEV === "1",
},
);
`;
const entryPath = join(distDir, ".server-entry.ts");
writeFileSync(entryPath, entry, "utf8");
// 3) Bundle the entry into a single self-contained, minified server.js
// (target bun). This also minifies every bundled page/component/route module.
const result = await Bun.build({
entrypoints: [entryPath],
target: "bun",
format: "esm",
minify: true,
sourcemap: config.build?.sourceMaps ? "inline" : "none",
});
if (!result.success) {
throw new Error("Server build failed:\n" + result.logs.map(String).join("\n"));
}
writeFileSync(join(distDir, "server.js"), await result.outputs[0]!.text(), "utf8");
const report = createBuildReport({
root,
distDir,
publicDir: distPublicDir,
adapter: config.build?.adapter ?? "bun",
routes: [
...router.pages.map((route) => ({ kind: "page" as const, route })),
...router.api.map((route) => ({ kind: "api" as const, route })),
...router.realtime.map((route) => ({ kind: "realtime" as const, route })),
],
runtimeFile: reactivePath,
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
runtimeAnalysis,
});
report.pluginAssets = emittedPluginAssets.assets.map((asset) => ({
id: asset.id,
publicPath: asset.publicPath,
bytes: statSync(asset.file).size,
runtime: asset.runtime,
}));
report.plugins = pluginRunner.plugins.map((plugin) => ({
name: plugin.name,
version: plugin.version,
}));
report.clientRuntimes = emittedPluginAssets.runtimes.map((runtime) => ({
id: runtime.id,
publicPath: runtime.publicPath!,
type: runtime.type ?? "module",
load: runtime.load ?? "defer",
}));
report.migrations = pluginContributions.migrations.map((migration) => ({
id: migration.id,
database: migration.database ?? "default",
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")}`);
console.log(
`✓ Routes: ${router.pages.length} pages, ${router.api.length} api, ${router.realtime.length} realtime, ${mwVars.length} middleware`,
);
console.log(`✓ Report: ${join(distDir, "build-report.json")}`);
if (violations.length) {
for (const violation of violations) {
console.warn(
`⚠ Budget ${violation.metric}: ${violation.actual} > ${violation.budget} (+${violation.overBy})`,
);
}
if (config.performance?.enforcement === "error") {
throw new Error(
`WRN-PERFORMANCE-BUDGET: ${violations.length} production budget(s) exceeded.`,
);
}
}
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
}
interface EmittedPluginAsset {
id: string;
publicPath: string;
file: string;
contentType: string;
runtime: boolean;
immutable: boolean;
}
interface EmittedPluginAssets {
assets: EmittedPluginAsset[];
runtimes: ClientRuntimeDefinition[];
}
function safeAssetName(id: string): string {
return (
id
.replace(/^@/, "")
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "asset"
);
}
function sourceBytes(source: string | Uint8Array): Uint8Array {
return typeof source === "string" ? new TextEncoder().encode(source) : source;
}
async function compileRuntimeSource(
runtime: ClientRuntimeDefinition,
sourceMaps: boolean,
): Promise<Uint8Array> {
if (runtime.source !== undefined) return sourceBytes(runtime.source);
if (!runtime.entry) throw new Error(`Runtime '${runtime.id}' has no entry.`);
const shouldBundle = runtime.bundle ?? /\.[cm]?tsx?$/.test(runtime.entry);
if (!shouldBundle) return readFileSync(runtime.entry);
const result = await Bun.build({
entrypoints: [runtime.entry],
target: "browser",
format: runtime.type === "script" ? "iife" : "esm",
minify: true,
sourcemap: sourceMaps ? "inline" : "none",
});
if (!result.success || !result.outputs[0]) {
throw new Error(
`Client runtime '${runtime.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
);
}
return new Uint8Array(await result.outputs[0].arrayBuffer());
}
async function rawAssetSource(asset: PackageAssetDefinition): Promise<Uint8Array> {
if (asset.source !== undefined) return sourceBytes(asset.source);
if (!asset.entry) throw new Error(`Asset '${asset.id}' has no entry.`);
return readFileSync(asset.entry);
}
async function emitPluginAssets(
contributions: PluginContributions,
distDir: string,
sourceMaps: boolean,
): Promise<EmittedPluginAssets> {
const outputDir = join(distDir, "plugin-assets");
mkdirSync(outputDir, { recursive: true });
const assets: EmittedPluginAsset[] = [];
const runtimes: ClientRuntimeDefinition[] = [];
for (const runtime of contributions.clientRuntimes) {
const bytes = await compileRuntimeSource(runtime, sourceMaps);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const filename = `${safeAssetName(runtime.id)}.${hash}.js`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
const publicPath = `/__wrnexus/assets/${filename}`;
assets.push({
id: runtime.id,
publicPath,
file,
contentType: "text/javascript; charset=utf-8",
runtime: true,
immutable: true,
});
runtimes.push({ ...runtime, entry: undefined, source: undefined, publicPath });
}
for (const asset of contributions.assets) {
const bytes = await rawAssetSource(asset);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const extension = extname(asset.entry ?? asset.publicPath ?? "") || "";
const filename = `${safeAssetName(asset.id)}.${hash}${extension}`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
// Package assets keep their declared public URL so component markup, CSS,
// and server responses do not need build-time string rewriting. The disk
// filename is still content-addressed to make deployments atomic.
assets.push({
id: asset.id,
publicPath: asset.publicPath!,
file,
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath ?? ""),
runtime: false,
immutable: asset.immutable ?? false,
});
}
return { assets, runtimes };
}
function renderProductionPluginAssets(assets: readonly EmittedPluginAsset[]): string {
if (!assets.length) return "{}";
const entries = assets.map(
(asset) =>
`${JSON.stringify(asset.publicPath)}: { path: join(import.meta.dir, "plugin-assets", ${JSON.stringify(asset.file.split(/[\\/]/).pop())}), contentType: ${JSON.stringify(asset.contentType)}, immutable: ${asset.immutable} }`,
);
return `{ ${entries.join(", ")} }`;
}
interface BuildReport {
frameworkVersion: string;
plugins?: Array<{ name: string; version?: string }>;
pluginAssets?: Array<{ id: string; publicPath: string; bytes: number; runtime: boolean }>;
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;
routes: Array<{
kind: "page" | "api" | "realtime";
path: string;
source: string;
sourceBytes: number;
dynamicParams: string[];
execution: RuntimeRequirements["kind"];
canPrerender: boolean;
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 };
budgetViolations: ReturnType<typeof checkPerformanceBudgets>;
}
function fileBytes(file: string): number {
return existsSync(file) && statSync(file).isFile() ? statSync(file).size : 0;
}
function walkFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) files.push(...walkFiles(path));
else if (stat.isFile()) files.push(path);
}
return files;
}
function createBuildReport(input: {
root: string;
distDir: string;
publicDir: string;
adapter: string;
routes: Array<{ kind: "page" | "api" | "realtime"; route: Route }>;
runtimeFile: string;
cssFile: string;
runtimeAnalysis: ReadonlyMap<string, RuntimeRequirements>;
}): BuildReport {
const assets = walkFiles(input.distDir)
.filter((file) => !file.endsWith("build-report.json") && !fwd(file).includes("/compiled/"))
.map((file) => ({ file: fwd(file.slice(input.distDir.length + 1)), bytes: fileBytes(file) }))
.sort((a, b) => b.bytes - a.bytes);
const imageExtensions = /\.(?:avif|gif|jpe?g|png|svg|webp)$/i;
const imageBytes = Math.max(
0,
...walkFiles(input.publicDir)
.filter((file) => imageExtensions.test(file))
.map(fileBytes),
);
return {
frameworkVersion: currentCliVersion(),
generatedAt: new Date().toISOString(),
root: input.root,
adapter: input.adapter,
routes: input.routes.map(({ kind, route }) => ({
kind,
path: route.raw,
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
sourceBytes: fileBytes(route.file),
dynamicParams: route.paramNames,
execution: input.runtimeAnalysis.get(route.file)?.kind ?? "dynamic",
canPrerender: input.runtimeAnalysis.get(route.file)?.canPrerender ?? false,
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: {
routeJsBytes: fileBytes(input.runtimeFile),
routeCssBytes: fileBytes(input.cssFile),
imageBytes,
},
budgetViolations: [],
};
}
async function buildBrowserRuntime(
source: string,
outFile: string,
entryFile: string,
): Promise<string> {
writeFileSync(entryFile, source, "utf8");
const result = await Bun.build({
entrypoints: [entryFile],
target: "browser",
format: "esm",
minify: true,
});
if (!result.success) {
throw new Error("Runtime build failed:\n" + result.logs.map(String).join("\n"));
}
const code = await result.outputs[0]!.text();
writeFileSync(outFile, code, "utf8");
return code;
}