Build System
Frame-Master uses a singleton builder that merges configurations from every plugin into one coordinated build pipeline.
📦 Overview
- Single shared
builderinstance that includes configs from all loaded plugins - beforeBuild/afterBuild lifecycle hooks for custom logic
- Build analytics, history, and report generation
- Concurrent build protection to prevent race conditions
🚀 Using the Builder
Basic Usage
import { builder } from "frame-master/build";
export function myPlugin(): FrameMasterPlugin {
return {
name: "my-plugin",
version: "1.0.0",
serverStart: {
main: async () => {
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 Methods
builder.build(...entrypoints)→ run build with merged configbuilder.isBuilding()→ check if a build is in progressbuilder.awaitBuildFinish()→ wait for the current build, ornullif idlebuilder.getConfig()→ current mergedBuildConfigbuilder.analyzeBuild()→ size/kind stats for last buildbuilder.generateReport("text" | "json")→ formatted report
⚙️ Build Configuration
Static Configuration
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
build: {
buildConfig: async () => {
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
import { defineBuildConfig } from "frame-master/build";
build: {
buildConfig: defineBuildConfig({
target: "browser",
external: ["react"],
minify: true,
splitting: true,
}),
}🔀 Configuration Merging
entrypoints,external: deduplicated + concatenatedplugins: concatenated (order preserved), then normalized through Frame-Master's build-plugin chaining by defaultdefine,loader: deep merged (later wins)- primitives: last plugin wins (warns)
Example merge:
// Plugin A
buildConfig: { external: ["react"], define: { __A__: "true" } }
// Plugin B
buildConfig: { external: ["lodash"], define: { __B__: "true" } }
// Result
external: ["react", "lodash"]
define: { __A__: "true", __B__: "true" }Build Plugin Chaining
Frame-Master does more than append Bun plugins together. By default, all build plugins contributed through buildConfig.plugins are wrapped into one chained loader so multiple plugins can transform the same file in sequence.
That means this:
// Plugin A
buildConfig: {
plugins: [pluginA],
}
// Plugin B
buildConfig: {
plugins: [pluginB],
}behaves like one ordered pipeline instead of Bun's normal "first matching onLoad wins" behavior.
What gets chained
onLoadhandlers are chained in registration order- Frame-Master's
build.finally(loader, callback)hooks run after chainedonLoadhandlers for the matching loader onResolve,onStart,onEnd, andonBeforeParsestill pass through normally
Why it matters
If two plugins both match .tsx files, both can participate in the final output:
source.tsx -> plugin A onLoad -> plugin B onLoad -> finally(html|js|css...) -> final bundled artifactThis is what enables composable transforms such as:
- one plugin injecting imports
- another plugin wrapping components or rewriting exports
- a final post-processing step adjusting emitted HTML, CSS, or JS
Reading prior chained output
Inside a chained onLoad, do not assume you should always re-read the source file from disk. Earlier handlers may already have transformed it.
Use the helper from the chaining API instead:
import { getChainableContent } from "frame-master/plugin";
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
const content = await getChainableContent(args);
return {
contents: `import "my-lib";\n${content}`,
loader: "tsx",
};
});Available chain-aware fields:
args.__chainedContents— content returned by the previous handlerargs.__chainedLoader— loader returned by the previous handler
For binary data, use getChainableBinaryContent(args) instead of getChainableContent(args).
Post-processing with build.finally(...)
Frame-Master extends Bun's plugin API with a finally hook that runs after the chained onLoad pipeline for a loader has finished.
build: {
buildConfig: {
plugins: [
{
name: "html-minify-pass",
setup(build) {
build.finally("html", ({ contents }) => ({
contents:
typeof contents === "string"
? contents.replace(/>\s+</g, "><")
: contents,
}));
},
},
],
},
}Use finally when a transformation should happen after all onLoad handlers for that loader have already run.
Disabling chaining
Chaining is enabled by default. Disable it only if a plugin requires Bun's raw first-match loader behavior:
import { defineConfig } from "frame-master";
export default defineConfig({
plugins: [
// ...
],
pluginsOptions: {
disableOnLoadChaining: true,
},
});When disabled, build plugins no longer receive Frame-Master chaining features such as ordered onLoad composition, args.__chainedContents, args.__chainedLoader, or build.finally(...).
🪝 Lifecycle Hooks
beforeBuild
build: {
beforeBuild: async (config) => {
console.log("Starting build", config.entrypoints.length, "entries");
if (!config.outdir) throw new Error("Output directory not specified");
await Bun.write(
".frame-master/build-info.json",
JSON.stringify({ timestamp: new Date().toISOString(), entrypoints: config.entrypoints, target: config.target })
);
},
}afterBuild
build: {
afterBuild: async (config, result, builder) => {
if (!result.success) return;
const manifestPath = `${config.outdir}/manifest.json`;
await Bun.write(
manifestPath,
JSON.stringify({ files: result.outputs.map((o) => o.path), buildTime: new Date().toISOString() }, null, 2)
);
// Register custom files so cleanup keeps them
result.outputs.push({ path: manifestPath, kind: "asset", hash: "", loader: "file" } as Bun.BuildArtifact);
const analysis = builder.analyzeBuild();
if (analysis.totalSize > 1_000_000) console.warn("Bundle exceeds 1MB", analysis.totalSize);
},
}enableLoging
build: {
enableLoging: process.env.NODE_ENV !== "production",
}📊 Build Analysis
const result = await builder.build("/src/client.ts");
if (result.success) {
const analysis = builder.analyzeBuild();
console.log("Total size", analysis.totalSize);
console.log("Average size", analysis.averageSize);
console.log("Largest", analysis.largestFiles.slice(0, 5));
console.log("By kind", analysis.byKind);
}History and reports:
const history = builder.getBuildHistory();
const successRate = history.filter((b) => b.success).length / history.length;
builder.clearBuildHistory();
const textReport = builder.generateReport("text");
const jsonReport = builder.generateReport("json");🔒 Concurrent Build Protection
if (builder.isBuilding()) await builder.awaitBuildFinish();
await builder.build("/src/index.ts");Warning: The builder throws if you start a build while another is running—check first.
🧹 Output Cleanup
After each successful build, files not listed in result.outputs are deleted from outdir.
Register custom files:
afterBuild: async (config, result) => {
if (!result.success) return;
const path = `${config.outdir}/my-custom-file.json`;
await Bun.write(path, JSON.stringify({ data: "value" }));
result.outputs.push({
path,
kind: "asset",
hash: "",
loader: "file",
} as Bun.BuildArtifact);
};🛠️ Helper Utilities
import Builder from "frame-master/build";
// Build regex for Bun plugins
const filter = Builder.pluginRegexMake({
path: ["src", "components"],
ext: ["ts", "tsx"],
});
// Return empty stubs for server-only modules
build.onLoad({ filter: /\.server\.(ts|tsx)$/ }, async (args) => {
const mod = await import(args.path);
return Builder.returnEmptyFile("tsx", mod);
});💻 CLI Build Command
NODE_ENV=production frame-master build
NODE_ENV=production frame-master build --verboseFlow: sets env, initializes plugins, loads builder, runs build, prints summary.
📝 Complete Example
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: {
buildConfig: async () => {
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) {
build.onLoad({ filter: /\.server\.tsx?$/ }, async (args) => {
const mod = await import(args.path);
return Builder.returnEmptyFile("tsx", mod);
});
},
},
],
});
},
beforeBuild: async (config) => {
console.log("🔨 Starting build...", config.entrypoints.length);
await Bun.write(
".frame-master/build-time.txt",
new Date().toISOString(),
);
},
afterBuild: async (config, result, builder) => {
if (!result.success) return;
const analysis = builder.analyzeBuild();
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 () => {
if (process.env.NODE_ENV === "production") {
const result = await builder.build();
if (!result.success) throw new Error("Production build failed");
}
},
},
};
}