Runtime components
A runtime component is a TSX source your app stores outside its route tree, for example in a database, and creates or changes long after brust build. One running build compiles it and renders it as a real brust component, static or native, inside your pages. No deploy, and every replica serves the same result.
Three parts fit together: you expose some of your own modules, you compile a source into an artifact, and a page places the artifact with a slot.
Expose your modules
A runtime component may share code with the app only through modules you list in [build] expose. Each one is built once, as a client file with a stable URL, so a built component and a runtime component that import it get the same module instance in the browser. That is how both read and write one store.
# brust.toml
[build]
expose = ["shop/sdk.ts"]// shop/sdk.ts: an app module, built with the app and exposed to runtime components
import { defineStore, signal } from '@brust/core/store'
export const cart = defineStore('cart', () => ({ count: signal(0) }))- The paths are app-root-relative and inside the root or a
source_rootsentry. A missing file or a path outside the roots is a build error. - Every
defineStore('name', …)an exposed module exports is listed in the manifest, so a runtime component may read it. The store forms recognised are a direct export, a default export, a renamed export,export … from,export *and import-then-export, all with relative specifiers and a literal store name. A store re-exported through an alias or a package specifier is not listed, and a runtime component that reads it is refused (runtime-store). - Changing
exposechanges the build id, so artifacts compiled for the old build are no longer valid (see Limits and caching).
When an exposed module makes another component heavier
By default the exposed files and your built components are one bundle, so a module that both a component and an exposed file import (a large dependency, say) is hoisted into a shared chunk. The component’s own chunk then shrinks to a small stub and every page that loads it also loads that big shared chunk. If exposing one module did that to a component that has nothing to do with runtime components, set expose_isolated:
# brust.toml
[build]
expose = ["shop/sdk.ts"]
expose_isolated = true # build the exposed files apart; a component keeps its own copy of what it shares with them- The exposed files are built first, on their own, and a built component imports each by its URL. The component keeps its own copy of whatever else it shares with them, and no new shared chunk appears. Each exposed module is still one instance, for the built component and for runtime components alike.
- The price is a copy. A module or package that both sides import now runs once on each side, so it is in the page twice. A module that holds state (a store, a cache, a singleton) must therefore be exposed, or be reached only through an exposed module; otherwise the two sides each have their own state. A store defined in an exposed module is not affected. The build prints one warning that names every module it bundled on both sides (a package by its name), so you can check each against this rule.
- It is off by default and changes the build id like any change to the built files. Set without
exposeit does nothing, and the build warns.
"runtime": {
"exposed": { "shop/sdk.ts": "client/x-sdk-<hex>.js" },
"stores": { "cart": "shop/sdk.ts" }
}Compile a source
compileComponent from @brust/core/compile compiles source text exactly as the build would. It reads dist/manifest.json and brust.toml (source roots, server-only modules, tsconfig aliases) and writes nothing under the app root. It runs in Bun and is refused in a browser bundle.
compileComponent({ source, filename, appRoot?, dist? }).filenameis a virtual app-root-relative path such asel/Promo.tsx; it decides how the source’s relative imports resolve.appRootdefaults to the working directory anddistto<appRoot>/dist.- The result is
{ ok: true, artifact, diagnostics }or{ ok: false, diagnostics }. A diagnostic carriesclass,rule,message,line,colandremediation. - The artifact id is content-addressed: it changes when the source text changes and is equal on every machine for an equal (filename, source). Two filenames with the same text give different ids, because the filename decides how imports resolve.
- The source may import
react,@brust/core/store/reactand your exposed modules. Type-only imports are erased by the parser and are not checked.
Place it with Dynamic
<Dynamic component={ref} props={…} /> from @brust/core is a slot. The server decides on every render which ref goes there; the artifact’s root is the output, with no wrapper element. It works inside a server list row.
// List.tsx: a built component with one slot per row
import { Dynamic } from '@brust/core'
export default function List({ items }: { items: { ref: string; label: string }[] }) {
return (
<ul>
{items.map((i) => (
<li key={i.ref}>
<Dynamic component={i.ref} props={{ label: i.label }} />
</li>
))}
</ul>
)
}componentandpropsmust be server expressions that do not depend on state, a store or live props (dynamic-expr,dynamic-reactive). Onlycomponent,propsandkeyare accepted (dynamic-props).- A slot cannot sit in a row the browser builds, such as the list of a stateful component (
dynamic-in-client-row), or in a React island (dynamic-in-react). CallingDynamicas a function throws. - The component receives
propsas its props (JSON values). Arefmust match[A-Za-z0-9_-]{1,128}; anything else renders nothing. brust build --target static, the Cloudflare target and--ssgrefuse an app that has a slot (dynamic-target): a slot is filled by the running native server, and a prerendered page would freeze it.
The resolver
The routes entry exports components. Its resolve(ref, { build }) returns the artifact for a ref, null when there is none, or a promise of either. A build that has a slot and no components export does not start.
// routes.tsx
import { type RuntimeComponents } from '@brust/core'
import { compileComponent } from '@brust/core/compile'
export const components: RuntimeComponents = {
async resolve(ref, { build }) {
const source = await db.componentSource(ref) // text, stored after the build
if (source === null) return null // no such ref: the slot prints nothing
const cached = await db.artifact(ref, build) // keyed by (ref, build)
if (cached) return cached
const r = await compileComponent({ source, filename: 'el/Promo.tsx' })
if (!r.ok) return null // r.diagnostics says why
await db.saveArtifact(ref, build, r.artifact)
return r.artifact
},
}- A page renders once when every ref in it is warm. A cold ref is resolved (all cold refs of one render in one call to a worker), then the page renders a second time. Nothing else runs twice: the loader is not run again.
- Missing:
resolvereturnednull. The slot prints nothing, the answer is remembered for 10 seconds, and the page is cached for at most that long, so a row that names a ref before its artifact exists heals. - Refused or failed: the artifact failed a registration check, or
resolvethrew. The slot prints nothing and one line is logged ([brust] runtime component <ref>: <why>). The page is still answered with 200, but it is not stored in the page cache. - An error while the artifact’s template renders also prints nothing for that slot, and never turns the page into a 500.
- The scripts of every native artifact that rendered are linked once per page at
/_brust/rc/<build>/<ref>/<id>.js, served with an immutable cache header only when the id matches the registered artifact. A page that is otherwise all static gets the client runtime when a native artifact rendered. - Warm artifacts are kept per server, up to
[components] cache_entries(default 2048). Each entry holds up to 512 KiB.
The artifact
The artifact is JSON. Your app stores it and returns it as it is; do not edit it.
interface RuntimeArtifact {
v: 1
build: string // assets.build of the build it was compiled for
id: string // the x-data / defineBehavior id: a function of (filename, source text)
tier: 'static' | 'native'
jinja: string // the server template
client: string | null // one self-contained ES module; null for a static component
features: string[] // the client runtime modules it needs
stores: string[] // the store names it reads
}It is valid for one build only, because the client chunk imports the build’s hashed runtime and exposed-module URLs. Key your store by (ref, build). After a deploy, resolve gets the new build id and compiles again; an artifact for another build is refused.
What is refused
These are errors with ok: false. Each names a position and a fix.
| Rule | When |
|---|---|
runtime-tier | The component needs React (an island). The message carries the compiler’s reason. |
runtime-jobs | Some expression is left to run on the server per render (a runtime job). Only a pure component fits one template. |
runtime-import | An import that is not an exposed module, react or @brust/core/store/react. A private copy of an app module would split its state. An import with an import attribute (with { type: "json" }) is refused too: an attribute changes how the module is loaded. |
runtime-macro | A macro import (with { type: "macro" }). A Bun macro runs its code on the server when the source is parsed, so it is refused before anything else looks at the source: compiling never runs code of a runtime component. |
runtime-store | It reads a store no exposed module defines. |
runtime-use-id | It calls useId. |
runtime-children | It takes children fed per instance. |
runtime-dynamic | It holds a <Dynamic>: slots do not nest. |
runtime-size | The template or the client module is over 256 KiB. |
Facts for a check
The result of compileComponent also carries facts: what the source file refers to, read from the same parse and symbol table the compiler uses, over the whole file (render, handlers, effects and module-level helpers). They are present whenever the text parses, on an ok: false result too. Use them as the input of your own allowlist.
const r = await compileComponent({ source, filename: 'el/Promo.tsx' })
const f = r.facts // present when the text parses, on { ok: false } too
if (f && (f.risky.length > 0 || f.rawHtml || f.globals.some((g) => !ALLOWED_GLOBALS.has(g)))) {
return null // your policy: refuse what you do not want to run
}
// Handlers: allow-list what they write and call, and refuse the rest (incl. what the walk could not trace).
const writesOk = (w) => w.target === 'local'
const callsOk = (c) => c.kind === 'setter' || c.kind === 'local'
if (f?.handlers.some((h) => !h.writes.every(writesOk) || !h.callees.every(callsOk))) return null| Field | Holds |
|---|---|
imports | Each runtime import as { specifier, names }. Type-only imports are erased by the parser and never appear. |
globals | Every unbound identifier the file reads, writes or calls (fetch, document, window), sorted. |
hooks | Calls named useX as { name, from }; from is the import specifier, or null for a local or global one. |
tags, attrs | The JSX tags and every prop name written on an element, including dangerouslySetInnerHTML, ref and style. |
spreads, rawHtml | Whether a JSX spread attribute appears, and whether dangerouslySetInnerHTML is written. |
handlers | Each onX prop with its event, tag and position, and what its function does, block bodies, nested callbacks and every local function it reaches (by call or by naming it) included:callees (a callee’s kind is import, local, setter, prop, prop-member, global, global-member, param-member, member or unknown; a handler that is not a function written in the file, such as onClick={props.go}, is itself a callee), writes (every assignment, ++ and delete, each with a target and the writtenpath), readsParams (it looks at the event), globals and reaches. |
functions | Every local function some handler reaches, with the same facts for its own body, so the rule applied to a handler can be applied to each function behind it. |
risky | Constructs that can reach a name the walk cannot see: dynamic-import, computed-member, constructor-access and eval, with positions. |
A writes entry’s target says what the written place is rooted at: local (a variable the handler declares, or a property of an object literal it made, one level deep), param (e = x),param-member (e.currentTarget.innerHTML = x, also through a const alias of the event), prop-member (through a parameter of an enclosing function, such as props.obj.x = 1), closure and closure-member (a variable of the component or module, or a property of one), global(location = x), global-member (document.title = x) and other for anything the walk cannot trace (a call result, this, a loop variable, a name assigned from several sources). A target that is not recognised is other, never local, and a callee it cannot name is unknown, never omitted. A callee of kind prop or prop-member is not local: it came in from outside the component (its props, or a helper’s argument), and the file does not say what it is, even when the same file passes a global into it. It is claimed only for a parameter that is never reassigned, never handed to a call or spread into an element, and none of whose members (or its aliases’ members) is written anywhere in the file; otherwise the callee is unknown or member.Allow-list: write targets local and callee kinds setter and local, and refuse the rest; add prop only on purpose, for a platform that trusts the props its host passes. Effects (useEffect), callback refs and module-level code are named by hooks, attrs and globals; their writes are not listed.
Facts are a report, not a sandbox. They name what the source refers to. They are an input to an allowlist, not a proof of isolation: a computed member such as window[k], eval or a dynamic import() can reach a name the walk never sees (which is why they are listed in risky), and what an imported function does is outside the file. Pair them with a Content-Security-Policy. There is no built-in allow or deny profile; the runtime-import refusal already stops foreign imports.
React islands from your build
A runtime component compiles static or native, so a component that needs React (a hook brust does not lower) is refused in it. To place one anyway, list it in [build] islands: the export of a module that is also in [build] expose.
[build]
expose = ["shop/sdk.tsx"]
islands = ["shop/sdk.tsx#Motion"] # path#Export: a React component of an exposed module- The build compiles each listed component as a React island even when no route renders it: it gets its chunk and a manifest entry, and no server job. A listed entry that does not exist or is not a React component fails the build.
- In a runtime component it is client-only:
compileComponentprints its host with the props you pass and nothing inside, lists its id in the artifact'sislands, and the page loads the build's chunk for it (once, however many slots place it). The browser mounts it from its props; it is not server-rendered. Props must be values the template can print as JSON, as for any island. - A route of the app that renders the same component still server-renders it; the same chunk hydrates there and mounts here.
- An artifact that names an island the build does not list is refused at registration. A React component that is not listed is refused by
compileComponent, as before.
Islands you render yourself
An app that renders a component on its own (its own server rendering, its own client code) returns an island artifact: tier: 'island', an id, and a client module, with no jinja. The slot prints the same host a built React island gets, <brust-island data-id="<id>" x-props='<the props>'>, around the HTML your components.render(ref, props, ctx) returns for that slot's props. The island runtime then hydrates each host once when the browser is idle, calls your hydrate function with (host, props, committed), sets data-hydrated and, when you call committed(),data-committed, and calls the function it returned when the host leaves the page.
// The app renders this component itself: `render` returns its HTML for the slot's props, `client` hydrates it.
const CLIENT = `;(globalThis.__brustIslands ||= []).push(['counter_3f9a', (host, props, committed) => {
const b = host.querySelector('button')
let n = 0
const on = () => { b.textContent = props.label + ' ' + ++n }
b.addEventListener('click', on)
committed() // the host gets data-committed and fires brust:island
return () => b.removeEventListener('click', on) // called when the host leaves the page
}])
globalThis.__brustIslandReady?.()`
export const components: RuntimeComponents = {
resolve(ref, { build }) {
if (ref !== 'counter') return null
// id: change it whenever CLIENT changes (a content hash); the chunk URL carries it and is cached for good
return { v: 1, build, id: 'counter_3f9a', tier: 'island', client: CLIENT }
},
render(ref, props, { page }) {
// page.id is one page render, page.total the calls of this batch: a per-page budget if you need one
return `<button type="button">${escapeHtml(String(props.label))} 0</button>`
},
}- The HTML is trusted. What
renderreturns is placed inside the host as given, like a loader's raw HTML: escape what you put in it. The props are written intox-propsas data, escaped as for a built island. - One page render makes at most one extra worker call: the refs it has not resolved yet and the (ref, props) pairs whose HTML is not cached go together.
renderis called once per distinct (ref, props) of that page, in parallel, withctx = { build, page: { id, total } }:page.idis the same for every call of one page render,page.totalthe number of calls in the batch (cached pairs are not called). - The server caches the HTML per (ref, artifact, props), bounded by
[components] cache_entriesand 64 MiB in total; a new artifact for the ref starts afresh.null(or norender) prints an empty host your client mounts into, and the page is cached as usual. A throw, or more than 4 MiB of HTML, prints an empty host and the page is not cached. rendermust depend only on(ref, props). Do not read cookies, the user, the time or anything else of the request: the HTML cache and the page cache are shared by every visitor, so what one user's render returned is what the next user gets. Pass what varies as props, or render that part in the client.- All the HTML one page asks for comes back in that one worker reply, which is limited by
[server] max_loader_response_bytes(default 8 MiB, up to 64 MiB). A page whose batch is bigger loses every island of that batch: empty hosts, and the page is not cached. Raise the limit, or keep the per-page HTML small (page.totalhelps you budget it). - The client module is served at
/_brust/rc/<build>/<ref>/<id>.js, at most 256 KiB, cached for good: change the id whenever the client changes. A ref registered again with the same id and other code is refused, and so is one id with two different codes. - The module may import other same-origin modules by absolute path (
/app/shared.js, with your base path if you have one); brust neither bundles nor rewrites them, and one URL is one module in the browser, so every island on the page shares it. A cross-origin import is not refused by brust, but[csp](script-src 'self') blocks it. The chunk itself is same-origin and adds nothing inline. compileComponentnever produces an island artifact: you build it yourself. The same targets apply: a<Dynamic>slot renders on the brust server only.
Limits and caching
jinjaandclientare each at most 256 KiB. The server also checks at registration that the artifact is for this build, that every feature and store it names exists, and that its template parses. An artifact whose id belongs to a built component is refused.- The chunk URL carries the artifact id, which changes with the source. Browsers cache it for good, so give a changed source a new ref (or a ref that includes a version); the server keeps the artifact warm per ref.
- An artifact’s template renders in its own sandboxed template environment with a fuel limit: it cannot include or import an app template, and a runaway loop ends as a render error for that slot.
- SPA navigation works as on any page: the slot is part of the page HTML, and its scripts load with the page. A native artifact that reads a store seeds it like a built component does.
The trust model
An artifact is code: its template runs on your server and its client module runs in your visitors’ browsers, with the page’s own privileges. Return only what compileComponent produced from a source you accept.
- Never accept an artifact from a client or an untrusted store. Compile on your side, from the source text, and store the result next to the ref and the build.
- Decide who may write a source before you compile it. The compile API enforces the shape of a runtime component (no foreign imports, no React tier, no jobs); it is not a sandbox for what the source’s own handlers do in the browser. Pair untrusted authors with a Content-Security-Policy and a review step.
- The ref must change when the source changes, and artifacts are keyed per build. That keeps a stale artifact from rendering against a build whose runtime URLs it does not match.