brust 2.0.0-alpha.5
Cloudflare / Workers target

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/cf

bun run deploy:cf needs a Cloudflare login on your machine (wrangler login) or an API token in the environment. The scripts are:

package.json
"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

CommandWhat 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.

brust.toml
[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"
KeyDefaultMeaning
namethe app-root wrangler name, else package.json name made into a Worker nameThe Worker name. If both this and the wrangler file name the Worker and they differ, this wins, with a warning.
compatibility_datethe date @brust/core-cf was built withMust 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_capacity256Rendered pages kept per isolate (native default is 1000). Integer in 1..4294967295.
job_cache_capacity512Job results kept per isolate. Same range.
statsfalseServe /_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.
aliasnoneModule 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
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:

dist/cf/wrangler.jsonc
{
  "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_flags entry starting with no_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:

lib/team-db.ts
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-level env.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 allow eval() and new 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 scopeone process, all workersone isolate; capacity 256 pages by default
cache.invalidatethe whole processthe running isolate; others after ttl_seconds, unless a cache adapter broadcasts
Response compressiongzip in Rustnone in the Worker; Cloudflare compresses at the edge
/_brust/cache/statsalways onoff (404) unless stats = true; per isolate
Static filesserved by RustWorkers Static Assets, with the headers from _headers; the Worker is not run for a hit
CORS on static assetsstamped by the servernot stamped: Static Assets answer without running brust. Only pages and actions get CORS headers
Plan offer (loader and jobs in one call)onoff: the loader and jobs are two direct in-isolate calls, which cost microseconds
[server], [workers]usedignored, with a warning
Server-only modules (bun:*)allowedbuild error cf-unsupported; swap with [cloudflare.alias]
Static site generation (--ssg)not in v2not targeted

Limits

Numbers below are Cloudflare’s, read on 2026-10-12. They change; check the source before you size anything.

LimitFreePaidSource
Worker size (uncompressed)64 MiB64 MiBC5
Startup (global scope)1 s1 sC5
CPU time per HTTP request10 ms30 s default, up to 5 minC5
Memory per isolate (JS heap and wasm)128 MB128 MBC5
Subrequests per invocation5010,000C5
Static Asset files per Worker version20,000100,000C5
One Static Asset file25 MiB25 MiBC5
D1 queries per invocation501,000C6

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 dev output on your own routes.
  • Memory. Both caches live in the 128 MB. Lower l1_capacity if your pages are large. A body over 8 MiB is never cached.
  • Subrequests. brust makes none of its own. Your D1, KV and fetch calls 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.

Note

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.