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-urlencoded or multipart/form-data are allowed by default; mount a middleware for stricter origin checks if you need them. - No streaming responses from actions — they return a single Response or value. For server-sent events / long-polling, use API routes (src/app/api/*/route.ts). - Multipart with file uploads needs the x-bext-form header unset on the no-JS PE path so the dispatcher takes the spool path; the `` helper handles this for you.