Bundling¶
ts_binary and ts_bundle collect transitive .js outputs and hand them to a
pluggable bundler. ts_bundle requires a bundler and has no bundler-less mode.
Vite is the only bundler this ruleset ships; another one plugs in through
BundlerInfo.
A Cloudflare Worker is bundled by wrangler. See ts_worker_dry_run for building one and checking that it still deploys, ts_worker_deploy for uploading it, and Testing § Cloudflare Workers for running its tests inside workerd.
With Vite¶
Declare the three targets at the workspace root. See where the bundler's tree has to sit.
# BUILD.bazel, at the workspace root
load("@rules_typescript//vite:bundler.bzl", "vite_bundler")
load("@rules_typescript//npm:defs.bzl", "node_modules")
load("@rules_typescript//ts:defs.bzl", "ts_bundle")
node_modules(
name = "node_modules",
deps = [
"@npm//:vite",
"@npm//:zod",
],
)
vite_bundler(
name = "vite",
vite = "@npm//:vite",
node_modules = ":node_modules",
)
ts_bundle(
name = "app",
entry_point = "//src/app",
bundler = ":vite",
format = "esm",
sourcemap = True,
minify = True,
external = ["react", "react-dom"],
)
Where the bundler's node_modules has to sit¶
That tree supplies Vite and every npm package the bundled graph imports; a
ts_compile dep does not carry its own npm packages into it. A missing package
fails the build, naming the file that imported it:
Error: [vite]: Rolldown failed to resolve import "zod" from
"…/bazel-out/k8-fastbuild/bin/src/lib/math.js".
The tree is materialised at the target's own name under its package in
bazel-bin, and rolldown resolves a bare specifier by Node's
walk-up from the importer, so the tree has to sit in an ancestor directory of
every compiled .js that imports one. A tree in //src/app does not serve an
import in //src/lib; one at the workspace root serves the whole repository.
external is the other way out, for a specifier you want left as an import for
whoever consumes the bundle.
Running Without a Bundler¶
ts_binary with no bundler runs the entry point's own .js on the JS runtime,
with the transitive .js in its runfiles. The imports resolve as written;
nothing is bundled or concatenated.
load("@rules_typescript//ts:defs.bzl", "ts_binary")
ts_binary(
name = "app",
entry_point = "//src/app",
)
Chunk Splitting¶
split_chunks = True gives third-party code its own chunk, so a first-party edit
leaves the vendor chunk's content hash untouched. Vite bundlers only; a bundler
invoked through the standard CLI interface ignores the attribute.
Both modes honour it. In lib mode the single-file output becomes a directory. In
app mode the output is a directory already, and Vite already splits on dynamic
imports; split_chunks adds the rule that everything under node_modules lands
in one chunk.
It is emitted as build.rollupOptions.output.manualChunks, which every Vite
generation from 6 onward honours. The vendor-splitting plugin it used to emit was
removed in Vite 7.
Lib mode derives the extension from the nearest package.json#type, which
nothing in a Bazel output tree declares, so the chunk can land as .mjs, and its
name carries a content hash. Locate it by exclusion: the entry is
<bundle_name>.es.js (or .cjs.js, .iife.js), the chunk is whatever else is
in the directory.
minify = True selects the running Vite's own default minifier (esbuild on 6,
oxc on 8) without naming one: esbuild and terser are optional peers, absent from
a tree built from deps = ["@npm//:vite"]. False also pins
output.minify: false, since build.minify: false alone still runs the
dead-code pass, which re-emits every chunk from its AST and drops a plugin's
renderChunk output.
CSS, CSS modules and assets¶
A stylesheet, a *.module.css and an imported asset need no bundle-level
configuration. They reach the bundler through the entry point's CssInfo,
CssModuleInfo and AssetInfo, the providers css_library, css_module and
asset_library populate. Each rule copies its files
into bazel-bin beside the compiled .js that imports them.
The two modes differ in what comes out:
- App mode hashes every imported stylesheet and asset
(
assets/index-C_rPVxYH.css,assets/big_logo-DWeKL6j3.svg) and rewrites the references in the emitted HTML. An asset under Vite's 4096-byteassetsInlineLimitis inlined as adata:URI and gets no filename at all. - Lib mode extracts all CSS into one
<bundle_name>.css, never referenced from the JS, so the library's consumer includes it. Bazel declares that file explicitly: Vite writes it either way, and an undeclared output goes out with the sandbox. The declared outputs are that stylesheet, the entry.jsand its map. An asset too large to inline is a loose file, undeclared, and goes out with the sandbox; app mode declares a directory and keeps it.
Static files (public_dir)¶
public_dir names the files that must keep the name they were given: a
robots.txt, a favicon referenced from an HTML tag, anything fetched by a URL
the build never sees. Vite copies them into the output directory verbatim: no
hash, no transform, no reference rewriting.
filegroup(
name = "public",
srcs = glob(["public/**"]),
)
ts_bundle(
name = "app",
entry_point = "//src/app",
bundler = ":vite",
mode = "app",
html = "index.html",
public_dir = ":public",
manifest = True,
)
They are staged into a directory of their own under bazel-bin first: Vite
copies a publicDir wholesale, and the source package in the sandbox also holds
the sources, the HTML and the compiled outputs. Anything imported from
TypeScript belongs in an asset_library, where it gets a content hash and a
cacheable URL.
publicDir is one directory, named by the declaration and not by whichever files
a glob matched. Three shapes fail at analysis time, each naming the file:
- a file outside the package of the
public_dirlabel; - a file directly in that package, where the package itself would become the URL
root; put the files in
public/and glob that; - files in more than one directory under the package; split them, or point
public_dirat a single directory.
The Manifest¶
manifest = True writes manifest.json into the output directory, mapping each
input to the hashed file it became, plus the CSS and assets each chunk pulled in.
It is for a server that renders HTML itself and has to emit script and link tags
for filenames it did not choose. Vite's own index.html needs none of it.
Both attrs are app mode only and fail at analysis time in lib mode, which declares its output filenames.
Framework plugins via vite_config¶
vite_config takes the config file, vite_config_srcs the local modules it
imports. Both are staged into bazel-bin, and the generated config loads the
staged copy and prepends its plugins to Bazel's. TanStack Start's plugin goes
through that hook, and Remix's when a client-only bundle is what you want. Two
frameworks do not fit through it: SvelteKit, which has
a rule of its own, and Solid Start, which has no
rule and no bundle target at all. See
framework detection.
// vite.plugins.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { myPlugin } from "./plugins/mine";
export default { plugins: [tanstackStart(), myPlugin()] };
ts_bundle(
name = "app",
entry_point = "//src/app",
bundler = ":vite",
mode = "app",
html = "index.html",
vite_config = "vite.plugins.ts",
vite_config_srcs = glob(["plugins/**/*.ts"]),
)
TypeScript is accepted, and so are the extensionless relative specifiers a
bundler-resolution config is written with: the generated config loads yours
through Vite's own loadConfigFromFile, the loader Vite runs on a root config. A
plain dynamic import() reads neither, and is the fallback only for a .mjs
config when Vite is not in the tree.
Three constraints:
vite_config_srcsstages each module at its path relative to the config's package, so a relative import resolves in the staged tree as it does in the source tree. Without it only the entry config is staged, and its relative imports fail naming the file. A file outside the config's own package is an error: it would stage above the staging root.- A bare npm specifier resolves through the Bazel tree, a relative one through
the staged tree. The staged copy sits beside the
node_modulesthe build produced, so a framework package resolves without a source-treenode_modules. - The config supplies plugins and runs in the output tree. Anything it computes
from its surroundings sees
bazel-bin: an env var it expects the dev server to have set, a file it reads from the repository.
Keys the Generated Config Reads¶
The generated config reads a fixed set of keys out of yours, so a config that computes the build is not expressible:
| Rule | Keys it reads |
|---|---|
ts_bundle |
plugins, root |
ts_dev_server |
plugins |
Any other key makes the load throw, naming the keys it found and the keys it honours:
[rules_typescript] ts_bundle: the vite_config sets define, resolve, which the generated config does not read. Only plugins, root reach the build; the rest would be
silently discarded. Move what you need into a plugin, or open an
issue for the option.
define, resolve.alias, build.target and optimizeDeps are the options a
real framework config sets; the build stops on them. Where an attribute owns the
option, use it: define, env_vars, external, minify, split_chunks,
public_dir, manifest. The check runs where the config is loaded, not at
analysis time, because only the loaded object says which keys it has.
A config carrying root builds under ts_bundle and fails under
ts_dev_server, which takes its serve root from the target. Under oj, root is
in the provider's ignored_config_fields for a different reason: oj takes the
served directory from argv.
Custom Bundler (BundlerInfo Interface)¶
Any Bazel rule that returns BundlerInfo can plug into ts_bundle and
ts_binary. This lets you bring your own bundler — esbuild, Rolldown, webpack —
without modifying rules_typescript.
load("@rules_typescript//ts:defs.bzl", "BundlerInfo")
def _my_bundler_impl(ctx):
return [BundlerInfo(
bundler_binary = ctx.file.binary,
config_file = None, # optional static config
runtime_deps = depset([]), # files needed at bundle time
use_generated_config = False, # set True for Vite-style config
)]
my_bundler = rule(
implementation = _my_bundler_impl,
attrs = {
"binary": attr.label(
allow_single_file = True,
executable = True,
cfg = "exec",
),
},
)
BundlerInfo Invocation Modes¶
Mode 1 — Standard CLI (use_generated_config = False, the default)
ts_bundle invokes the bundler binary with:
<bundler_binary>
--entry <path/to/entry.js>
--out-dir <output/dir>
--format esm|cjs|iife
[--external <pkg>]...
[--sourcemap]
[--define <key>=<value>]...
[--config <config_file>] (only when config_file is set)
Output is expected at <out-dir>/<bundle_name>.js (and .js.map if --sourcemap).
public_dir and manifest are Vite options and fail at analysis time on a
target whose bundler is invoked this way.
Mode 2 — Generated config (use_generated_config = True)
ts_bundle generates a vite.config.mjs containing all bundle options and
invokes the binary with six positional arguments, all execroot-relative. The
trailing three are passed as empty strings when absent, so the positions never
shift:
<bundler_binary> \
<generated vite.config.mjs> \
<entry .js> \
<output dir> \
<html file> (app mode only; "" otherwise) \
<staging manifest> ("" when there are no staging_srcs) \
<lib-mode stylesheet> ("" in app mode)
Lib mode declares the output by name inside <name>_bundle/, following Vite's
own lib convention:
| Format | Output file |
|---|---|
esm |
<bundle_name>.es.js |
cjs |
<bundle_name>.cjs.js |
iife |
<bundle_name>.iife.js |
App mode, and lib mode with split_chunks = True, declare that directory
itself, because the hashed filenames are not known at analysis time.
oj is a dev server here and not a bundler.
Its binary has a build subcommand, and the oj revision this module pins gives that
subcommand the --config flag oj dev already had. What is still missing is a
rule: nothing in the ruleset returns BundlerInfo for oj, and oj build's CLI
matches neither invocation mode, since it takes its root positionally and names
its output with --out.
BundlerInfo Fields¶
| Field | Type | Description |
|---|---|---|
bundler_binary |
File |
The executable that performs bundling |
config_file |
File or None |
Optional static config passed via --config (mode 1 only) |
runtime_deps |
depset of File |
Files the bundler needs at runtime |
use_generated_config |
bool |
When True, use mode 2 (generated vite.config.mjs) |
Attributes¶
The two attribute sets differ. Shared:
| Attribute | Type | Default | Description |
|---|---|---|---|
entry_point |
label |
required | ts_compile target providing JsInfo |
bundler |
label |
required for ts_bundle, optional for ts_binary |
Target providing BundlerInfo |
bundle_name |
string |
rule name | Output file name (without .js) |
format |
string |
"esm" |
Output format: esm, cjs, iife |
sourcemap |
bool |
True |
Emit source map |
external |
string_list |
[] |
Module specifiers to leave external |
define |
string_dict |
{} |
Global constant replacements |
ts_bundle only: minify, split_chunks, env_vars, mode, html,
public_dir, manifest, vite_config, vite_config_srcs, staging_srcs. See
the ts_bundle reference.
ts_binary only: entry_file (which .js is the entry when the target emits
several) and node_modules. See the
ts_binary reference.
Setting minify on a ts_binary fails the build; it is not a silent no-op.