Build System
Frame-Master's build pipeline uses a singleton Builder pattern that intelligently merges configurations from all plugins into a unified build process.
📦 Overview
The build system orchestrates Bun's build API with plugin configurations, lifecycle hooks, and build analysis tools.
Singleton Builder Pattern
All plugins contribute to a single shared builder instance. When any plugin triggers a build, it includes configurations from all loaded plugins, ensuring consistent and coordinated builds.
Key Features
🔄 Smart Config Merging
Intelligently merges build configs from all plugins
🪝 Lifecycle Hooks
beforeBuild and afterBuild hooks for custom logic
📊 Build Analytics
Analyze outputs, track history, generate reports
⚡ Concurrent Protection
Prevents race conditions with build locking
🚀 Using the Builder
Import and use the singleton builder in your plugins.
Basic Usage
import { builder } from "frame-master/build";export function myPlugin(): FrameMasterPlugin {return {name: "my-plugin",version: "1.0.0",serverStart: {main: async () => {// Trigger build with entrypointsconst result = await builder.build("/src/client.ts","/src/entry.tsx");if (result.success) {console.log("Build successful!", result.outputs.length, "files");} else {console.error("Build failed:", result.logs);}},},};}
Builder Instance Methods
builder.build(...entrypoints)(...entrypoints: string[]) => Promise<Bun.BuildOutput>Execute the build with merged plugin configurations. Accepts absolute file paths as entrypoints.
→See detailed documentationbuilder.isBuilding()() => booleanCheck if a build is currently in progress. Useful for preventing concurrent builds.
builder.awaitBuildFinish()() => Promise<Bun.BuildOutput> | nullReturns a promise that resolves when the current build finishes, or null if no build is running.
builder.getConfig()() => Bun.BuildConfig | nullGet the current merged build configuration from all plugins.
builder.analyzeBuild()() => BuildAnalysisAnalyze the last build's outputs including sizes, artifact types, and largest files.
builder.generateReport(format)(format: 'text' | 'json') => stringGenerate a formatted build report for logging or monitoring.
⚙️ Build Configuration
Configure how your plugin contributes to the build pipeline.
Static Configuration
Static configs are merged once when the builder is imported. Use for configurations that don't change at build time.
export function myPlugin(): FrameMasterPlugin {return {name: "my-plugin",version: "1.0.0",build: {buildConfig: {external: ["react", "react-dom"],target: "browser",minify: true,sourcemap: "external",define: {"process.env.NODE_ENV": JSON.stringify("production"),},},},};}
Dynamic Configuration
Dynamic configs are functions called on each builder.build(). Use for runtime-dependent configurations.
build: {buildConfig: async (builder) => {const isDev = process.env.NODE_ENV !== "production";return {external: ["react", "react-dom"],minify: !isDev,sourcemap: isDev ? "inline" : "external",define: {"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),"__DEBUG__": isDev.toString(),"__BUILD_TIME__": JSON.stringify(new Date().toISOString()),},plugins: [myCustomBunPlugin({ debug: isDev }),],};},}
Type-Safe Configuration
Use defineBuildConfig helper for full TypeScript autocomplete.
import { defineBuildConfig } from "frame-master/build";build: {buildConfig: defineBuildConfig({target: "browser", // Full autocomplete!external: ["react"],minify: true,splitting: true,// TypeScript catches typos and invalid values}),}
🔀 Configuration Merging
How Frame-Master merges configurations from multiple plugins.
Smart Merging Strategies
Different configuration keys are merged using appropriate strategies to prevent conflicts and ensure all plugins contribute correctly.
Merging Strategies by Key
entrypointsDeduplicated and concatenatedexternalDeduplicated and concatenatedpluginsConcatenated (preserves order)defineDeep merged (source overrides target)loaderDeep merged (source overrides target)primitivesLast plugin wins (with warning)// Plugin AbuildConfig: {external: ["react"],define: { "__A__": "true" },}// Plugin BbuildConfig: {external: ["lodash"],define: { "__B__": "true" },}// Merged Result{external: ["react", "lodash"], // Both includeddefine: {"__A__": "true", // From Plugin A"__B__": "true", // From Plugin B},}
🪝 Lifecycle Hooks
Execute custom logic before and after the build process.
beforeBuild
beforeBuild(config: Bun.BuildConfig, builder: Builder) => Promise<void>Called after configs are merged but before Bun.build() executes. All plugins' beforeBuild hooks run in parallel.
build: {beforeBuild: async (buildConfig, builder) => {console.log("Starting build with", buildConfig.entrypoints.length, "entries");// Validate configurationif (!buildConfig.outdir) {throw new Error("Output directory not specified");}// Generate build manifestawait Bun.write(".frame-master/build-info.json", JSON.stringify({timestamp: new Date().toISOString(),entrypoints: buildConfig.entrypoints,target: buildConfig.target,}));// some work before build...},}
afterBuild
afterBuild(config: Bun.BuildConfig, result: Bun.BuildOutput, builder: Builder) => Promise<void>Called after Bun.build() completes. Only executes if build succeeded. Access outputs via builder.outputs.
Output Directory Cleanup
After each build, Frame-Master automatically removes any files in the output directory that are not referenced in result.outputs. If your plugin needs to add files to the output directory, you must push them as build artifacts to prevent deletion.
build: {afterBuild: async (buildConfig, result, builder) => {if (!result.success) {console.error("Build failed!");return;}console.log("✅ Build successful!");// Log all generated filesfor (const output of result.outputs) {console.log(` ${output.path} (${output.kind}) - ${output.size} bytes`);}// ⚠️ IMPORTANT: Files added to outdir will be deleted unless registered!// To add a custom file, write it and push to result.outputsconst manifestPath = `${buildConfig.outdir}/manifest.json`;const manifestContent = JSON.stringify({files: result.outputs.map(o => o.path),buildTime: new Date().toISOString(),}, null, 2);await Bun.write(manifestPath, manifestContent);// Register the file as a build artifact to prevent cleanup deletionresult.outputs.push({path: manifestPath,kind: "asset",hash: "",loader: "file",} as Bun.BuildArtifact);// Analyze bundle sizesconst analysis = builder.analyzeBuild();if (analysis.totalSize > 1_000_000) {console.warn("⚠️ Bundle exceeds 1MB:", analysis.totalSize);}},}
enableLoging
enableLogingbooleanEnable detailed logging during build. When ANY plugin sets this to true, logging is enabled for all builds.
Default: false
build: {// Enable in development onlyenableLoging: process.env.NODE_ENV !== "production",// Or with environment variable// enableLoging: process.env.DEBUG_BUILD === "true",}
📊 Build Analysis
Analyze build outputs and track build performance.
analyzeBuild()
const result = await builder.build("/src/client.ts");if (result.success) {const analysis = builder.analyzeBuild();console.log("Total size:", analysis.totalSize, "bytes");console.log("Average file size:", analysis.averageSize, "bytes");console.log("Files generated:", analysis.artifacts.length);// Check largest filesconsole.log("\nLargest files:");for (const file of analysis.largestFiles.slice(0, 5)) {console.log(` ${file.path}: ${file.size} bytes`);}// Group by artifact kindconsole.log("\nBy kind:");for (const [kind, stats] of Object.entries(analysis.byKind)) {console.log(` ${kind}: ${stats.count} files (${stats.totalSize} bytes)`);}}
Analysis Object Structure
interface BuildAnalysis {totalSize: number;averageSize: number;artifacts: Array<{path: string;size: number;kind: string;}>;largestFiles: Array<{ path: string; size: number }>;byKind: Record<string, { count: number; totalSize: number }>;}
Build History
// Get build historyconst history = builder.getBuildHistory();// Calculate average build timeconst avgDuration = history.reduce((sum, b) => sum + b.duration, 0) / history.length;console.log("Average build time:", avgDuration.toFixed(2), "ms");// Calculate success rateconst successRate = history.filter(b => b.success).length / history.length;console.log("Success rate:", (successRate * 100).toFixed(1), "%");// Clear history when neededbuilder.clearBuildHistory();
Generate Reports
// Console-friendly text reportconst textReport = builder.generateReport("text");console.log(textReport);// Output:// 📊 Build Report// ══════════════════════════════════════════════════//// Total Files: 12// Total Size: 245.32KB// Average Size: 20.44KB// Build Duration: 156.23ms// Status: ✅ Success//// 📦 By Artifact Kind:// chunk: 8 files (198.45KB)// entry-point: 4 files (46.87KB)//// 🔝 Largest Files:// 89.23KB - build/client-abc123.js// 45.12KB - build/vendor-def456.js// ...// JSON report for monitoring systemsconst jsonReport = builder.generateReport("json");await sendToMonitoring(JSON.parse(jsonReport));
🔒 Concurrent Build Protection
Prevent race conditions when multiple builds are triggered.
No Concurrent Builds
The builder throws an error if you attempt to start a build while another is in progress. Use isBuilding() and awaitBuildFinish() to coordinate.
// Check before starting a buildif (builder.isBuilding()) {console.log("Build in progress, waiting...");await builder.awaitBuildFinish();}await builder.build("/src/client.ts");// Or handle in file watcherfunction onFileChange() {if (builder.isBuilding()) {console.log("Skipping rebuild - build already in progress");return;}builder.build("/src/index.ts");}// Coordinate with other async tasksasync function deployAfterBuild() {const ongoing = builder.awaitBuildFinish();if (ongoing) {console.log("Waiting for build...");const result = await ongoing;if (!result.success) {console.error("Build failed, skipping deployment");return;}}await deployToServer();}
🧹 Output Directory Cleanup
Frame-Master automatically cleans orphaned files from the output directory after each build.
Important: File Cleanup Behavior
After each successful build, Frame-Master scans the output directory and deletes any files not present in the build outputs. This ensures a clean build directory without stale artifacts.
How It Works
- Build completes and generates
result.outputs - Builder scans all files in
outdir - Files NOT in
result.outputsare deleted afterBuildhooks execute
Adding Custom Files to Output
If your plugin needs to write additional files to the output directory (manifests, assets, etc.), you must register them as build artifacts to prevent automatic deletion.
afterBuild: async (config, result, builder) => {if (!result.success) return;// Write your custom fileconst customFilePath = `${config.outdir}/my-custom-file.json`;await Bun.write(customFilePath, JSON.stringify({ data: "value" }));// ✅ Register it as a build artifact to prevent deletionresult.outputs.push({path: customFilePath,kind: "asset",hash: "",loader: "file",} as Bun.BuildArtifact);// Now the file will survive the cleanup!}
Multiple Custom Files
afterBuild: async (config, result, builder) => {if (!result.success) return;// Helper to write and register filesconst writeAndRegister = async (filename: string, content: string) => {const filePath = `${config.outdir}/${filename}`;await Bun.write(filePath, content);result.outputs.push({path: filePath,kind: "asset",hash: "",loader: "file",} as Bun.BuildArtifact);};// Write multiple custom filesawait writeAndRegister("manifest.json", JSON.stringify({version: "1.0.0",files: result.outputs.map(o => o.path),}));await writeAndRegister("build-info.txt", `Built at: ${new Date().toISOString()}`);await writeAndRegister("assets/config.json", JSON.stringify({api: process.env.API_URL}));}
🛠️ Helper Utilities
Utility methods for common build tasks.
pluginRegexMake
Builder.pluginRegexMake({ path, ext }) => RegExpCreate file filter patterns for Bun plugins based on path and extensions.
import Builder from "frame-master/build";// Match TypeScript files in src/componentsconst filter = Builder.pluginRegexMake({path: ["src", "components"],ext: ["ts", "tsx"],});// Matches: src/components/Button.tsx, src/components/utils/helper.ts// Use in a Bun pluginconst plugin: BunPlugin = {name: "my-plugin",setup(build) {build.onLoad({ filter }, async (args) => {// Handle matched filesreturn { contents: "...", loader: "ts" };});},};
returnEmptyFile
Builder.returnEmptyFile(loader: Bun.Loader, module: object) => { contents: string, loader }Generate stub exports for server-only modules to prevent client-side usage.
import Builder from "frame-master/build";// In a Bun plugin to block server-only files in client buildsbuild.onLoad({ filter: /\.server\.(ts|tsx)$/ }, async (args) => {const mod = await import(args.path);return Builder.returnEmptyFile("tsx", mod);});// Result: Client bundle contains error-throwing stubs// export default function _default() {// throw new Error("[ default ] This is server-only component...")// }// export const serverFunction = () => {// throw new Error("[ serverFunction ] This is server-only component...")// }
💻 CLI Build Command
Run production builds from the command line.
# Run production buildNODE_ENV=production frame-master build# With verbose outputNODE_ENV=production frame-master build --verbose
Build Command Flow
- Sets BUILD_MODE environment variable
- Initializes all plugins via InitAll()
- Imports and initializes the singleton builder
- Triggers build with merged configurations
- Displays build summary and analysis
📝 Complete Example
A full plugin example using all build features.
import { builder, defineBuildConfig, Builder } from "frame-master/build";import type { FrameMasterPlugin } from "frame-master";export function myBuildPlugin(): FrameMasterPlugin {return {name: "my-build-plugin",version: "1.0.0",build: {// Dynamic configurationbuildConfig: async (builder) => {const isDev = process.env.NODE_ENV !== "production";return defineBuildConfig({target: "browser",external: ["react", "react-dom"],minify: !isDev,sourcemap: isDev ? "inline" : "external",splitting: true,define: {"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),"__VERSION__": JSON.stringify("1.0.0"),},plugins: [{name: "server-stub",setup(build) {// Stub server-only files in client bundlebuild.onLoad({ filter: /\.server\.tsx?$/ }, async (args) => {const mod = await import(args.path);return Builder.returnEmptyFile("tsx", mod);});},},],});},// Pre-build hookbeforeBuild: async (config, builder) => {console.log("🔨 Starting build...");console.log(" Entrypoints:", config.entrypoints.length);console.log(" Output:", config.outdir);// Generate build timestampawait Bun.write(".frame-master/build-time.txt", new Date().toISOString());},// Post-build hookafterBuild: async (config, result, builder) => {if (!result.success) {console.error("❌ Build failed");return;}console.log("✅ Build complete!");// Analyze and reportconst analysis = builder.analyzeBuild();const formatSize = (bytes: number) => {if (bytes < 1024) return `${bytes}B`;if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)}KB`;return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;};console.log(` Total: ${formatSize(analysis.totalSize)}`);console.log(` Files: ${analysis.artifacts.length}`);// Warn on large bundlesfor (const file of analysis.largestFiles.slice(0, 3)) {if (file.size > 500_000) {console.warn(` ⚠️ Large file: ${file.path} (${formatSize(file.size)})`);}}// Generate manifestawait Bun.write(`${config.outdir}/manifest.json`,JSON.stringify({buildTime: new Date().toISOString(),files: analysis.artifacts.map(a => ({path: a.path,size: a.size,kind: a.kind,})),totalSize: analysis.totalSize,}, null, 2));},enableLoging: process.env.DEBUG_BUILD === "true",},serverStart: {main: async () => {// Trigger build on server start (production)if (process.env.NODE_ENV === "production") {const result = await builder.build();if (!result.success) {throw new Error("Production build failed");}}},},};}
