/** * `wrnexus build` — production build (Point 4). * * Emits into `/dist`: * islands/.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 { 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//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 = {}; 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 { 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; }