PRISM Rendering Pipeline
This page traces a single PRISM page request all the way through the
Rust workspace: how the route is matched, how the matched .tsx is
turned into a V8-evaluable JS bundle (via the
tsc-rs frontend and the
PRISM compile pass), how that bundle
is evaluated in the V8 render pool, and
how the resulting HTML is assembled and cached.
It is the integration story for three subsystems that have their own reference pages — read this first to see how they fit together, then follow the cross-links for depth.
The path at a glance
HTTP request (GET /pricing)
│
▼
[1] Dispatch bext-server ssr_pipeline::pipeline::handle
│ → on_demand::handle_bext_app (framework = prism)
▼
[2] Route match prism::handle_prism_request → handle_prism_request_inner
│ ROUTE_TABLE_CACHE + bext_turbopack::prism::discover_routes
▼
[3] Compile (cache miss)
│ ┌─ pre_transform_source ──────────────────────────────────────┐
│ │ PRISM compile-fold pass bext_core::transform::prism_compile │ ← tsc-rs AST
│ │ (static JSX → string concat) │
│ └──────────────────────────────────────────────────────────────┘
│ tsc-rs --pipe worker (TscRsPipeWorker.transform_full) ← tsc-rs emit
│ transitive-import BFS + emit_route_bundle (topo-sorted IIFEs)
▼
[4] Evaluate V8 render pool (SubprocessPool / PrismPool)
│ install_prism_context → __bextPrismRender(props)
▼
[5] Collect collect_async_prism_result (drive microtasks/timers)
│ RouteResult { kind: "html" | "response" }
▼
[6] Assemble + cache HTML transforms → ISR / L0 / compressed-body cache
│
▼
HTTP response
Every stage below names the crate / file and the key symbols
so you can jump into the source. Paths are relative to
crates/ in the workspace.
1. Dispatch — is this a PRISM site?
PRISM is not auto-detected. A site opts in explicitly with
[framework] type = "prism" in
bext.config.toml, parsed into
FrameworkConfig::framework_type (bext-server/src/config.rs). There
are two dispatch entry points depending on deployment mode
(deployment modes):
| Mode | Entry | Notes |
|---|---|---|
Standalone (bext run) |
bext-server/src/handler.rs |
Checks framework_type == FrameworkType::Prism, builds a PrismCacheCtx backed by the L1/L2 IsrCache, calls ssr_pipeline::prism::handle_prism_request. |
| Masquerade (nginx-compat vhost) | ssr_pipeline/on_demand.rs::handle_bext_app |
When [build] engine = "turbopack", the gateway hands off to super::prism::handle_prism_request. This is how docs.bext.dev and the other in-process sites are served. |
Both converge on the shared pipeline:
pipeline::handle() ssr_pipeline/pipeline.rs
→ gate_static_public() static / public asset fast-path
→ on_demand::handle_bext_app() framework dispatch
→ prism::handle_prism_request() public, single-flight wrapper
→ prism::handle_prism_request_inner() the real work
handle_prism_request wraps the inner call in single-flight
coalescing (crate::single_flight) for cookie-less SSR GETs, so a
thundering herd on a cold route collapses to one render. Toggle with
BEXT_SINGLEFLIGHT=0.
One detail worth knowing:
FrameworkType::Prismmaps internally toDetectedFramework::Fetchfor the legacy scanner — PRISM is intercepted before the scanner runs, so tooling that readsDetectedFrameworkseesFetch, notPrism.
2. Route matching
Inside handle_prism_request_inner, special paths are matched first
(/islands/<name>.js, /_bext/action/<name>, /_bext/image,
metadata files, /api/*), then middleware is
consulted, then the page route table:
ROUTE_TABLE_CACHE— a process-globalMutex<HashMap<…>>inprism.rs, keyed by(app_root, invisible_segments)and holding the per-app route list (aVecofRouteInfo). On a miss it callsbext_turbopack::prism::discover_routes, which walkssrc/app/forpage.tsx/page.jsx. -RouteInfo(bext-turbopack/src/prism.rs) carriesurl_path,page_path,is_client,is_dynamic,is_catch_all. -match_routedoes a linear scan: static routes before dynamic, catch-all ([...slug]) last. -route_signals_cachememoizes per-route analysis (has_loader,has_action,revalidate, streaming eligibility) used to branch ISR and streaming behavior. See PRISM Data.
The cache is invalidated by clear_route_table_cache_for_app(), called
from the bundler.rs file watcher when a file under src/app/
changes. See live reload and
invalidation & restarts.
If you need to debug what the bundler emits, set BEXT_DUMP_PRISM_BUNDLE=1 and restart bext. The full CommonJS bundle for the matched route is written to stderr so you can inspect the IIFE layout and any folded string concatenations.
3. Compile — .tsx → V8 bundle
On a cache miss the matched route is compiled to a single CommonJS
bundle. The orchestration lives in bext-turbopack/src/direct.rs; the
PRISM-specific entry is compile_route_server in
bext-turbopack/src/prism.rs, which sets
jsx_import_source = Some("@bext-stack/framework") and calls
compile_entry_to_js.
3a. Four-layer compile cache
compile_entry_to_js checks four caches before doing any work:
PATH_MEMO— path-keyed, TTL-based (default 30 s,BEXT_TURBOPACK_PATH_MEMO_TTL_MS). Skips even the mtime stat. 2. content-hash compile cache — in-process, keyed on au64hash of the entry plus all transitive imports' mtimes. Survives TTL expiry. 3. persistent disk cache (compile_disk_cache) — content-hash-keyed bundles under/var/cache/bext/compile, with read-time transitive- mtime verification (self-correcting; a changed dep rejects the entry). This is the tier that survives a restart/redeploy: an unchanged route is served from disk in80 µs instead of re-running tsc-rs (8 ms) — eliminating the post-deploy recompile storm. Opt out withBEXT_TURBOPACK_DISK_CACHE=0. 4. single-flight gate —COMPILE_KEY_LOCKS, a per-content-hashArc<Mutex<()>>so N concurrent requests for the same invalidated module don't each cold-compile. Kill switch:BEXT_TURBOPACK_COMPILE_SINGLEFLIGHT=0.
No longer a gotcha (2026-06-12): the in-process caches (1, 2) still die with the process, but the disk tier (3) means a restart no longer cold-compiles unchanged routes. Source edits are picked up by the live-reload watcher (worker mode) or the masquerade route watcher (in-process mode) within ~2 s — the disk entry self-invalidates on the changed file's mtime, so you no longer need
rm -rf .bext+ restart. See invalidation & restarts.
3b. The pre-transform fold (before tsc-rs)
pre_transform_source runs before the TypeScript frontend sees the
file. When jsx_import_source == "@bext-stack/framework" and the file
is .tsx/.jsx:
"use signals"files route tobext_core::transform::prism_signals::optimize(see Signals). - otherwise the PRISM compile pass (bext_core::transform::prism_compile::optimize) folds static JSX subtrees into string concatenations directly in the source text, using the tsc-rs parser/AST (tsc_rs_parser::parse+tsc_rs_astnode types). It performs byte-range splices on the original source — it does not emit JS itself. Opt out withBEXT_PRISM_COMPILE=0.
Setting jsx_import_source also forces the tsc-rs JSX mode to
react-jsx (automatic runtime), so the emitter produces
require("@bext-stack/framework/jsx-runtime") calls for any JSX the
fold pass left behind. See the JSX runtime.
3c. The tsc-rs transform (the actual TS→JS)
The folded source is handed to a persistent tsc-rs --pipe
subprocess — TscRsPipeWorker in direct.rs:
transform_fullsends{ filename, source, options }as one JSON line and reads back{ output, imports, dynamic_imports, exports, source_map }. This is where TSX actually becomes CommonJS JS. -PipeOptionscarriestarget: es2022,module: commonjs,jsx,jsx_import_source. - The worker tracks the on-disk binary mtime and self-respawns when you rebuild tsc-rs, so frontend changes take effect without restarting bext. - A pool of these workers is sized byBEXT_TURBOPACK_PIPE_POOL_SIZE(default 1) so distinct modules can transform in parallel.
Full division of labor — who parses, who emits — is on the TypeScript Frontend page.
3d. Transitive closure + bundle emit
compile_closure does a BFS over the entry's imports, transforming
each module once (results memoized in the blake3-keyed
shared_registry() / ModuleRegistry), then emit_route_bundle
stitches everything into one file:
- writes shim IIFEs and globalThis.__Layout{0,1,2} fallbacks,
- emits each module as an IIFE with //# sourceURL=<abs>,
- runs a
topological_sortso modules evaluate in dependency order (with pre-registered empty__modules[abs]objects for cycle safety).
BEXT_PRISM_COMPILE_OFFTHREAD=1 moves this whole step onto
spawn_blocking so a cold compile doesn't block the actix worker
thread (a recompile-storm mitigation — see the
V8 render pool post-mortem).
Dump the emitted bundle for inspection with BEXT_DUMP_PRISM_BUNDLE=1.
4. Evaluate in V8
The compiled bundle (an Arc-shared string) is evaluated by the
V8 render pool. There are two backends;
the dispatch decision in handle_prism_request_inner is roughly
use_pool = !subprocess_mode_configured() && backend().is_none():
| Backend | Crate / file | When |
|---|---|---|
SubprocessPool |
bext-v8/src/subprocess.rs |
Production. BEXT_V8_POOL=1. N out-of-process workers, each one V8 isolate, render frames sent over framed Unix sockets. |
In-process PrismPool |
bext-v8/src/prism_pool.rs |
default_pool(), BEXT_PRISM_WORKERS OS threads (default 2), each a long-lived isolate. |
| Single eval thread | bext-v8/src/eval.rs |
Fallback; one bext-v8-eval-worker thread serializes all renders. |
Regardless of backend, a render context is set up by
install_prism_context (eval.rs): a fresh V8 context with the
bridge functions registered (bridge::register_bridge_functions —
__httpFetch, __bextReadChunk, __env, __readFile, …), the
polyfills evaluated, and the bundle JS evaluated. Warm contexts are
cached in prism_contexts (keyed by prism_cache_key(shell, bundle))
so subsequent renders on the same isolate skip re-evaluating the bundle.
Building a context for the first time evaluates the bundle JS. That eval
is backed by a persistent V8 bytecode cache (bytecode_disk_cache,
/var/cache/bext/bytecode, keyed by the bundle hash + V8 version tag):
on a cold context build the worker restores the compiled bytecode via
ConsumeCodeCache instead of re-parsing+compiling the JS, so a
post-restart context build is faster too. Opt out with
BEXT_V8_BYTECODE_DISK_CACHE=0. Together with the disk compile cache
(§3a), a restart skips both the tsc-rs compile and the V8 parse.
5. Render → RouteResult
The render is driven by calling globalThis.__bextPrismRender(propsJson):
- Buffered (
render_prism_in_context_buffered) —collect_async_prism_resultpollsglobalThis.__bextPrismResult.donein a loop, pumping microtasks/timers (pump_timers_and_microtasks) until the render settles, then returns the result. - Streaming (render_prism_in_context_streaming, opt-inBEXT_PRISM_STREAMING=1) —collect_async_prism_result_progressivedrives the V8 event loop and pushes each new chunk onto a Tokio channel as it appears, sorenderToReadableStreamoutput flushes to the wire progressively. Routes with aloader/action(which can redirect or set a status) fall back to buffered.
The render returns a RouteResult over a binary IPC framing
(\x01v1\n{envelope}\n{body}) that avoids JSON.stringify escape
overhead:
- kind: "html" — a normal page body.
kind: "response"— the loader/action returned aResponse(redirect,notFound(), custom status). Rust extractsstatus,headers, andbodyand builds theSiteResponsedirectly.
6. Assemble, transform, cache
The HTML is finalized through the response-time transform pipeline (server-island replacement, preload-hint extraction for HTTP 103 Early Hints) and the request lifecycle's compression stage, then cached across several layers:
| Layer | Where | Purpose |
|---|---|---|
| L0 | bext-server/src/l0_cache.rs |
Top-of-chain in-process cache at the actix boundary. Only fresh ISR hits are stored (host+path+query key). |
| ISR (L1/L2) | bext-core/src/cache/isr.rs (IsrCache) |
The mode = "isr" cache with revalidate TTL, tag invalidation, and stale-while-revalidate loopback. L2 is Redis-backed for multi-instance. See caching. |
| Compressed-body side cache | DashMapPrismStore in prism.rs |
Pre-compressed (brotli/gzip) bodies so hot hits skip re-compression. Budgeted by BEXT_COMPRESSED_BODIES_MAX_BYTES (default 512 MiB). |
| Route table / compile cache | §2, §3a | Structural caches keyed by app + content hash. |
The docs.bext.dev site you are reading runs mode = "isr" with
revalidate = 300, so most fragment renders are cache hits.
7. The error path
When a render fails, prism_error_response (prism.rs) returns a 500
with the error surfaced in headers for the dev overlay:
x-bext-error-kind,x-bext-error-message,x-bext-error-route, each sanitized to visible ASCII byto_ascii_header(em dashes, curly quotes, and ellipses are folded; anything outside0x20..=0x7Ebecomes?). This matters because a non-ASCII header value makesHeaderValue::TryFromsilently fail and the header vanish. - the body is a styled error page fromrender_prism_error_pagewith heuristic "likely cause" hints.
Gotcha: compile failures take a different branch that renders the error page directly without setting
x-bext-error-message, so the dev overlay falls back to reading the (possibly compressed) body. Only render failures set the header. See error codes and troubleshooting.
Under pool backpressure, the response is instead a 503 with
Retry-After: 1 and x-bext-pool-shed: queue-full — see
early shed.
Where to go next
- TypeScript Frontend (tsc-rs) — the parser/emitter that powers steps 3b–3c. - PRISM compile pass — what the fold pass folds, what it skips, and the perf numbers. - V8 Render Pool — the pool topology, watchdog, and resilience that runs step 4. - Transform Pipeline — every source and response transform in execution order. - Request Lifecycle — the full HTTP pipeline this render is one stage of. - PRISM (framework) — the authoring model from the app developer's side.