Workers target
brust builds the same app for Cloudflare Workers that it runs natively with brust start. The page renderer is the same Rust code, compiled to WebAssembly.
The only JavaScript that runs is the shim worker-build generates and your own compiled TypeScript: loaders, jobs.js, actions.ts, client chunks. brust adds no runtime layer of its own, and the runtime needs only Workers. It ships no Durable Object, KV namespace or D1 database.
The target is Workers with Static Assets. Cloudflare Pages is not targeted: its landing page says “Start new projects with Workers” (source C1). Cross-instance cache invalidation has its own pages: Cache on KV and DO and Cache adapters.
Quick start
$ bun create @brust/app@latest my-app # pin or use @latest: `bun create` caches the scaffolder
$ cd my-app
$ bun install
$ bun run dev:cf # build + wrangler dev (local workerd, simulated bindings)
$ bun run deploy:cf # build + wrangler deploy of dist/cfbun run deploy:cf needs a Cloudflare login on your machine (wrangler login) or an API token in the environment. The scripts are:
"dev:cf": "brust dev routes.tsx --target cloudflare",
"deploy:cf": "brust build routes.tsx --target cloudflare && wrangler deploy -c dist/cf/wrangler.jsonc"You need Bun and wrangler. You do not need a Rust toolchain: the app-independent wasm ships as the npm package @brust/core-cf, which installs next to @brust/core.
Commands
| Command | What it does |
|---|---|
brust build [entry] --target cloudflare [--out-dir dist] [--wasm-from-source] | The normal build into dist/, then the Workers project in dist/cf/. |
brust dev [entry] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>] | Builds, runs wrangler dev on dist/cf/wrangler.jsonc, and rebuilds and restarts it when a source file changes. There is no hot module replacement. Arguments after -- go to wrangler. |
--target takes bun (the default) or cloudflare. brust dev supports only --target cloudflare for now.
--wasm-from-source builds the wasm from a brust checkout instead of using @brust/core-cf. It needs the checkout’s pinned nightly Rust, the wasm32-unknown-unknown target and worker-build. App authors never need it.
[cloudflare] in brust.toml
The table is optional. Its presence is not required to use --target cloudflare; the defaults below apply. Unknown keys are a build error. [server] and [workers] are ignored on this target, with a warning.
[cloudflare]
name = "my-worker" # wrangler `name`
compatibility_date = "2026-10-01" # must be >= 2026-08-04
l1_capacity = 256 # page cache entries per isolate
job_cache_capacity = 512 # job cache entries per isolate
stats = false # serve /_brust/cache/stats
edge_cache = "off" # the only accepted value today
[cloudflare.alias]
"lib/team-db.ts" = "lib/team-db.d1.ts"| Key | Default | Meaning |
|---|---|---|
name | the app-root wrangler name, else package.json name made into a Worker name | The Worker name. If both this and the wrangler file name the Worker and they differ, this wins, with a warning. |
compatibility_date | the date @brust/core-cf was built with | Must be YYYY-MM-DD and at least 2026-08-04. From that date nodejs_compat is on by default, and brust needs it for AsyncLocalStorage (source C2). |
l1_capacity | 256 | Rendered pages kept per isolate (native default is 1000). Integer in 1..4294967295. |
job_cache_capacity | 512 | Job results kept per isolate. Same range. |
stats | false | Serve /_brust/cache/stats. When off, that path answers 404. The numbers are per isolate. |
edge_cache | "off" | Reserved. "workers-cache" is refused: the build says it lands later. |
alias | none | Module swaps, see below. |
[actions], [cors] and [cache] are read as on native. [build] server_only is honoured too.
[cloudflare.alias]
Some modules do not exist on Workers: bun:sqlite, Bun.file, anything under bun or bun:*. An alias replaces a module of your app, at bundle time, with another one. Both sides are paths relative to the app root, and both must resolve. Use it to swap a database facade: the pokedex example maps lib/team-db.ts to lib/team-db.d1.ts (see the D1 recipe).
What the build generates
dist/cf/
wrangler.jsonc merged config (wrangler.jsonc section below)
worker/ the @brust/core-cf glue + wasm, copied verbatim
brust-app.js ONE Bun bundle of your app
main.js only with a Durable Object cache adapter: re-exports the Worker and BrustCacheDO
assets/ Workers Static Assets
_brust/client/* copies of dist/client
public/* copies of dist/public
_headers cache headers
.stage/ generated entry, templates, smoke check (kept for inspection)brust-app.js is built from a short generated entry (about 25 lines, no logic). It imports your routes, jobs.js and actions.ts and exports loader, jobs, action, install, the manifest, the templates and a baked CONFIG. The wasm calls those exports directly. The bundle uses the workerd, worker and browser export conditions, so react-dom/server resolves to its edge build.
After bundling, the build imports brust-app.js under Bun with a stub for cloudflare:workers and checks the exports. This catches a broken bundle before wrangler does. The check runs your modules’ top level with env = {}, so open bindings lazily, inside a call, not at import time.
wrangler.jsonc
If your app root has a wrangler.jsonc, wrangler.json or wrangler.toml, brust merges it into the generated file. Keep only one of them. brust owns these keys and ignores yours with a warning: main, assets, compatibility_date, no_bundle, find_additional_modules, base_dir, rules, build, site. Everything else is yours: name, vars, d1_databases, kv_namespaces, durable_objects, routes, observability and so on, also under env.<name>.
brust writes:
{
"main": "worker/index.js",
"compatibility_date": "2026-10-01",
"assets": { "directory": "assets", "binding": "ASSETS", "run_worker_first": false,
"html_handling": "none", "not_found_handling": "none" }
}Refused with an error:
- a binding named
ASSETS(it is brust’s static assets binding); - a
compatibility_flagsentry starting withno_nodejs_compat.
brust never writes an account identifier. It keeps one only if you wrote it. migrations_dir of each d1_databases entry, and relative tsconfig and alias paths, are rewritten relative to dist/cf/, so wrangler d1 migrations apply finds your files when run with -c dist/cf/wrangler.jsonc.
Static assets and _headers
Files under dist/client and dist/public become assets at the URL they are requested at. Only files the native server would serve are copied; others are skipped with a warning. A request for an asset does not run the Worker. A miss under /_brust/ or /public/ reaches brust, which answers 404.
_headers sets X-Content-Type-Options: nosniff and X-Powered-By on everything, immutable on /_brust/client/*, and per file on /public/*: immutable for names with a content hash, max-age=3600 otherwise. A _headers file holds at most 100 rules; past that brust writes a single /public/* rule with max-age=3600 and warns.
Bindings and cloudflare:workers
Declare bindings in your own wrangler.jsonc and read them from the env export:
import { env } from 'cloudflare:workers'
export function db() {
const d1 = env.TEAM_DB
if (!d1) throw new Error('TEAM_DB D1 binding missing: add it to wrangler.jsonc')
return d1
}Rules that follow from Workers (source C3):
- I/O (D1, KV,
fetch) works only inside a request. Open bindings inside the function that uses them. Never keep an I/O object across requests. - The build runs your module top level once, with an empty
env. A top-levelenv.DB.prepare(...)fails the build smoke check.
Actions on Workers
Actions run as on native: one Elysia 2 app, [actions] read from brust.toml. What differs:
- brust generates a startup
.compile()of your Elysia app. Workers alloweval()andnew Function(text)during the startup phase of a Worker script (source C4), and Elysia’s compiler needs them, so compiling at startup is the safe place. Do not rely on lazy compilation in your own code. - The admission rules still run in Rust: method gate, ambiguous path (400),
Content-Encoding(415), body cap (413) and the cross-site guard (403).cross_site = "reject"is the default and works the same way. cache.invalidate(...)from an action clears the cache of the isolate that ran it. Cache on KV and DO explains how to reach the others.
Differences from native
Native (brust start) | Workers | |
|---|---|---|
| Page cache scope | one process, all workers | one isolate; capacity 256 pages by default |
cache.invalidate | the whole process | the running isolate; others after ttl_seconds, unless a cache adapter broadcasts |
| Response compression | gzip in Rust | none in the Worker; Cloudflare compresses at the edge |
/_brust/cache/stats | always on | off (404) unless stats = true; per isolate |
| Static files | served by Rust | Workers Static Assets, with the headers from _headers; the Worker is not run for a hit |
| CORS on static assets | stamped by the server | not stamped: Static Assets answer without running brust. Only pages and actions get CORS headers |
| Plan offer (loader and jobs in one call) | on | off: the loader and jobs are two direct in-isolate calls, which cost microseconds |
[server], [workers] | used | ignored, with a warning |
Server-only modules (bun:*) | allowed | build error cf-unsupported; swap with [cloudflare.alias] |
Static site generation (--ssg) | not in v2 | not targeted |
Limits
Numbers below are Cloudflare’s, read on 2026-10-12. They change; check the source before you size anything.
| Limit | Free | Paid | Source |
|---|---|---|---|
| Worker size (uncompressed) | 64 MiB | 64 MiB | C5 |
| Startup (global scope) | 1 s | 1 s | C5 |
| CPU time per HTTP request | 10 ms | 30 s default, up to 5 min | C5 |
| Memory per isolate (JS heap and wasm) | 128 MB | 128 MB | C5 |
| Subrequests per invocation | 50 | 10,000 | C5 |
| Static Asset files per Worker version | 20,000 | 100,000 | C5 |
| One Static Asset file | 25 MiB | 25 MiB | C5 |
| D1 queries per invocation | 50 | 1,000 | C6 |
What this means for brust:
- Size. The brust wasm plus glue is about 2.65 MB raw (0.79 MB gzip) in the minimal test app, measured by the brust CI on 2026-10-12. A real app adds
react-dom, Elysia and its own code. - CPU. A cache hit costs microseconds. A miss runs the loader, the jobs and the template render. The Free plan’s 10 ms is tight for heavy pages; measure
wrangler devoutput on your own routes. - Memory. Both caches live in the 128 MB. Lower
l1_capacityif your pages are large. A body over 8 MiB is never cached. - Subrequests. brust makes none of its own. Your D1, KV and
fetchcalls count. - Startup. brust parses the manifest, compiles templates and compiles Elysia at startup. A large app should stay far from 1 s; wrangler’s deploy output reports
startup_time_ms.
Troubleshooting
cf-unsupported: file imports bun:sqlite
The module does not exist on Workers (also bun, bun:*). Replace it with a Workers-safe module and map it with [cloudflare.alias] (D1 recipe). A server_only entry keeps it out of browser bundles.
cf-smoke failure at build
The build imported your bundle with an empty env and an export was missing, or your top level threw. Move binding access into functions.
cf-config: more than one wrangler config
Keep one of wrangler.jsonc, wrangler.json, wrangler.toml.
cf-config: a binding named ASSETS
Rename yours. ASSETS belongs to brust’s static assets.
compatibility_date must be >= 2026-08-04
Raise [cloudflare] compatibility_date. brust needs nodejs_compat for AsyncLocalStorage.
@brust/core-cf is not installed
Run bun add -d @brust/core-cf@<same version as @brust/core>, or use --wasm-from-source in a brust checkout. A version mismatch with @brust/core prints a warning.
A binding is undefined
It is missing from your wrangler.jsonc, or it was read at import time. Check with wrangler dev and read bindings inside the request.
Error 10021 on deploy (startup too long)
The global scope took more than 1 s. Remove heavy work at module top level.
A page stays stale after a mutation
Expected without a cache adapter: other isolates serve the old page up to ttl_seconds. See Cache on KV and DO.
This page follows docs/cloudflare.md in the brust repository, the reviewed source. If the two ever disagree, the markdown wins.
Sources
All fetched 2026-10-12. Every figure and quotation above was re-read from these pages on that date.