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.

Tip

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 }; - PipeOptions pins target: es2022, module: commonjs, and the JSX mode (react classic, or react-jsx automatic when jsx_import_source is 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, plus tsc_rs_symbols for 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