@trebired/bundler
v3.1.1
Published
Discover-only esbuild bundler wrapper with SCSS support, watch mode, and runtime asset manifests.
Maintainers
Readme
@trebired/bundler
Discover-only bundler wrapper around esbuild with SCSS support, watch mode, source annotations, and a runtime-friendly asset manifest.
@trebired/bundler now has one public entry model: discovery rules. You describe what the bundler should find, whether each matched file should stay isolated, join a grouped bundle, or be ignored, and the package handles the rest.
Install
Runtime support: Bun 1+ and Node.js 18+.
npm install @trebired/bundlerQuick Start
import { bundle } from "@trebired/bundler";
await bundle({
discover: {
dir: "./src/frontend",
rules: [
{
key: "client",
include: ["**/*.client.ts", "**/*.client.tsx"],
strategy: "entry",
},
{
key: "defer",
include: ["**/*.defer.ts"],
strategy: "entry",
},
{
key: "global-style",
include: ["css/**/*.css", "css/**/*.scss"],
strategy: "bundle",
maxBundleSize: "50mb",
},
{
key: "shared-script",
include: ["**/*.ts", "**/*.js"],
exclude: ["**/*.client.ts", "**/*.client.tsx", "**/*.defer.ts"],
strategy: "bundle",
maxBundleSize: "50mb",
},
{
key: "ignored-tests",
include: ["**/*.test.*", "**/*.spec.*"],
strategy: "ignore",
},
],
},
outDir: "./dist",
sourcemap: "external",
annotateSources: true,
manifest: true,
});CLI
Create a config module:
import { defineBundlerConfig } from "@trebired/bundler";
export default defineBundlerConfig({
discover: {
dir: "./src/frontend",
rules: [
{
key: "client",
include: ["**/*.client.ts", "**/*.client.tsx"],
strategy: "entry",
},
{
key: "global-style",
include: ["css/**/*.css", "css/**/*.scss"],
strategy: "bundle",
},
{
key: "shared-script",
include: ["shared/**/*.ts", "shared/**/*.js"],
strategy: "bundle",
},
],
},
outDir: "./dist",
manifest: true,
});Run:
trebired-bundler build --config ./bundler.config.mjs
trebired-bundler watch --config ./bundler.config.mjsDiscover Rules
Rules are ordered. First match wins.
entry: keep one output entry per matched filebundle: group all matched files together, then split only when the whole group exceedsmaxBundleSizeaggregate: synthesize one internal entry module in memory from many discovered modulesignore: track the file as intentionally ignored and exclude it from outputs
Every discovered file must match exactly one rule. If a file is in scope and matches nothing, the build fails.
maxBundleSize
- only valid on
bundlerules - defaults to
50mb - accepts bytes or strings like
"512kb","50mb", or"1gb" - splits by summed source-file size before handing grouped parts to
esbuild - fails the build if a single grouped file is larger than the configured limit
Bundle Naming
Grouped outputs always use package-owned names:
bundle-<stable-id>.jsbundle-<stable-id>-2.jsbundle-<stable-id>.cssbundle-<stable-id>-2.css
Callers do not provide custom grouped bundle names.
Aggregate Rules
Use strategy: "aggregate" when you need one generated entry module without writing any temporary source file to disk.
The first built-in aggregate kind is module-map. It:
- optionally imports one configured root module
- imports every matched module with namespace imports
- resolves a configured export from each matched module
- builds a map object keyed by normalized discovered path
- exports the map, a resolver function, and optionally the root module binding
- can also export a default object containing the same fields
Example:
await bundle({
discover: {
dir: "./src/frontend",
rules: [
{
key: "ssr-pages",
include: ["pages/**/*.tsx"],
exclude: ["**/*.client.tsx", "**/*.defer.tsx", "**/*.spec.tsx", "**/*.test.tsx"],
strategy: "aggregate",
aggregate: {
kind: "module-map",
rootModule: "layouts/root_document.tsx",
collapseIndex: true,
exports: {
root: "rootDocument",
map: "pages",
resolver: "getPageComponent",
default: true,
},
},
},
],
},
outDir: "./dist",
});Path keys default to the matched module path relative to the aggregate rule root, without the file extension:
pages/home.tsx->homepages/blog/post.tsx->blog/postpages/settings/index.tsx->settingswhencollapseIndex: true
Frontend Conventions
This API is meant for conventions like:
*.client.ts*.client.tsx*.defer.ts- global
css/**/*.css - global
css/**/*.scss
Typical setup:
- client boot files use
strategy: "entry" - defer boot files use
strategy: "entry" - shared JS/TS helpers use
strategy: "bundle" - global CSS/SCSS uses
strategy: "bundle" - tests and non-runtime files use
strategy: "ignore"
Important behavior:
- grouped
bundlerules must stay style-only or script-only; mixing CSS/SCSS with JS/TS in one rule fails *.client.*and*.defer.*entries may not import JS/TS files owned by a grouped bundle rule; that fails the build because those files are treated as shared standalone bundles, not implicit app-entry dependencies
Manifest
Set manifest: true to write dist/bundler-manifest.json, or pass manifest: { file: "custom-name.json" }.
The build result also exposes assetManifest directly.
import { buildAssetManifest, bundle, collectAssetLinks } from "@trebired/bundler";
const result = await bundle({
discover: {
dir: "./src/frontend",
rules: [
{
key: "client",
include: ["**/*.client.ts", "**/*.client.tsx"],
strategy: "entry",
},
{
key: "global-style",
include: ["css/**/*.css", "css/**/*.scss"],
strategy: "bundle",
},
],
},
outDir: "./dist",
});
const assetManifest = result.assetManifest || buildAssetManifest({
metafile: result.metafile!,
outDir: "./dist",
rootDir: process.cwd(),
resolvedDiscovery: result.resolvedDiscovery,
});
const assets = collectAssetLinks(assetManifest, [
"src/frontend/home.client.tsx",
], {
from: "source",
publicPath: "/",
});Asset Manifest Shape
result.entries is a source ownership map:
Record<string, string>
// source path -> owning entry keyassetManifest exposes:
sources[sourcePath]: source file -> owning entry key, rule key, strategy, outputsentries[entryKey]: entry or grouped bundle -> owned sources, outputs, JS, CSS, assets, plus generated/aggregate metadata when applicableentryOutputs[emittedFile]: emitted entry output -> entry keyoutputs[outputFile]: normalized output metadatarules[ruleKey]: grouped entry keys plus ignored sources for that rule, plus aggregate metadata when applicable
This lets runtime code resolve either:
- a source path to its owning entry key
- an entry key to the emitted scripts/styles/assets
- a grouped bundle back to the exact source files it owns
Collecting Runtime Links
Use collectAssetLinks() when app code needs scripts and styles for one or more sources or entry keys.
Supported lookup modes:
from: "source"from: "entryKey"from: "entryOutput"from: "ruleKey"from: "auto"(default)
Watch Mode
watch() stays discover-driven.
- added or removed matching files trigger a discovery rescan
- if source ownership changes, the bundler rebuilds the esbuild context
onEntrySetChanged()receives the new source ownership maponRebuilt()receives the fullBundlerBuildResult- invalid intermediate states still surface failures, but the watcher keeps running and recovers on the next valid filesystem change
import { watch } from "@trebired/bundler";
const session = await watch({
discover: {
dir: "./src/frontend",
rules: [
{
key: "client",
include: ["**/*.client.ts", "**/*.client.tsx"],
strategy: "entry",
},
{
key: "shared-script",
include: ["shared/**/*.ts", "shared/**/*.js"],
strategy: "bundle",
},
],
},
outDir: "./dist",
async onEntrySetChanged(entries) {
console.log(entries);
},
async onRebuilt(result) {
console.log(result.outputs);
},
});
await session.dispose();Import Graph Walking
Use walkImportGraph() when a higher-level tool needs to inspect internal source dependencies without bundling:
import { walkImportGraph } from "@trebired/bundler";
const graph = await walkImportGraph({
entries: "./src/app.tsx",
rootDir: process.cwd(),
});It resolves:
- relative imports
- re-exports
- string-literal dynamic imports
- tsconfig
paths
Public Config Shape
type BundlerDiscoverRule =
| {
key: string;
include: string[];
exclude?: string[];
strategy: "entry";
}
| {
key: string;
include: string[];
exclude?: string[];
strategy: "bundle";
maxBundleSize?: number | string;
}
| {
key: string;
include: string[];
exclude?: string[];
strategy: "ignore";
}
| {
key: string;
include: string[];
exclude?: string[];
strategy: "aggregate";
aggregate: {
kind: "module-map";
rootModule?: string;
rootModuleExportName?: string;
matchedModuleExportName?: string;
keyFromPath?: "relative-path";
collapseIndex?: boolean;
allowEmpty?: boolean;
exports?: {
root?: string;
map: string;
resolver: string;
default?: boolean;
};
};
};
type BundlerDiscoverOptions = {
dir: string;
rules: BundlerDiscoverRule[];
ignoreDirs?: string[];
};
type BundlerOptions = {
discover: BundlerDiscoverOptions | BundlerDiscoverOptions[];
outDir: string;
rootDir?: string;
environment?: "browser" | "node" | "neutral";
format?: Format;
target?: string | string[];
minify?: boolean;
stripComments?: boolean;
sourcemap?: boolean | "inline" | "external";
splitting?: boolean;
publicPath?: string;
external?: string[];
define?: Record<string, string>;
clean?: boolean;
annotateSources?: boolean;
manifest?: boolean | { file?: string };
onRebuilt?: (result: BundlerBuildResult) => void | Promise<void>;
onEntrySetChanged?: (entries: Record<string, string>) => void | Promise<void>;
logger?: BundlerLogger;
loggerAdapter?: BundlerLoggerAdapter;
};Migration Notes
This release removes the old mixed entry model.
entriesis gone- public
virtualEntriesis gone modeis gonediscover.include/discover.excludeat the top level is replaced by ordereddiscover.rules- runtime code using
entryNamesorentrySourcesshould move toassetManifest.sourcesandassetManifest.entries
What It Does Not Do
This package does not:
- replace
esbuild - provide a dev server or HMR
- invent a custom runtime module system
- auto-convert grouped shared JS/TS sources into dependency-safe page entry imports
