`wrnexus build` failed on any client function whose body used TypeScript:
const requestBody: Record<string, unknown> = {}
error: Expected ";" but found ":"
Codegen copies a client function's body into the browser module verbatim.
It removes the types from the function's *signature*, which is what made
this easy to miss -- the emitted module looked transpiled, and only bodies
carried types through. The artifact is written as .mjs and read back as
plain JavaScript, so the failure surfaced as a syntax error in generated
code rather than at the .wrn line responsible.
Browser modules are now transpiled before they are written, at all three
sites that emit one (the production build and both dev-server paths).
Reproduced end to end: a page with an annotated body failed the build with
the reported errors, and after the fix builds, ships valid minified JS, and
runs -- the handler sets its state correctly in a browser.
Note: the same body is also embedded as a string for the CSP-safe fallback
interpreter, which still receives it untranspiled. The compiled module
shadows the fallback, so this is only reachable in the window before that
module loads. Left alone here because stripping it lives in codegen, which
also runs under Node in the editor bundle where the Bun transpiler is
unavailable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1196 lines
47 KiB
TypeScript
1196 lines
47 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 { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr";
|
|
import { getIslandRuntime } from "@wrnexus/react/runtime";
|
|
import {
|
|
analyzeRuntimeImports,
|
|
analyzeRuntimeRequirements,
|
|
assertReactAvailable,
|
|
buildIslands,
|
|
islandNamesFrom,
|
|
assertValidAst,
|
|
generate,
|
|
generateTargets,
|
|
parse,
|
|
type RuntimeRequirements,
|
|
type DeploymentRuntime,
|
|
runtimeCapabilities,
|
|
resolveWrnImports,
|
|
stripBrowserTypes,
|
|
} from "@wrnexus/compiler";
|
|
import {
|
|
loadAppConfig,
|
|
headToString,
|
|
renderProductionFontHead,
|
|
findStyleEntry,
|
|
bundleCss,
|
|
renderStyles,
|
|
resolveThemeConfig,
|
|
resolveBrowserCookieOptions,
|
|
renderActiveThemeCss,
|
|
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 controllersPath = join(distDir, "controllers.js");
|
|
const islandsPath = join(distDir, "islands.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 bundledUiDir = uiComponentsDir();
|
|
const componentDirs = [
|
|
bundledUiDir,
|
|
join(bundledUiDir, "..", "styles"),
|
|
...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>();
|
|
/** Island component name -> resolved .tsx source, collected across all routes. */
|
|
const discoveredIslands = new Map<string, 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);
|
|
|
|
// Islands must be known before codegen (to emit markers instead of
|
|
// component mounts) and before route analysis (an island route ships JS).
|
|
const fileImports = resolveWrnImports(ast.structuredImports, file, {
|
|
appRoot: root,
|
|
mode: config.imports?.mode ?? "compatible",
|
|
aliases: config.imports?.aliases,
|
|
});
|
|
const fileIslands = islandNamesFrom(fileImports);
|
|
for (const imported of fileImports) {
|
|
if (imported.kind === "island" && imported.resolved) {
|
|
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
|
|
}
|
|
}
|
|
|
|
runtimeAnalysis.set(
|
|
file,
|
|
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
|
|
);
|
|
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, { islands: fileIslands })}`.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 = fileImports;
|
|
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");
|
|
// The entry is .mjs, so anything TypeScript left in a client function
|
|
// body would be read back as JavaScript and fail to parse.
|
|
writeFileSync(clientEntry, stripBrowserTypes(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}`);
|
|
const controllerCode = await buildBrowserRuntime(
|
|
getComponentControllerRuntime(),
|
|
controllersPath,
|
|
join(compiledDir, "controllers.entry.js"),
|
|
);
|
|
assetHash.update(controllerCode);
|
|
console.log(`✓ Controllers: ${controllersPath}`);
|
|
|
|
// Island bootstrap: emitted unconditionally but inert without markers, so a
|
|
// build with no islands still ships no React.
|
|
const islandCode = await buildBrowserRuntime(
|
|
getIslandRuntime(),
|
|
islandsPath,
|
|
join(compiledDir, "islands.entry.js"),
|
|
);
|
|
assetHash.update(islandCode);
|
|
console.log(`✓ Islands: ${islandsPath}`);
|
|
|
|
// Island bundles are emitted only when a route actually imported a .tsx, so a
|
|
// build with no islands produces no React and no island assets at all.
|
|
const islandsDir = join(distDir, "island");
|
|
if (discoveredIslands.size > 0) {
|
|
const missingReact = assertReactAvailable(root);
|
|
if (missingReact) {
|
|
throw new Error(`${missingReact.code}: ${missingReact.message}`);
|
|
}
|
|
mkdirSync(islandsDir, { recursive: true });
|
|
const islandBuild = await buildIslands({
|
|
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
|
|
outDir: islandsDir,
|
|
appRoot: root,
|
|
});
|
|
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
|
|
console.log(
|
|
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
|
|
);
|
|
}
|
|
|
|
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
|
const theme = resolveThemeConfig(config.theme, config.cookies);
|
|
const themeCss = renderThemeCss(theme);
|
|
const themeJs = renderThemeRuntime(theme);
|
|
writeFileSync(join(distDir, "theme.css"), themeCss, "utf8");
|
|
writeFileSync(join(distDir, "theme.js"), themeJs, "utf8");
|
|
const themeAssetsDir = join(distDir, "theme");
|
|
for (const themeName of theme.names) {
|
|
const themeDir = join(themeAssetsDir, encodeURIComponent(themeName));
|
|
mkdirSync(themeDir, { recursive: true });
|
|
writeFileSync(join(themeDir, "_.css"), renderActiveThemeCss(theme, themeName), "utf8");
|
|
for (const accentName of theme.accentNames) {
|
|
writeFileSync(
|
|
join(themeDir, `${encodeURIComponent(accentName)}.css`),
|
|
renderActiveThemeCss(theme, themeName, accentName),
|
|
"utf8",
|
|
);
|
|
}
|
|
}
|
|
assetHash.update(themeCss);
|
|
assetHash.update(themeJs);
|
|
console.log(`✓ Theme: ${theme.names.length} themes (default: ${theme.default})`);
|
|
|
|
// 1a2) WrNexus 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 i18nConfig = config.i18n
|
|
? {
|
|
...config.i18n,
|
|
cookie: {
|
|
...resolveBrowserCookieOptions(config.cookies, "language"),
|
|
...(config.i18n.cookie ?? {}),
|
|
},
|
|
}
|
|
: undefined;
|
|
const i18n = Object.keys(localeMessages).length
|
|
? resolveI18n(localeMessages, i18nConfig)
|
|
: 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",
|
|
// Component discovery is not style discovery. Built-in and plugin
|
|
// components own their CSS; scanning every available component makes
|
|
// Tailwind/Iconify generate rules for packages and components the app
|
|
// never renders. Packages that intentionally use app utilities opt in
|
|
// through an explicit styles.source contribution.
|
|
sources: pluginContributions.styles.flatMap((style) =>
|
|
style.source ? [style.source] : [],
|
|
),
|
|
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
|
|
},
|
|
config.styles,
|
|
);
|
|
// Theme tokens are request-selected and loaded separately. Keep only the
|
|
// shared UI primitives with application CSS so the full theme/accent
|
|
// matrix cannot leak back into the blocking stylesheet.
|
|
const combinedSource = `${uiStyles}\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 shared blocking request for UI + application CSS. The small active
|
|
// theme stylesheet is selected from the request cookie and loaded first.
|
|
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 { render as ${v} } from ${JSON.stringify(importPathFor(c.file))};`);
|
|
return `{ name: ${JSON.stringify(c.name)}, mod: { render: ${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 { render as ${v} } from ${JSON.stringify(importPathFor(l.file))};`);
|
|
return `{ name: ${JSON.stringify(l.name)}, mod: { render: ${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"),
|
|
controllersPath: join(import.meta.dir, "controllers.js"),
|
|
clientModulesDir: join(import.meta.dir, "client"),
|
|
islandsDir: join(import.meta.dir, "island"),
|
|
themePath: join(import.meta.dir, "theme.css"),
|
|
themeAssetsDir: join(import.meta.dir, "theme"),
|
|
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 ? `stylesIncludeUi: 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,
|
|
});
|
|
// The emitted runtime is served from its content-addressed public path.
|
|
// Keep an empty source marker so @wrnexus/plugin can normalize this
|
|
// already-emitted descriptor during production HTML rendering without
|
|
// trying to resolve the original build-time source on the deploy host.
|
|
runtimes.push({ ...runtime, entry: undefined, source: "", 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;
|
|
}
|