release: WRNexusJS 0.4.0
This commit is contained in:
+282
-25
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user