464 lines
18 KiB
TypeScript
464 lines
18 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 { join, resolve } from "node:path";
|
|
import { buildRouter, type Route } from "@wrnexus/router";
|
|
import { getReactiveRuntime } from "@wrnexus/csr";
|
|
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
|
|
import {
|
|
loadAppConfig,
|
|
headToString,
|
|
renderProductionFontHead,
|
|
findStyleEntry,
|
|
renderStyles,
|
|
resolveThemeConfig,
|
|
renderThemeCss,
|
|
renderThemeRuntime,
|
|
} from "@wrnexus/styles";
|
|
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
|
|
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 { createPluginRunner } 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, "/");
|
|
|
|
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 reactivePath = join(distDir, "reactive.js");
|
|
const publicDir = join(root, "public");
|
|
const distPublicDir = join(distDir, "public");
|
|
|
|
console.log(`Building ${appDir} -> ${distDir}`);
|
|
|
|
// Clean output.
|
|
rmSync(distDir, { recursive: true, force: true });
|
|
mkdirSync(compiledDir, { recursive: true });
|
|
if (existsSync(publicDir)) {
|
|
cpSync(publicDir, distPublicDir, { recursive: true });
|
|
console.log(`✓ Public: ${distPublicDir}`);
|
|
}
|
|
|
|
const config = await loadAppConfig(root);
|
|
const pluginRunner = createPluginRunner(config.plugins, {
|
|
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>>);
|
|
await pluginRunner.hook("buildStart");
|
|
|
|
// `.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 compileWrn = async (file: string): Promise<void> => {
|
|
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
|
const source = readFileSync(file, "utf8");
|
|
let ast = parse(source);
|
|
assertValidAst(ast, { file, accessibility: true });
|
|
ast = await pluginRunner.transformAst(ast, file);
|
|
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"),
|
|
);
|
|
}
|
|
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);
|
|
};
|
|
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 DB migrations into the build so the production server can auto-apply
|
|
// them on startup (dev auto-migrates from app/db/migrations; prod needs the
|
|
// .sql files inside dist/). The default db's migrations go to dist/migrations;
|
|
// each named db's to dist/db/<name>/migrations.
|
|
const defaultMigrationsSrc = join(appDir, "db", "migrations");
|
|
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
|
|
if (hasDefaultMigrations) {
|
|
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
|
|
console.log(`✓ Migrations: dist/migrations`);
|
|
}
|
|
const namedMigrationDbs: string[] = [];
|
|
for (const name of Object.keys(config.databases ?? {})) {
|
|
const src = join(appDir, "db", name, "migrations");
|
|
if (!existsSync(src)) continue;
|
|
cpSync(src, join(distDir, "db", name, "migrations"), { recursive: true });
|
|
namedMigrationDbs.push(name);
|
|
console.log(`✓ Migrations: dist/db/${name}/migrations`);
|
|
}
|
|
|
|
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
|
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 assetHash = createHash("sha256");
|
|
|
|
// 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 = 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(`✓ 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 = "";
|
|
if (styleEntry) {
|
|
const css = await renderStyles(
|
|
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
|
|
config.styles,
|
|
);
|
|
assetHash.update(css);
|
|
const combinedCss = `${frameworkStyles}\n${css}`;
|
|
// 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;
|
|
|
|
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))};`);
|
|
return ` { raw: ${JSON.stringify(r.raw)}, mod: ${v} },`;
|
|
});
|
|
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}`);
|
|
|
|
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}],
|
|
},
|
|
{
|
|
reactivePath: join(import.meta.dir, "reactive.js"),
|
|
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)},
|
|
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.length
|
|
? `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)},
|
|
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 ?? {})},
|
|
},
|
|
);
|
|
`;
|
|
|
|
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,
|
|
runtimeFile: reactivePath,
|
|
cssFile: hasStyles ? join(distDir, "styles.css") : join(distDir, "framework.css"),
|
|
});
|
|
const violations = checkPerformanceBudgets(
|
|
config.performance?.budgets ?? {},
|
|
report.measurements,
|
|
);
|
|
report.budgetViolations = violations;
|
|
writeFileSync(join(distDir, "build-report.json"), JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
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 BuildReport {
|
|
frameworkVersion: string;
|
|
generatedAt: string;
|
|
root: string;
|
|
adapter: string;
|
|
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
|
|
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: Route[];
|
|
runtimeFile: string;
|
|
cssFile: string;
|
|
}): 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: "0.3.0",
|
|
generatedAt: new Date().toISOString(),
|
|
root: input.root,
|
|
adapter: input.adapter,
|
|
routes: input.routes.map((route) => ({
|
|
path: route.raw,
|
|
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
|
|
sourceBytes: fileBytes(route.file),
|
|
dynamicParams: route.paramNames,
|
|
})),
|
|
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;
|
|
}
|