Documentation

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

my-plugin.ts
import { builder } from "frame-master/build";
export function myPlugin(): FrameMasterPlugin {
return {
name: "my-plugin",
version: "1.0.0",
serverStart: {
main: async () => {
// Trigger build with entrypoints
const 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 documentation
builder.isBuilding()() => boolean

Check if a build is currently in progress. Useful for preventing concurrent builds.

builder.awaitBuildFinish()() => Promise<Bun.BuildOutput> | null

Returns a promise that resolves when the current build finishes, or null if no build is running.

builder.getConfig()() => Bun.BuildConfig | null

Get the current merged build configuration from all plugins.

builder.analyzeBuild()() => BuildAnalysis

Analyze the last build's outputs including sizes, artifact types, and largest files.

builder.generateReport(format)(format: 'text' | 'json') => string

Generate 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.

static-config.ts
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.

dynamic-config.ts
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.

typed-config.ts
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 concatenated
externalDeduplicated and concatenated
pluginsConcatenated (preserves order)
defineDeep merged (source overrides target)
loaderDeep merged (source overrides target)
primitivesLast plugin wins (with warning)
merge-example.ts
// Plugin A
buildConfig: {
external: ["react"],
define: { "__A__": "true" },
}
// Plugin B
buildConfig: {
external: ["lodash"],
define: { "__B__": "true" },
}
// Merged Result
{
external: ["react", "lodash"], // Both included
define: {
"__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.

before-build.ts
build: {
beforeBuild: async (buildConfig, builder) => {
console.log("Starting build with", buildConfig.entrypoints.length, "entries");
// Validate configuration
if (!buildConfig.outdir) {
throw new Error("Output directory not specified");
}
// Generate build manifest
await 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.

after-build.ts
build: {
afterBuild: async (buildConfig, result, builder) => {
if (!result.success) {
console.error("Build failed!");
return;
}
console.log("✅ Build successful!");
// Log all generated files
for (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.outputs
const 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 deletion
result.outputs.push({
path: manifestPath,
kind: "asset",
hash: "",
loader: "file",
} as Bun.BuildArtifact);
// Analyze bundle sizes
const analysis = builder.analyzeBuild();
if (analysis.totalSize > 1_000_000) {
console.warn("⚠️ Bundle exceeds 1MB:", analysis.totalSize);
}
},
}

enableLoging

enableLogingboolean

Enable detailed logging during build. When ANY plugin sets this to true, logging is enabled for all builds.

Default: false

logging.ts
build: {
// Enable in development only
enableLoging: 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()

analyze-build.ts
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 files
console.log("\nLargest files:");
for (const file of analysis.largestFiles.slice(0, 5)) {
console.log(` ${file.path}: ${file.size} bytes`);
}
// Group by artifact kind
console.log("\nBy kind:");
for (const [kind, stats] of Object.entries(analysis.byKind)) {
console.log(` ${kind}: ${stats.count} files (${stats.totalSize} bytes)`);
}
}

Analysis Object Structure

analysis-types.ts
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

build-history.ts
// Get build history
const history = builder.getBuildHistory();
// Calculate average build time
const avgDuration = history.reduce((sum, b) => sum + b.duration, 0) / history.length;
console.log("Average build time:", avgDuration.toFixed(2), "ms");
// Calculate success rate
const successRate = history.filter(b => b.success).length / history.length;
console.log("Success rate:", (successRate * 100).toFixed(1), "%");
// Clear history when needed
builder.clearBuildHistory();

Generate Reports

reports.ts
// Console-friendly text report
const 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 systems
const 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.

concurrent-protection.ts
// Check before starting a build
if (builder.isBuilding()) {
console.log("Build in progress, waiting...");
await builder.awaitBuildFinish();
}
await builder.build("/src/client.ts");
// Or handle in file watcher
function onFileChange() {
if (builder.isBuilding()) {
console.log("Skipping rebuild - build already in progress");
return;
}
builder.build("/src/index.ts");
}
// Coordinate with other async tasks
async 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

  1. Build completes and generates result.outputs
  2. Builder scans all files in outdir
  3. Files NOT in result.outputs are deleted
  4. afterBuild hooks 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.

register-output.ts
afterBuild: async (config, result, builder) => {
if (!result.success) return;
// Write your custom file
const customFilePath = `${config.outdir}/my-custom-file.json`;
await Bun.write(customFilePath, JSON.stringify({ data: "value" }));
// ✅ Register it as a build artifact to prevent deletion
result.outputs.push({
path: customFilePath,
kind: "asset",
hash: "",
loader: "file",
} as Bun.BuildArtifact);
// Now the file will survive the cleanup!
}

Multiple Custom Files

multiple-files.ts
afterBuild: async (config, result, builder) => {
if (!result.success) return;
// Helper to write and register files
const 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 files
await 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 }) => RegExp

Create file filter patterns for Bun plugins based on path and extensions.

plugin-regex.ts
import Builder from "frame-master/build";
// Match TypeScript files in src/components
const filter = Builder.pluginRegexMake({
path: ["src", "components"],
ext: ["ts", "tsx"],
});
// Matches: src/components/Button.tsx, src/components/utils/helper.ts
// Use in a Bun plugin
const plugin: BunPlugin = {
name: "my-plugin",
setup(build) {
build.onLoad({ filter }, async (args) => {
// Handle matched files
return { 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.

empty-file.ts
import Builder from "frame-master/build";
// In a Bun plugin to block server-only files in client builds
build.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.

terminal
# Run production build
NODE_ENV=production frame-master build
# With verbose output
NODE_ENV=production frame-master build --verbose
💡

Build Command Flow

  1. Sets BUILD_MODE environment variable
  2. Initializes all plugins via InitAll()
  3. Imports and initializes the singleton builder
  4. Triggers build with merged configurations
  5. Displays build summary and analysis

📝 Complete Example

A full plugin example using all build features.

complete-build-plugin.ts
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 configuration
buildConfig: 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 bundle
build.onLoad({ filter: /\.server\.tsx?$/ }, async (args) => {
const mod = await import(args.path);
return Builder.returnEmptyFile("tsx", mod);
});
},
},
],
});
},
// Pre-build hook
beforeBuild: async (config, builder) => {
console.log("🔨 Starting build...");
console.log(" Entrypoints:", config.entrypoints.length);
console.log(" Output:", config.outdir);
// Generate build timestamp
await Bun.write(".frame-master/build-time.txt", new Date().toISOString());
},
// Post-build hook
afterBuild: async (config, result, builder) => {
if (!result.success) {
console.error("❌ Build failed");
return;
}
console.log("✅ Build complete!");
// Analyze and report
const 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 bundles
for (const file of analysis.largestFiles.slice(0, 3)) {
if (file.size > 500_000) {
console.warn(` ⚠️ Large file: ${file.path} (${formatSize(file.size)})`);
}
}
// Generate manifest
await 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");
}
}
},
},
};
}

🎯Next Steps