Files
WRNexusJS/packages/cli/src/build.ts
T

314 lines
12 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, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getReactiveRuntime } from "@wrnexus/csr";
import { compileWireFile } 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 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}`);
}
// `.wrn` route files are compiled to `.ts` so Bun.build can bundle them.
let compiledCount = 0;
const importPathFor = (file: string): string => {
if (!file.endsWith(".wrn")) return fwd(file);
const ts = compileWireFile(readFileSync(file, "utf8"));
const out = join(compiledDir, `route${compiledCount++}.ts`);
writeFileSync(out, ts, "utf8");
return fwd(out);
};
const config = await loadAppConfig(root);
// 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 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 ?? {})},
},
);
`;
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,
});
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");
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(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
}
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;
}