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 <Form>
helper lets you intercept and update the page in
place.
The <Form> helper
import { Form, FORM_CLIENT_RUNTIME } from "@bext-stack/framework";
<Form name="subscribe">
<input name="email" type="email" required />
<button>Subscribe</button>
</Form>
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 <Form name="X"> 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
<Form name="subscribe"> declares that field. Declared: email,
newsletter
The check is regex-based and tolerates dynamic patterns —
form.get(varName), form.get(\x-${id}`), dynamic