TypeScript Frontend (tsc-rs)
bext does not shell out to the official tsc, SWC, or esbuild for its
TypeScript work. The whole TS/TSX → JS frontend is tsc-rs, a
from-scratch TypeScript compiler written in Rust. It lives in its own
workspace (ts-rs on disk, published as the tsc-rs toolchain) and is
consumed by bext both as a set of library crates (in-process, for
AST work) and as a --pipe subprocess (for the actual TSX→JS
emit).
This page maps the tsc-rs workspace and documents exactly how bext wires it in. For where it sits in a render, see the PRISM Rendering Pipeline.
Why a custom compiler
The PRISM compile-fold pass needs a real TypeScript AST — it folds static JSX subtrees into string concatenations by inspecting parsed nodes and splicing the original source text. That requires a parser that is:
- fast — it runs on every cold route compile, inside the request
path;
- embeddable — callable as a Rust library, not a Node subprocess,
for the AST passes; and
- tooling-friendly — tsc-rs offers preserve modes
(
preserveTypeAnnotations,preserveComments) and a source-text- preserving emitter that keeps original formatting for nodes that need no transform.
tsc-rs describes itself as a tooling-first compiler rather than a
byte-for-byte tsc clone. Reported parity at the pinned revision is
~92% of the compiler test suite (92.2%) and ~85% conformance (85.0%);
the type checker is intentionally incomplete (bext only uses the
parser/binder/emitter, not the checker).
The tsc-rs workspace
The compiler is a classic staged pipeline, one crate per stage:
source ─▶ scanner ─▶ parser ─▶ binder/symbols ─▶ checker/types ─▶ emitter ─▶ JS
(tokens) (SourceFile) (SymbolTable) (TypeId) (EmitOutput)
Compiler-core crates
| Crate | Role |
|---|---|
tsc_rs_ast |
All AST node types — SourceFile, Stmt/StmtKind, Expr/ExprKind, TypeNode, JSX nodes, Span, Diagnostic, CompilerOptions. NodeId/SymbolId/TypeId are u32; identifiers ≤24 bytes are stored inline (CompactString), and node-kind enums are size-pinned (≤32 bytes) by compile-time assertions. |
tsc_rs_scanner |
Lexer — streaming (TsScanner) and batch (Scanner) tokenizers; scan_all_with_comments() for tooling. |
tsc_rs_parser |
Recursive-descent parser → SourceFile. Public API: parse(file_name, source) (JSX auto-enabled for .tsx/.jsx) and parse_with_jsx(file_name, source, jsx_enabled). |
tsc_rs_symbols |
Binder — walks the AST into a SymbolTable with a ScopeGraph and ReferenceKinds. Public: bind(file) -> SymbolTable. |
tsc_rs_types |
Type system + inference (check, is_assignable, control-flow narrowing facts). Not used by bext. |
tsc_rs_resolver |
Node + Classic module resolution → ResolvedModule. |
tsc_rs_emitter |
Source-text-preserving JS / source-map / .d.ts emitter. Public: emit, emit_strip_types, emit_strip_types_jsx(file, jsx, jsx_import_source), and variants for cross-file consts / const-enum inlining. Returns EmitOutput { javascript, source_map, declaration_file }. |
tsc_rs_project |
tsconfig.json handling, multi-file orchestration (rayon), incremental .tsbuildinfo, project references (-b). |
Tooling, server, and satellite crates
| Crate | Role |
|---|---|
tsc_rs_cli |
The tsc-rs binary — compile, --watch, --lsp, and the --pipe mode bext drives. |
tsc_rs_server |
Hand-rolled JSON-RPC LSP (diagnostics, hover, go-to-def, refs, completions, rename, formatting). |
tsc_rs_query |
QueryEngine — a unified tooling facade over parser/binder/resolver/checker, consumed by tsc_rs_project and the LSP. |
tsc_rs_incremental |
Incremental-build metadata + ChangeReason change detection. |
tsc_rs_constraints |
Experimental constraint-graph solver ("explain why type X is Y"). |
tsc_rs_control_flow |
Experimental CFG primitives built once during binding and reused. |
tsc_rs_harness |
Baseline / oracle test infrastructure (compiler + fourslash LSP tests). |
tsc_rs_bench |
Criterion benchmarks for scanner / parser / emitter / pipeline. |
tsc_rs_openapi |
Generates OpenAPI specs from tRPC routers by static analysis (Zod → JSON Schema). |
tsc_rs_analyze |
Monorepo static analysis — import graph, cycles, dead code, externals — parse + resolve only, no checking. |
tsc_rs_wasm |
wasm-bindgen bindings exposing compile() / validate() for browser / edge / embedded use. |
How bext depends on tsc-rs
bext-core declares four tsc-rs crates as git dependencies pinned
to a revision (crates/bext-core/Cargo.toml):
tsc_rs_parser = { git = "https://github.com/benfavre/ts-rs.git", rev = "651ef98c" }
tsc_rs_ast = { git = "https://github.com/benfavre/ts-rs.git", rev = "651ef98c" }
tsc_rs_emitter = { git = "https://github.com/benfavre/ts-rs.git", rev = "651ef98c" }
tsc_rs_symbols = { git = "https://github.com/benfavre/ts-rs.git", rev = "651ef98c" }
The workspace root (bext/Cargo.toml) then redirects those git
deps to the local sibling checkout with a [patch] so day-to-day
development builds against the working tree:
[patch."https://github.com/benfavre/ts-rs.git"]
tsc_rs_ast = { path = "../ts-rs/crates/tsc_rs_ast" }
tsc_rs_parser = { path = "../ts-rs/crates/tsc_rs_parser" }
tsc_rs_emitter = { path = "../ts-rs/crates/tsc_rs_emitter" }
tsc_rs_symbols = { path = "../ts-rs/crates/tsc_rs_symbols" }
Practical consequence: editing ../ts-rs and rebuilding bext picks up
your changes immediately — the pinned rev only matters for clean
CI / release builds that don't have the sibling checkout. The other
bext crates (bext-turbopack, bext-server) don't depend on tsc-rs
directly — they reach it through bext-core for the AST passes and
through the tsc-rs binary for emit.
Two integration modes
bext uses tsc-rs in two distinct ways, and the split is the key thing to understand.
Mode 1 — library crates (in-process AST passes)
The source transforms that need to
inspect or rewrite code parse with tsc_rs_parser and walk
tsc_rs_ast nodes in-process. None of these emit JS — they either
splice the source string or build a side manifest, then hand the
result to Mode 2 for the actual transpile.
| bext file | tsc-rs APIs used | What it does |
|---|---|---|
bext-core/src/transform/prism_compile.rs |
tsc_rs_parser::parse + tsc_rs_ast::{JsxElement, JsxAttribute, JsxChild, Expr, Stmt, …} |
The PRISM compile-fold pass. Folds static JSX → string concat by byte-range splicing the original source. |
bext-core/src/transform/react_semantics.rs |
tsc_rs_parser::parse + tsc_rs_symbols::{bind, ScopeGraph, ReferenceKind} |
The only consumer of the binder — builds a scope/reference manifest for the React-compiler pass and RSC boundary detection. |
bext-core/src/transform/prism_signals.rs |
tsc_rs_parser::parse + tsc_rs_ast |
The "use signals" JSX transform. See Signals. |
bext-core/src/transform/rsc_directives.rs |
tsc_rs_parser::parse |
Detect/rewrite "use client" / "use server" for the RSC path. |
bext-core/src/transform/{flow_extract,cache_directive,server_boundary,import_strip}.rs |
pre-parsed tsc_rs_ast::SourceFile |
Directive extraction + import stripping; receive the AST from mod.rs::maybe_parse_ast (lazy — only parses when the source contains a trigger string). |
bext-core/src/framework/prism_data.rs |
tsc_rs_parser::parse (in catch_unwind) |
Analyzes route exports — loader, action, metadata. See PRISM Data. |
bext-core/src/runtime/typescript.rs |
tsc_rs_parser::parse + tsc_rs_emitter::emit_strip_types |
strip_types() — the one in-process path that uses the emitter directly, for small internal TS snippets that don't warrant the pipe round-trip. |
The lazy-parse rule in transform/mod.rs::maybe_parse_ast is a
deliberate cost control: the AST is only built when the source actually
contains "use flow", "use server", "use client", "use cache",
or a banned-package name. Files that need none of those skip parsing
entirely.
If you check out ts-rs next to bext, all AST changes take effect on the next cargo build — the [patch] in the workspace root redirects the pinned git deps to your local tree automatically. You do not need to bump the pinned revision during development.
Mode 2 — the tsc-rs --pipe worker (the real TSX→JS)
The actual transpilation — TSX → CommonJS that V8 evaluates — is done
by a long-lived tsc-rs --pipe subprocess, managed by
TscRsPipeWorker in bext-turbopack/src/direct.rs:
- one newline-delimited JSON request per module:
{ filename, source, options }→{ output, imports, dynamic_imports, exports, source_map }; -PipeOptionspinstarget: es2022,module: commonjs, and the JSX mode (reactclassic, orreact-jsxautomatic whenjsx_import_sourceis set); - the worker records the binary's mtime and self-respawns when you rebuild tsc-rs, so frontend changes apply without restarting bext; - a pool of workers (BEXT_TURBOPACK_PIPE_POOL_SIZE, default 1) lets distinct modules transpile in parallel.
bext-turbopack::direct::transform_module is the façade; the three
bext-server compile paths (ssr_compile_native,
rsc_server_compile_native, rsc_client_build_native) all funnel
through it, with a fork-per-call tsc-rs subprocess as the fallback if
the pipe worker is unavailable.
Pipe mode exists to avoid fork+exec on every module — a cold route with dozens of transitive imports would otherwise pay a process spawn per file.
Division of labor (the one-paragraph version)
tsc-rs the binary does the real TSX → JS transpile (Mode 2). tsc-rs the library (
tsc_rs_parser+tsc_rs_ast, plustsc_rs_symbolsfor React semantics) is the AST frontend for bext's in-process source passes (Mode 1), which rewrite source text before the binary transpiles it. Turbopack/the bundler does module resolution, transitive-closure walking, and bundle stitching — not the per-module TS transform. SWC is used only by the optional React Compiler pass, not for stripping types.
Cross-references
- PRISM Rendering Pipeline —
where the parse/fold/emit steps run in a request.
- Transform Pipeline — every
source transform, in order, with the AST-vs-string split.
- PRISM compile pass — the headline
consumer of the tsc-rs AST.
- Crate Reference — the bext side of
the dependency graph.
- Build & Feature Flags — how the
turbopack/react-compilerfeatures gate these paths.