release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+282 -25
View File
@@ -21,7 +21,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { basename, extname, join, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getReactiveRuntime } from "@wrnexus/csr";
import { assertValidAst, generate, parse } from "@wrnexus/compiler";
@@ -40,7 +40,15 @@ import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
import { checkPerformanceBudgets } from "@wrnexus/core";
import { createPluginRunner } from "@wrnexus/plugin";
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
@@ -70,7 +78,12 @@ export async function runBuild(appRoot: string): Promise<void> {
}
const config = await loadAppConfig(root);
const pluginRunner = createPluginRunner(config.plugins, {
const discoveredPlugins = await discoverPlugins(root, config.plugins, {
includeDevDependencies: true,
strict: true,
warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
});
const pluginRunner = createPluginRunner(discoveredPlugins, {
root,
mode: "production",
command: "build",
@@ -80,6 +93,8 @@ export async function runBuild(appRoot: string): Promise<void> {
});
await pluginRunner.configure(config as Record<string, unknown>);
await pluginRunner.configResolved(config as Readonly<Record<string, unknown>>);
const pluginContributions = await pluginRunner.contributions();
const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
await pluginRunner.hook("buildStart");
// `.wrn` route files are compiled once into deterministic intermediate modules.
@@ -120,26 +135,90 @@ export async function runBuild(appRoot: string): Promise<void> {
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.
// 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");
const hasDefaultMigrations = !!config.db && existsSync(defaultMigrationsSrc);
if (hasDefaultMigrations) {
if (config.db && existsSync(defaultMigrationsSrc)) {
cpSync(defaultMigrationsSrc, join(distDir, "migrations"), { recursive: true });
console.log(`✓ Migrations: dist/migrations`);
hasDefaultMigrations = 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);
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`);
}
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
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),
@@ -149,6 +228,17 @@ export async function runBuild(appRoot: string): Promise<void> {
]);
for (const file of wrnFiles) await compileWrn(file);
const assetHash = createHash("sha256");
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.
@@ -201,9 +291,20 @@ export async function runBuild(appRoot: string): Promise<void> {
const styleEntry = findStyleEntry(appDir, root, config.styles?.entry);
let hasStyles = false;
let inlineStyles = "";
if (styleEntry) {
const hasPackageStyleEntries = pluginContributions.styles.some((style) => !!style.entry);
if (styleEntry || hasPackageStyleEntries) {
const css = await renderStyles(
{ entryPath: styleEntry, appDir, appRoot: root, mode: "production" },
{
entryPath: styleEntry,
appDir,
appRoot: root,
mode: "production",
sources: [
...componentDirs,
...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
],
entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
config.styles,
);
assetHash.update(css);
@@ -294,8 +395,8 @@ await createProductionServer(
storage: ${config.storage ? JSON.stringify(config.storage) : "undefined"},
${hasDefaultMigrations ? `migrationsDir: join(import.meta.dir, "migrations"),` : ""}
${
namedMigrationDbs.length
? `databaseMigrationDirs: { ${namedMigrationDbs
namedMigrationDbs.size
? `databaseMigrationDirs: { ${[...namedMigrationDbs]
.map(
(n) =>
`${JSON.stringify(n)}: join(import.meta.dir, "db", ${JSON.stringify(n)}, "migrations")`,
@@ -309,6 +410,8 @@ await createProductionServer(
${hasStyles ? `stylesIncludeFramework: true,` : ""}
${inlineStyles ? `inlineStyles: ${JSON.stringify(inlineStyles)},` : ""}
assetVersion: ${JSON.stringify(assetVersion)},
clientRuntimes: ${JSON.stringify(emittedPluginAssets.runtimes)},
pluginAssets: ${renderProductionPluginAssets(emittedPluginAssets.assets)},
head: ${JSON.stringify(headStr)},
seo: ${JSON.stringify(config.seo ?? {})},
mobile: ${JSON.stringify(config.mobile ?? {})},
@@ -342,10 +445,36 @@ await createProductionServer(
distDir,
publicDir: distPublicDir,
adapter: config.build?.adapter ?? "bun",
routes: router.pages,
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"),
});
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);
const violations = checkPerformanceBudgets(
config.performance?.budgets ?? {},
report.measurements,
@@ -374,12 +503,139 @@ await createProductionServer(
console.log(`\nRun it: bun ${fwd(join(distDir, "server.js"))}`);
}
interface EmittedPluginAsset {
id: string;
publicPath: string;
file: string;
contentType: string;
runtime: boolean;
immutable: boolean;
}
interface EmittedPluginAssets {
assets: EmittedPluginAsset[];
runtimes: ClientRuntimeDefinition[];
}
function safeAssetName(id: string): string {
return (
id
.replace(/^@/, "")
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "asset"
);
}
function sourceBytes(source: string | Uint8Array): Uint8Array {
return typeof source === "string" ? new TextEncoder().encode(source) : source;
}
async function compileRuntimeSource(
runtime: ClientRuntimeDefinition,
sourceMaps: boolean,
): Promise<Uint8Array> {
if (runtime.source !== undefined) return sourceBytes(runtime.source);
if (!runtime.entry) throw new Error(`Runtime '${runtime.id}' has no entry.`);
const shouldBundle = runtime.bundle ?? /\.[cm]?tsx?$/.test(runtime.entry);
if (!shouldBundle) return readFileSync(runtime.entry);
const result = await Bun.build({
entrypoints: [runtime.entry],
target: "browser",
format: runtime.type === "script" ? "iife" : "esm",
minify: true,
sourcemap: sourceMaps ? "inline" : "none",
});
if (!result.success || !result.outputs[0]) {
throw new Error(
`Client runtime '${runtime.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
);
}
return new Uint8Array(await result.outputs[0].arrayBuffer());
}
async function rawAssetSource(asset: PackageAssetDefinition): Promise<Uint8Array> {
if (asset.source !== undefined) return sourceBytes(asset.source);
if (!asset.entry) throw new Error(`Asset '${asset.id}' has no entry.`);
return readFileSync(asset.entry);
}
async function emitPluginAssets(
contributions: PluginContributions,
distDir: string,
sourceMaps: boolean,
): Promise<EmittedPluginAssets> {
const outputDir = join(distDir, "plugin-assets");
mkdirSync(outputDir, { recursive: true });
const assets: EmittedPluginAsset[] = [];
const runtimes: ClientRuntimeDefinition[] = [];
for (const runtime of contributions.clientRuntimes) {
const bytes = await compileRuntimeSource(runtime, sourceMaps);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const filename = `${safeAssetName(runtime.id)}.${hash}.js`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
const publicPath = `/__wrnexus/assets/${filename}`;
assets.push({
id: runtime.id,
publicPath,
file,
contentType: "text/javascript; charset=utf-8",
runtime: true,
immutable: true,
});
runtimes.push({ ...runtime, entry: undefined, source: undefined, publicPath });
}
for (const asset of contributions.assets) {
const bytes = await rawAssetSource(asset);
const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const extension = extname(asset.entry ?? asset.publicPath ?? "") || "";
const filename = `${safeAssetName(asset.id)}.${hash}${extension}`;
const file = join(outputDir, filename);
writeFileSync(file, bytes);
// Package assets keep their declared public URL so component markup, CSS,
// and server responses do not need build-time string rewriting. The disk
// filename is still content-addressed to make deployments atomic.
assets.push({
id: asset.id,
publicPath: asset.publicPath!,
file,
contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath ?? ""),
runtime: false,
immutable: asset.immutable ?? false,
});
}
return { assets, runtimes };
}
function renderProductionPluginAssets(assets: readonly EmittedPluginAsset[]): string {
if (!assets.length) return "{}";
const entries = assets.map(
(asset) =>
`${JSON.stringify(asset.publicPath)}: { path: join(import.meta.dir, "plugin-assets", ${JSON.stringify(asset.file.split(/[\\/]/).pop())}), contentType: ${JSON.stringify(asset.contentType)}, immutable: ${asset.immutable} }`,
);
return `{ ${entries.join(", ")} }`;
}
interface BuildReport {
frameworkVersion: string;
plugins?: Array<{ name: string; version?: string }>;
pluginAssets?: Array<{ id: string; publicPath: string; bytes: number; runtime: boolean }>;
clientRuntimes?: Array<{ id: string; publicPath: string; type: string; load: string }>;
migrations?: Array<{ id: string; database: string; source: string }>;
componentDirs?: string[];
generatedAt: string;
root: string;
adapter: string;
routes: Array<{ path: string; source: string; sourceBytes: number; dynamicParams: string[] }>;
routes: Array<{
kind: "page" | "api" | "realtime";
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>;
@@ -406,7 +662,7 @@ function createBuildReport(input: {
distDir: string;
publicDir: string;
adapter: string;
routes: Route[];
routes: Array<{ kind: "page" | "api" | "realtime"; route: Route }>;
runtimeFile: string;
cssFile: string;
}): BuildReport {
@@ -422,11 +678,12 @@ function createBuildReport(input: {
.map(fileBytes),
);
return {
frameworkVersion: "0.3.0",
frameworkVersion: currentCliVersion(),
generatedAt: new Date().toISOString(),
root: input.root,
adapter: input.adapter,
routes: input.routes.map((route) => ({
routes: input.routes.map(({ kind, route }) => ({
kind,
path: route.raw,
source: fwd(route.file.replace(input.root, "").replace(/^\//, "")),
sourceBytes: fileBytes(route.file),