/** * Budget measurement helpers — shared by gates and the budgets.json publisher. */ import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { lazyRequire } from "../kernel/lazy-require.ts"; import { type BudgetGroup, OFFICIAL_PLUGIN_BUDGETS, PLUGIN_BUDGET_CATEGORIES, resolveExportBudgetTargets, } from "./exports.ts"; import { ABSOLUTE_REGRESSION_RATIO, CLIENT_BUDGET_BYTES, COLD_START_BUDGET_MS, CONSOLE_BUDGET_BYTES, EXPORT_REGRESSION_TOLERANCE_FLOOR_BYTES, EXPORT_REGRESSION_TOLERANCE_RATIO, KERNEL_EDGE_BUDGET_BYTES, ROUTING_P99_BUDGET_MS, } from "./limits.ts"; const ROOT = `${import.meta.dir}/../..`; const BUDGETS_JSON_PATH = resolve(ROOT, "budgets.json"); /** * Deps measured separately from OKE code in export samples. * Edge/client absolute gates keep the historical smaller external list. */ const BUILD_EXTERNALS = [ "@duckdb/node-api", "@duckdb/*", // ICU MessageFormat — adopted FormatJS runtime; kept out of the edge // micro-bundle the same way other heavy deps are measured separately. "intl-messageformat", "@formatjs/*", ] as const; /** Heavy optional / peer packages — not counted in export:* gzip samples. */ export const EXPORT_BUILD_EXTERNALS = [ ...BUILD_EXTERNALS, "sently", "sently/*", "ajv", "ajv/*", "ajv-formats", "oxc-parser", "zod", "@electric-sql/pglite", "@electric-sql/*", ] as const; /** How a sample is gated. */ export type BudgetGate = "absolute" | "regression" | "report"; /** Report section headings. */ const GROUP_HEADINGS: Readonly> = { core: "Core", exports: "Exports", plugins: "Plugins", drivers: "Drivers", }; /** Docs category → markdown subheading under Plugins. */ const PLUGIN_CATEGORY_HEADINGS: Readonly> = { auth: "Auth", oauth: "OAuth", security: "Security", ops: "Ops", perf: "Perf", }; /** One measured budget sample. */ export interface BudgetSample { /** Stable metric id. */ readonly id: string; /** Human label. */ readonly label: string; /** Measured value. */ readonly value: number; /** * Absolute cap, or the regression ceiling for export samples. * Landing copy reads this as the published cap — do not replace it * with {@link regressionLimit}. */ readonly limit: number; /** * Tighter growth ceiling (`ABSOLUTE_REGRESSION_RATIO` × last committed * value) when that multiple is still under {@link limit}. Absent when * the absolute cap is the only gate. */ readonly regressionLimit?: number; /** Unit for display / snapshot. */ readonly unit: "bytes" | "ms"; /** Gate mode. */ readonly gate: BudgetGate; /** Report section. */ readonly group: BudgetGroup; /** True when under the gate's limit (or first baseline for regression). */ readonly ok: boolean; } /** Full measurement set for {@link budgets.json}. */ export interface BudgetsSnapshot { /** ISO timestamp when measured. */ readonly measuredAt: string; /** Package version from package.json. */ readonly version: string; /** Individual samples. */ readonly budgets: readonly BudgetSample[]; } /** * Gzip size of all JS artifacts from a Bun.build result. * * @param result - Successful build * @param label - Error context */ async function gzipBuildOutputs( result: Awaited>, label: string, ): Promise { if (!result.success) { throw new Error(`${label} build failed:\n${result.logs.map(String).join("\n")}`); } let total = 0; for (const artifact of result.outputs) { const path = artifact.path; if (path && !/\.(m?js|cjs)$/.test(path) && artifact.kind === "asset") { continue; } const raw = await artifact.arrayBuffer(); if (raw.byteLength === 0) continue; total += Bun.gzipSync(new Uint8Array(raw)).byteLength; } if (total <= 0) throw new Error(`${label} build produced no JS output`); return total; } /** * Gzip size of a minified ESM bundle for one entry file. * * @param entry - Absolute path to the TypeScript entry * @param label - Error context * @param external - Packages left external (not inlined) */ export async function measureEntryGzipBytes( entry: string, label = entry, external: readonly string[] = BUILD_EXTERNALS, ): Promise { const result = await Bun.build({ entrypoints: [entry], minify: true, target: "bun", format: "esm", external: [...external], }); return gzipBuildOutputs(result, label); } /** * Raw (uncompressed) minified JS bytes for one entry. * * @param entry - Absolute path to the TypeScript entry * @param label - Error context * @param external - Packages left external (not inlined) */ export async function measureEntryRawBytes( entry: string, label = entry, external: readonly string[] = BUILD_EXTERNALS, ): Promise { const result = await Bun.build({ entrypoints: [entry], minify: true, target: "bun", format: "esm", external: [...external], }); if (!result.success) { throw new Error(`${label} build failed:\n${result.logs.map(String).join("\n")}`); } let total = 0; for (const artifact of result.outputs) { const path = artifact.path; if (path && !/\.(m?js|cjs)$/.test(path) && artifact.kind === "asset") continue; const raw = await artifact.arrayBuffer(); total += raw.byteLength; } if (total <= 0) throw new Error(`${label} build produced no JS output`); return total; } /** HTTP-ping app budget sample (raw + gzip, with/without export externals). */ export interface HttpPingBudgetSample { readonly gzipWithExternals: number; readonly rawWithExternals: number; readonly gzipWithoutExternals: number; readonly rawWithoutExternals: number; } /** * Measure a minimal HTTP ping app via `okengine/http`. */ export async function measureHttpPingAppBytes(): Promise { const dir = await mkdtemp(join(tmpdir(), "oke-http-ping-budget-")); const anchor = join(dir, "anchor.ts"); const httpEntry = resolve(ROOT, "src/http.ts"); await Bun.write( anchor, `import { on, flow, http, gate, oke, createBunRuntime } from ${JSON.stringify(httpEntry)};\n` + `on(http.get("/").public(), flow("ping", { do: () => "Hi" }));\n` + `export const app = oke({ name: "ping" });\n` + `export const rt = createBunRuntime;\n`, ); try { const [gzipWithExternals, rawWithExternals, gzipWithoutExternals, rawWithoutExternals] = await Promise.all([ measureEntryGzipBytes(anchor, "http-ping", EXPORT_BUILD_EXTERNALS), measureEntryRawBytes(anchor, "http-ping", EXPORT_BUILD_EXTERNALS), measureEntryGzipBytes(anchor, "http-ping-deps", BUILD_EXTERNALS), measureEntryRawBytes(anchor, "http-ping-deps", BUILD_EXTERNALS), ]); return { gzipWithExternals, rawWithExternals, gzipWithoutExternals, rawWithoutExternals, }; } finally { await rm(dir, { recursive: true, force: true }); } } /** * Gzip size of the kernel edge profile bundle. */ /** * Gzip size of the lazy decision module. Reported, not gated. */ export async function measureDecisionChunkGzipBytes(): Promise { const entry = `${import.meta.dir}/../kernel/decision-budget-entry.ts`; const result = await Bun.build({ entrypoints: [entry], minify: true, target: "bun", format: "esm", }); if (!result.success) { throw new Error(`decision chunk build failed:\n${result.logs.map(String).join("\n")}`); } const artifact = result.outputs[0]; if (!artifact) throw new Error("decision chunk build produced no output"); const raw = await artifact.arrayBuffer(); return Bun.gzipSync(new Uint8Array(raw)).byteLength; } export async function measureKernelEdgeGzipBytes(): Promise { try { return await gzipKernelEdgeInProcess(); } catch { // bun test's in-process Bun.build cannot always resolve the kernel graph. return gzipKernelEdgeSpawn(); } } /** In-process edge gzip — used outside `bun test`. */ async function gzipKernelEdgeInProcess(): Promise { const entry = `${import.meta.dir}/../kernel/budget-entry.ts`; // Browser target matches the historical edge-profile gate. const result = await Bun.build({ entrypoints: [entry], minify: true, target: "browser", format: "esm", external: [...BUILD_EXTERNALS], }); if (!result.success) { throw new Error(`kernel edge build failed:\n${result.logs.map(String).join("\n")}`); } const artifact = result.outputs[0]; if (!artifact) throw new Error("kernel edge build produced no output"); const raw = await artifact.arrayBuffer(); return Bun.gzipSync(new Uint8Array(raw)).byteLength; } /** Subprocess edge gzip — bun test cannot always resolve this graph in-process. */ async function gzipKernelEdgeSpawn(): Promise { const entry = resolve(ROOT, "src/kernel/budget-entry.ts"); const probe = ` const result = await Bun.build({ entrypoints: ${JSON.stringify([entry])}, minify: true, target: "browser", format: "esm", external: ${JSON.stringify([...BUILD_EXTERNALS])}, }); if (!result.success) { console.error(result.logs.map(String).join("\\n")); process.exit(1); } const artifact = result.outputs[0]; if (!artifact) { console.error("no output"); process.exit(1); } const raw = await artifact.arrayBuffer(); process.stdout.write(String(Bun.gzipSync(new Uint8Array(raw)).byteLength)); `; const proc = Bun.spawn([process.execPath, "-e", probe], { cwd: ROOT, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (exitCode !== 0) { throw new Error(`kernel edge build failed:\n${stderr || stdout}`); } const n = Number(stdout.trim()); if (!Number.isFinite(n) || n <= 0) { throw new Error(`kernel edge measure produced ${stdout}`); } return n; } /** * Gzip size of the client runtime bundle (one file, no split). * * If this build ever uses `splitting`, the gate must count the entry plus * every chunk it imports statically. The entry file alone under-counts the * first download when a shared chunk loads with it. */ export async function measureClientGzipBytes(): Promise { const entry = resolve(ROOT, "src/client/budget-entry.ts"); // `bun test` sets NODE_ENV=test. An in-process Bun.build then inlines that // and the gzip drifts from `bun run budgets`. Spawn once, production, so // both callers share this number. const probe = ` const result = await Bun.build({ entrypoints: ${JSON.stringify([entry])}, minify: true, target: "browser", format: "esm", }); if (!result.success) { console.error(result.logs.map(String).join("\\n")); process.exit(1); } const artifact = result.outputs[0]; if (!artifact) { console.error("no output"); process.exit(1); } const raw = await artifact.arrayBuffer(); process.stdout.write(String(Bun.gzipSync(new Uint8Array(raw)).byteLength)); `; const proc = Bun.spawn([process.execPath, "-e", probe], { cwd: ROOT, stdout: "pipe", stderr: "pipe", env: { ...process.env, NODE_ENV: "production" }, }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (exitCode !== 0) { throw new Error(`client build failed:\n${stderr || stdout}`); } const n = Number(stdout.trim()); if (!Number.isFinite(n) || n <= 0) { throw new Error(`client measure produced ${stdout}`); } return n; } /** * Gzip size of one published package export entry. * * Bundles through a temporary `import *` anchor so minify cannot drop the * re-export graph (direct entry bundles collapse barrels to ~empty stubs). * * @param entry - Absolute path to the export entry */ export async function measureExportGzipBytes(entry: string): Promise { const dir = await mkdtemp(join(tmpdir(), "oke-export-budget-")); const anchor = join(dir, "anchor.ts"); await Bun.write( anchor, `import * as mod from ${JSON.stringify(entry)};\n` + `export function __okeExportBudgetAnchor(){return mod;}\n`, ); try { return await measureEntryGzipBytes(anchor, `export ${entry}`, EXPORT_BUILD_EXTERNALS); } finally { await rm(dir, { recursive: true, force: true }); } } /** One Console asset's gzip size. */ export interface ConsoleAssetGzip { /** File name under `dist/` or `dist/assets/`. */ readonly name: string; /** Gzipped byte length. */ readonly gzip: number; } /** Definitive Console bundle breakdown (initial vs lazy panel chunks). */ export interface ConsoleBundleBreakdown { /** Bytes the first navigation downloads (html + script/link/modulepreload). */ readonly initialGzipBytes: number; /** Assets counted in the initial load. */ readonly initial: readonly ConsoleAssetGzip[]; /** Lazy `panel-*` chunks (not in the initial navigation). */ readonly panels: readonly ConsoleAssetGzip[]; /** Other assets neither initial nor panel (should stay empty / tiny). */ readonly other: readonly ConsoleAssetGzip[]; } /** * Gzip size of Console initial load (html + entry js/css). * * @param distDir - Vite outDir (built if missing) */ export async function measureConsoleInitialGzipBytes( distDir = `${import.meta.dir}/../console/ui-next/dist`, ): Promise { const breakdown = await measureConsoleBundleBreakdown(distDir); return breakdown.initialGzipBytes; } /** * Build the Console SPA and return an initial-vs-lazy breakdown. * * Initial load is defined as what `index.html` actually references * (`