Server Actions
bext server actions are plain exported functions in src/actions/
files marked with the "use server" directive. The PRISM dispatcher
auto-discovers them at startup and routes
POST /_bext/action/<exportName> to the matching function. There is
no client-side proxy generation, no closure capture, no hash-based ID;
the function name is the route.
The model is intentionally narrower than RSC's "use server": no
inline-action transforms, no React-only conventions. In exchange you
get a predictable wire format that works with plain HTML forms, with
fetch, and from any other language as a regular JSON-or-FormData
HTTP POST.
Quickstart
src/actions/subscribe.ts:
"use server";
export async function subscribe(form: FormData) {
const email = form.get("email");
if (typeof email !== "string" || !email.includes("@")) {
throw new Error("invalid email");
}
await db.subscribers.insert({ email });
return { ok: true };
}
Use it from a page:
<form action="/_bext/action/subscribe" method="POST">
<input name="email" type="email" required />
<button>Subscribe</button>
</form>
That's the whole API. Submitting the form POSTs to the action; with JS disabled the browser follows a 303 back to the referer (so the user sees the next page render); with JS enabled, the `` helper lets you intercept and update the page in place.
The `` helper
<input name="email" type="email" required />
<button>Subscribe</button>
This emits a standard <form action="/_bext/action/subscribe" method="POST" data-bext-form="subscribe">. With no client runtime
loaded, it works exactly like the plain HTML form above.
If you also include FORM_CLIENT_RUNTIME once in your layout:
<body>
{/* …page… */}
<div dangerouslySetInnerHTML={{ __html: FORM_CLIENT_RUNTIME }} />
</body>
…then submissions on [data-bext-form] forms are intercepted, posted
via fetch with a x-bext-form: 1 header, and the result dispatched
on the form as a bext:result CustomEvent with
detail = { ok, status, result }. A bext:pending event fires
before the request — useful for spinners.
document.querySelector('form[data-bext-form="subscribe"]')
.addEventListener("bext:result", (e) => {
if (e.detail.ok) showToast("Subscribed!");
});
The runtime is ~860 bytes inline.
Wire protocol
| Submitter | Server response |
|---|---|
| Browser form submit (no JS) | 303 → Referer (so the page reloads with the action's effects visible) |
fetch() with x-bext-form: 1 |
JSON body of whatever the action returned |
| Action throws | 500 with { error: e.message } (or 303 back for the no-JS path) |
Action returns a Response |
passed through verbatim — use this for explicit redirects after success |
The same action serves both paths. No code branches needed.
Body parsing — by content-type
The dispatcher inspects the request's Content-Type and converts the
body before calling your function:
| Content-Type | Argument shape |
|---|---|
application/json |
parsed JS value |
application/x-www-form-urlencoded |
URLSearchParams (FormData-compatible API) |
multipart/form-data |
MultipartFormData (FormData-compatible API; file fields are MultipartFile) |
text/* and other |
raw string |
So form.get("email") works whether the page used <form> (urlencoded)
or <form enctype="multipart/form-data">.
MultipartFile exposes name, type, size, bytes(), text(),
and arrayBuffer() — all reads are async (matches the Web File API).
See file uploads for the streaming path.
Per-action body cap
Default body cap is 10 MB. Raise it per-action with a comment directive at the top of the file:
"use server";
// @bext:max-body 100mb
export async function uploadVideo(form: FormData) { … }
Recognized units (case-insensitive, optional whitespace before the
unit): bare bytes, kb, mb, gb. Invalid directives are ignored.
The cap applies to the entire request body and is checked during the
drain — oversize requests get a 413 without ever reaching the
function.
The cap is per-source-file (one cap covers all exports in
src/actions/uploadVideo.ts).
File uploads and streaming
For multipart bodies above ~1 MB (configurable via
BEXT_PRISM_SPOOL_THRESHOLD), the dispatcher spools the request to a
/tmp/bext-stream-* tempfile rather than buffering in memory. The
action wrapper detects this and routes through a chunk-based streaming
parser that reads bytes on demand via two V8 bridge natives:
__bextReadChunk(path, offset, length) → Uint8Array | null— reads bytes from a confined spool path (V8 cannot read arbitrary files — the bridge enforces the/tmp/bext-stream-prefix). -__bextFileSize(path) → number— file size for terminating scans.
Memory bound during parse is parser state plus one chunk plus the
boundary needle's length — independent of total upload size. File
parts are returned as MultipartFile objects whose bytes() /
text() / arrayBuffer() methods read lazily; the parser itself
never materializes a whole file.
50 MB and 1 GB uploads both go through the same path, with constant parser memory. The tempfile auto-deletes when the dispatch returns.
"use server";
// @bext:max-body 200mb
export async function uploadVideo(form: any) {
const file = form.get("video");
if (!file || typeof file.bytes !== "function") {
throw new Error("video field required");
}
// Stream to your storage of choice. file.bytes() materializes the
// whole file into a Uint8Array — for very large uploads, prefer
// arrayBuffer() into your storage SDK if it accepts ArrayBuffer
// chunks.
const data = await file.bytes();
await s3.putObject({ Bucket: "uploads", Key: file.name, Body: data });
return { ok: true, size: data.length };
}
Returning Response vs. plain values
- Return a
Response(e.g.redirect("/thanks")) → the framework hands it back verbatim. Use this for explicit redirects after success. - Return a plain object → JSON for the fetch path; 303-back-to-Referer for the no-JS path. Same action, both submitters.
Build-time form/action field cross-check
bext walks page sources for `` and
<form action="/_bext/action/X"> blocks, collects child
<input name="…"> declarations, and cross-references against the
form.get("…") calls inside each action's body. Mismatches surface
as tracing::warn entries during dev:
[bext check] action `subscribe` reads form.get("emial") but no
declares that field. Declared: email,
newsletter
The check is regex-based and tolerates dynamic patterns —
form.get(varName), form.get(\x-${id}`), dynamic `` — without false positives. Each unique (app_root, action, missing_field)` triple logs at most once per
process; fix the typo, restart, the warning's gone.
Actions called only via fetch (no matching form on any page) skip
the check entirely.
Three dispatch pipelines
The same action runs on three pipelines, all with parity for
content-type-aware body parsing, the 303 PE fallback, and the
@bext:max-body directive:
| Pipeline | Where it lives |
|---|---|
| Dev (Bun server) | framework/src/serve.ts |
Production __fetch JSC bundle |
framework/src/build.ts |
| bext-server PRISM-native (Rust) | crates/bext-server/src/ssr_pipeline/prism.rs |
The Rust path additionally exposes spool-to-disk + V8 bridge for large uploads.
Limitations
- No closure capture: actions are top-level exports, no enclosed
variables can be referenced by call site.
- No automatic CSRF tokens. Same-origin POSTs with
application/x-www-form-urlencodedormultipart/form-dataare allowed by default; mount a middleware for stricter origin checks if you need them. - No streaming responses from actions — they return a singleResponseor value. For server-sent events / long-polling, use API routes (src/app/api/*/route.ts). - Multipart with file uploads needs thex-bext-formheader unset on the no-JS PE path so the dispatcher takes the spool path; the `` helper handles this for you.