@savvy-web/bundler
v1.1.11
Published
Zero-config tsdown-based bundler for Silk Suite TypeScript packages
Downloads
6,392
Readme
@savvy-web/bundler
The zero-config bundler for Silk Suite TypeScript packages. Configure a package with a single self-executing savvy.build.ts, run it against the dev or npm target and get a clean, publishable dist/<target>/pkg. Install one devDependency; tsdown is pinned and tested transitively, so a toolchain upgrade is a bundler release rather than a peer bump across your repos.
Install
npm install --save-dev @savvy-web/bundler
# or
pnpm add -D @savvy-web/bundlerQuick start
Add a savvy.build.ts to the package root:
// savvy.build.ts
import { build } from "@savvy-web/bundler";
await build({
format: ["esm"],
devManifest: "preserve",
});Wire the two targets into package.json scripts and run them with Node's native TypeScript support (Node 24.11+):
{
"scripts": {
"build:dev": "node savvy.build.ts --target dev",
"build:prod": "node savvy.build.ts --target prod"
}
}npm run build:prod
# writes dist/prod/npm/pkg — the tarball root, with a resolved manifest and built code--target dev writes dist/dev/pkg, the local-link target with catalog:/workspace: specifiers preserved. --target prod writes dist/prod/npm/pkg with those specifiers resolved to concrete ranges, ready to publish — and emits the API Extractor api-model alongside it. A third target, --target exe, compiles SEA binaries and is covered below.
Every build emits per-module JavaScript alongside a single rolled-up, self-contained .d.ts per public entry. Each entry's declaration file pulls in every re-exported type, so a consumer that infers a type from your public API never has to reach into a deep sibling module that no export subpath addresses.
TypeScript config
The bundler ships its shared TypeScript base as a subpath export. Extend it from your package's tsconfig.json so source and declaration emit line up with what the bundler expects:
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@savvy-web/bundler/tsconfig/ecma.json"
}ecma.json sets ESNext libs, NodeNext resolution, strict mode and composite declaration output. Override any of it in your own tsconfig.json.
Multi-target publishing
By default --target prod builds a single group named after the package and writes it to dist/prod/npm/pkg. To publish the same package to more than one registry, or under more than one name, declare a publishConfig.targets map in package.json:
{
"publishConfig": {
"targets": {
"npm": true,
"github": "@scope/internal-name",
"mirror": { "registry": "https://registry.example.com", "from": "npm" }
}
}
}Each key is a target. true publishes under the package's own name to a well-known registry (npm, github); a string renames the group for that target; an object form takes { registry } plus either name (a rename) or from (reuse another target's built bytes). --target prod then builds one byte-variant group per distinct name, applies the rename to each group's manifest and writes dist/prod/<group>/pkg. It also writes dist/prod/targets.json, the group-to-registry binding the release step consumes to know what to publish where.
With no targets map the build falls back to the single-npm group above.
API Extractor meta
savvy build --target prod generates an API Extractor api-model from each prod group's resolved .d.ts. For every group it writes the bundle (<unscoped>.api.json, tsdoc-metadata.json and a resolved tsconfig.json) into dist/prod/<group>/meta as a release asset alongside pkg/, and copies the canonical group's bundle into any localPaths directories. Because it reads the prod build, the meta package.json carries concrete dependency versions rather than catalog:/workspace: specifiers.
API Extractor's analyzer messages — forgotten exports, missing release tags and TSDoc issues — surface in the build log rather than being dropped. A forgotten export (a type reachable from your public API but not itself exported) fails the build under CI (CI or GITHUB_ACTIONS set); locally it stays a warning so an incremental build is not blocked. Listing the message in tsdoc.suppressWarnings suppresses both the local warning and the CI failure, and the build log accounts for what it suppressed.
The meta field on defineBuild is tri-state:
- Omitted (or
undefined) — generation runs with default options. This is the default; you do not need ametafield for--target prodto emit the api-model. - An object — override the defaults:
localPaths(directories the canonical api-model is copied into),tsdoc(warning suppression and custom tags) andoptimistic(see below). false— opt out entirely; the prod meta asset becomes a no-op.
const config = defineBuild({
format: ["esm"],
meta: {
// directories the generated api-model is copied into
localPaths: ["../mcp/models/@savvy-web/bundler"],
tsdoc: {
suppressWarnings: [{ messageId: "ae-undocumented" }],
tagDefinitions: [{ tagName: "@internal", syntaxKind: "modifier" }],
},
},
});
// Or opt out of api-model generation altogether:
// const config = defineBuild({ meta: false });optimistic ("auto", the default, or a boolean) forward-looks the meta bundle's own version and its workspace-sibling dependency versions to their next release version from pending changesets. "auto" is off under CI (CI or GITHUB_ACTIONS set) and on locally, so a locally generated bundle matches what the CI release build would emit. Set it to true or false to pin the behavior:
const config = defineBuild({
meta: { optimistic: false }, // always use the current package.json versions
});--target meta is deprecated: it warns and no-ops. Generate the api-model with --target prod instead.
Executable binaries
Set the optional exe field to compile a single-executable application (SEA) from a bin entry, via @tsdown/exe:
const config = defineBuild({
format: ["esm"],
exe: {
fileName: "savvy",
entry: "./src/bin.ts",
// targets default to the package's own os/cpu when omitted
targets: [{ platform: "linux", arch: "x64" }],
},
});savvy build --target exe compiles each declared binary into dist/dev/pkg/bin. Pass an array to exe to compile several. When targets is omitted the platform is inferred from the package's os/cpu fields. --target exe errors if the config has no exe field.
JSX
Packages that emit JSX inherit their transform from tsconfig.json (compilerOptions.jsx/jsxImportSource) with no extra config. Set the optional jsx field on defineBuild to override it:
const config = defineBuild({
format: ["esm"],
jsx: { runtime: "automatic", importSource: "preact" },
});The resolved JSX settings are written into the generated tsconfig, which both the JS and declaration passes read, so the transform and the .d.ts emit share one runtime. Omit jsx to inherit the tsconfig value.
Dual-format output
Builds are esm-only by default. Set the format field to add a CommonJS output alongside the ESM one:
const config = defineBuild({
format: ["esm", "cjs"],
});A dual-format build emits an ESM .js and a require-able CJS .cjs plus matching .d.ts and .d.cts declarations, and writes a manifest carrying both import and require export conditions. The CJS output uses default-export interop — module.exports is the module's default export, so a require() of the package yields that value directly. Omit format, or pass ["esm"], for an ESM-only build.
Ambient type exports
Most declarations are generated from your source. For a types-only export backed by a hand-authored declaration file — global augmentations, module shims, ambient declare blocks — point the exports entry straight at a .d.ts, either as a bare string or under a types key:
{
"exports": {
"./globals": "./src/globals.d.ts",
"./env": { "types": "./src/env.d.ts" }
}
}The build copies each declaration file verbatim into every built target dir and rewrites its published manifest pointer to a key-derived { types: "./<name>.d.ts" }, preserving the source's .d.ts/.d.cts/.d.mts extension. No transform and no post-build copy step are involved.
Two constraints keep the copy sound. The declaration must be self-contained — a relative import or export inside it fails the build, since nothing pulls the referenced file into the output. And an export that pairs a hand-authored types with a runtime source (import/require/default) fails too: the bundler generates types from a runtime source, so the two cannot be mixed on one entry.
One further limit on scope: ambient declarations are supported only as named subpath exports (such as "./globals" or "./env"); a package must ship at least one JS or exe entry alongside its ambient declarations (a purely types-only package with no JS entries is not supported).
Bundling dependencies
Dependencies you declare in package.json are externalized automatically — they stay imported from the published .js and referenced from the .d.ts, and the consumer resolves them from their own node_modules. You don't list declared deps anywhere; externals exists only to externalize a package tsdown would otherwise bundle (a transitive dep you reference but don't declare). Four fields change the bundling posture, for the cases where a dependency cannot be left external:
const config = defineBuild({
// force-inline these specific packages into the .js, even ones declared in package.json
bundle: ["some-declared-dep"],
// force-bundle every non-externalized node_modules and workspace dep into the output
bundleNodeModules: true,
// inline only these packages' types into the bundled .d.ts; the rest stay external
bundledPackages: ["some-types-only-dep"],
// externalize these in the declaration pass only — referenced via import in the .d.ts,
// still bundled in the .js
dtsExternals: ["effect"],
});bundleinlines the listed packages into the JavaScript output, the inverse ofexternals, even for packages declared inpackage.jsonthat would otherwise be auto-externalized. Declarations are not inlined by this option — pair it withbundledPackagesto roll a package's types into the.d.tstoo.bundleNodeModulesinlines node_modules and workspace JavaScript into the output so the published package is self-contained, and inlines their types into the bundled.d.tsto match.bundledPackagesinlines only the listed packages' declarations into the.d.tswhile every other dependency stays external. Use it for a types-only dependency you don't want consumers to install.dtsExternalskeeps a package out of the declaration bundle when its types cannot be safely inlined — effect's cross-moduledeclare moduleaugmentations, for one, inline into conflicting interface extensions in consumers. The package is referenced byimportin the.d.tsand still bundled in the JavaScript, so declare it as a package dependency.
Per-entry overrides
The format and bundling fields above apply to every export entry. Use overrides to pin a subset of entries to their own format and bundling, layered onto the base config. The base build stays as configured; only the listed entries (by export subpath) get the override:
const config = defineBuild({
format: ["esm"], // base entries are ESM-only
overrides: [
{
// this one entry is also require-able, and inlines its node_modules so a
// CommonJS caller never has to resolve an ESM-only dependency
entries: ["./changesets/markdownlint"],
format: ["esm", "cjs"],
bundleNodeModules: true,
},
],
});Each override carries the same format, bundle, externals, bundleNodeModules, bundledPackages and dtsExternals fields as the base config, plus three partition-only fields: platform (the JS-pass target, "node" by default or "browser" for a client runtime), css (forwarded to tsdown's css option to enable @tsdown/css) and outSubdir (builds the partition into an isolated <group>/pkg/<subdir>/ sub-package, for which exactly one export path may be pinned). An override does not inherit the base externals — list what that partition needs. The build errors if an override names an export path the package does not declare. These partition fields are what @savvy-web/rspress-builder composes to build an RSPress plugin's browser runtime bundle.
Loose files
Every output above lands at a path the package's exports map addresses. Some files have to sit at a fixed name the runtime resolves by convention, outside that graph — a pnpm config dependency, for one, forbids runtime dependencies and resolves its pnpmfile.mjs/pnpmfile.cjs by filename at the package root. Use looseFiles to emit a standalone bundled file at a literal output path, with no exports entry, no declaration and no api-model:
const config = defineBuild({
bundleNodeModules: true,
looseFiles: {
"pnpmfile.mjs": "./src/pnpmfile.ts",
"pnpmfile.cjs": "./src/pnpmfile.ts",
},
});Each key is the literal output filename written into the package root; each value is a source path (a bare string) or a { source, format } object. The format is inferred from the key extension — .mjs is ESM, .cjs is CJS — so the example above bundles the one source into both an ESM and a CJS file from a single config. A .js key is format-ambiguous and needs an explicit format. Pair looseFiles with bundleNodeModules so each file is self-contained, since a config dependency cannot resolve runtime dependencies of its own.
Plugins
Pass your own tsdown/rolldown plugins with the plugins field. They are forwarded to every tsdown pass the build performs — the JavaScript pass, the bundled-declaration pass, the per-module declarations pass and each looseFiles pass — so a plugin's resolveId/load hooks are available everywhere your source is compiled. The use case is build-time codegen and virtual modules: a plugin can serve a module that exists only at build time, and any of your sources can import it.
import { defineBuild } from "@savvy-web/bundler";
import { PnpmConfigPlugin } from "pnpm-config-builder";
const config = defineBuild({
plugins: [PnpmConfigPlugin()],
bundleNodeModules: true,
looseFiles: {
"pnpmfile.mjs": "./src/pnpmfile.ts",
"pnpmfile.cjs": "./src/pnpmfile.ts",
},
});Because the plugins run on the looseFiles pass too, a pnpmfile source can import a virtual module the plugin resolves — here a config-dependency plugin serves the module the pnpmfile needs, and the pnpmfile lands at the package root as a self-contained bundle. Plugins are a rolldown concept; the supplied plugin objects are passed through to tsdown untouched.
Minified output
Prod output is not minified by default. This builder targets Node libraries, where readable output matters more than bundle size — minified code degrades stack traces and trips some security scanners. Set minify to opt back in:
const config = defineBuild({
minify: true, // applies to prod target groups only; dev is never minified
});Manifest transform
Every build runs a manifest transform after resolving publishConfig.targets, to produce the published package.json. By default it strips build- and dev-only fields (devDependencies, scripts, publishConfig and the like). Supplying your own transform replaces that default — import defaultManifestTransform and call it from yours if you still want the stripping:
import { defaultManifestTransform, defineBuild } from "@savvy-web/bundler";
const config = defineBuild({
transform: ({ pkg }) => {
// custom manifest work here
return defaultManifestTransform({ pkg });
},
});Build-time constants
The build injects process.env.__PACKAGE_VERSION__ as a compile-time constant set to the package's version, so source can read its own version without importing package.json at runtime:
// somewhere in src/
const version = process.env.__PACKAGE_VERSION__;
// the reference is replaced at build time with the package's version as a string literalAdd your own compile-time replacements with the define field. Values are inserted verbatim, so string literals must be pre-quoted:
const config = defineBuild({
define: {
"process.env.FLAG": JSON.stringify("on"),
},
});define merges with the auto-injected version constant; a key of process.env.__PACKAGE_VERSION__ in your own define wins.
Features
- One self-executing config —
savvy.build.tsis a top-levelawait build({...})call that derivescwdandargvfrom process globals. No main guard, noexport default, no factory-notation config file. - Build targets —
devfor local linking,prodfor a resolved publishable manifest (which also emits an API Extractor api-model) andexefor SEA binaries, on disjointdist/devanddist/prodoutput paths for clean caching. - Bundled declarations — per-module JavaScript with a single rolled-up
.d.tsper public entry, so re-exported types stay reachable through your published export subpaths. - Ambient type exports — a types-only
exportsentry backed by a hand-authored.d.ts(a bare string or{ types }) is copied verbatim into every target dir with its manifest pointer rewritten to a key-derived path, no customtransformor post-build copy needed; the declaration must be self-contained and may not be mixed with a runtime source. - Shared tsconfig base — extend
@savvy-web/bundler/ecma.jsonfor the ESNext/NodeNext/strict settings the build expects. - Manifest resolution —
catalog:andworkspace:specifiers are resolved against the workspace for the published target, and preserved for the linked dev target. - Multi-target publishing — a
publishConfig.targetsmap publishes one package to several registries or under several names;--target prodbuilds the distinct byte variants and writes atargets.jsonbinding for the release step. - Executable binaries — an
execonfig compiles SEA binaries from a bin entry via@tsdown/exe, inferring the platform from the package'sos/cpuwhen targets are omitted. - JSX, config-first — JSX transform is inherited from
tsconfig.jsonand overridable via thejsxfield, feeding both the dts tsconfig and the tsdown transform. - Dual-format output — esm-only by default; set
formatto["esm", "cjs"]for a require-able CJS output with default-export interop,.d.ctsdeclarations and dualimport/requireexport conditions. - Dependency bundling — declared dependencies stay external by default;
bundle,bundleNodeModules,bundledPackagesanddtsExternalsforce-inline specific packages or all node_modules into the output, inline select declarations into the.d.tsor hold a package out of the declaration bundle when its types cannot be inlined. - Per-entry overrides —
overridespins a subset of export entries to their own format and bundling, so one entry can ship dual-format CJS in an otherwise ESM-only package without changing the rest; partition-onlyplatform,cssandoutSubdirfields also let an entry build for the browser with CSS modules into its own sub-package. - Loose files —
looseFilesemits standalone bundled files at literal output paths outside the exports/declaration/api-model graph, with the format inferred from the key extension; pair withbundleNodeModulesfor self-contained pnpm config-dependency pnpmfiles. - Custom plugins —
pluginsforwards your own rolldown plugins to every tsdown pass, including thelooseFilespass, so build-time codegen and virtual modules resolve everywhere your source is compiled. - Readable prod output — prod output is unminified by default to keep stack traces legible and pass security scanners;
minifyopts back in. - Default manifest stripping — the published
package.jsondrops build- and dev-only fields automatically; a customtransformreplaces the default and can re-apply it viadefaultManifestTransform. - Build-time constants — the package version is injected as
process.env.__PACKAGE_VERSION__, and thedefinefield adds your own verbatim compile-time replacements. - Fast-fail config validation —
runBuildvalidates the config (publishConfig.targets,exe,meta) before any build work, raising a typedConfigValidationErroron the first violation. - One devDependency —
tsdownis a regular dependency, pinned and tested transitively, so you never carry it or its plugin peers in your own tree. - Injectable orchestration —
runBuildtakes its IO dependencies as options, so the build is testable without spawning a real bundle. - Escape hatch — every build behavior lives in
@savvy-web/tsdown-plugins; compose the same helpers in a hand-writtentsdown.config.tswhen you outgrow the front door.
API
build(input?, overrides?)— the front door: callsdefineBuild(input)thenrunBuild, derivingcwdfrom the entry script's directory (process.argv[1]) andargvfromprocess.argv.slice(2). Passoverrides(anyRunOptionskey) as the test IO seam.defineBuild(input)— normalizes a build config (externals,bundle,bundleNodeModules,bundledPackages,dtsExternals,minify,devManifest,transform,output,meta,jsx,exe,format,overrides,looseFiles,define,plugins), applying defaults. Theformatfield controls the output module formats forwarded to tsdown (esm-only by default; add"cjs"for a dual-format esm+cjs build).minifydefaults to false,transformdefaults to a manifest stripper, andoverridespins a subset of entries to their own format and bundling. Pure; it does not run the build.runBuild(config, options)— the orchestrator. Parses--target/--watch/--verbosefromoptions.argv, readspackage.jsonatoptions.cwd, derives entries, drives the build for the selected target and renders a report.--verboseexpands the report to a per-file table; the report is quiet by default. Every IO dependency onoptionsis injectable for tests.parseArgs(argv)— the argument parser behindrunBuild, exported for embedding.
Turbo tasks
pnpm turbo run build:prod produces the publishable output and the api-model bundle in one pass, writing the canonical group's api-model into the localPaths configured in each package's savvy.build.ts. The standalone build:meta task is deprecated — its --target meta now warns and no-ops.
