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::Prism maps internally to DetectedFramework::Fetch for the legacy scanner — PRISM is intercepted before the scanner runs, so tooling that reads DetectedFramework sees Fetch, not Prism.

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-global Mutex<HashMap<…>> in prism.rs, keyed by (app_root, invisible_segments) and holding the per-app route list (a Vec of RouteInfo). On a miss it calls bext_turbopack::prism::discover_routes, which walks src/app/ for page.tsx / page.jsx. - RouteInfo (bext-turbopack/src/prism.rs) carries url_path, page_path, is_client, is_dynamic, is_catch_all. - match_route does a linear scan: static routes before dynamic, catch-all ([...slug]) last. - route_signals_cache memoizes 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.

Tip

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:

  1. 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 a u64 hash 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 in 80 µs instead of re-running tsc-rs (8 ms) — eliminating the post-deploy recompile storm. Opt out with BEXT_TURBOPACK_DISK_CACHE=0. 4. single-flight gateCOMPILE_KEY_LOCKS, a per-content-hash Arc<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 to bext_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_ast node types). It performs byte-range splices on the original source — it does not emit JS itself. Opt out with BEXT_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 subprocessTscRsPipeWorker in direct.rs:

  • transform_full sends { 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. - PipeOptions carries target: 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 by BEXT_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_sort so 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_result polls globalThis.__bextPrismResult.done in a loop, pumping microtasks/timers (pump_timers_and_microtasks) until the render settles, then returns the result. - Streaming (render_prism_in_context_streaming, opt-in BEXT_PRISM_STREAMING=1) — collect_async_prism_result_progressive drives the V8 event loop and pushes each new chunk onto a Tokio channel as it appears, so renderToReadableStream output flushes to the wire progressively. Routes with a loader/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 a Response (redirect, notFound(), custom status). Rust extracts status, headers, and body and builds the SiteResponse directly.

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 by to_ascii_header (em dashes, curly quotes, and ellipses are folded; anything outside 0x20..=0x7E becomes ?). This matters because a non-ASCII header value makes HeaderValue::TryFrom silently fail and the header vanish. - the body is a styled error page from render_prism_error_page with 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