Skip to content
zy445566Public

About

typescript binary compiler

Topics

Resources

Stars

25 stars

Watchers

2 watching

Forks

Repository files navigation

xbintsc

Language: English | 简体中文

xbintsc compiles a practical subset of TypeScript directly to native binaries. It parses TypeScript itself, binds names, lowers the program to LLVM IR text, and hands the IR to clang, which produces a standalone executable linked against a small C runtime.

The design goals are:

  1. Incremental compilation — builds are content-addressed and skipped when nothing changed.
  2. Well-factored modules — lexer, parser, binder, codegen, driver, extensions each live in their own directory with their own tests.
  3. Extensive tests — per-module unit tests plus end-to-end tests that compile and run real binaries.
  4. Pluggable extensions — Node fs, the gui HTML/CSS renderer , … are optional compile modules, not core code.
  5. Self-hosting — the compiler compiles itself: src/cli/main.ts builds into a native xbintsc binary whose emitted LLVM IR is a fixpoint across generations. CI builds these binaries for every platform and a release attaches them.
  6. Multi-platform — macOS, Linux and Windows on x64 and arm64 (Apple Silicon / AArch64), validated by GitHub Actions on native runners.

How it works

source.ts
   │  lexer      src/lexer
   ▼
 tokens
   │  parser     src/parser        → AST (src/ast)
   ▼
 AST
   │  binder     src/binder        → scopes, symbols, closures
   ▼
 bound AST
   │  codegen    src/codegen       → LLVM IR text (src/codegen/llvm.ts)
   ▼
 module.ll ──clang──► module.o ──link──► executable
                                  ▲
                          runtime/ (C runtime, NaN-boxed values)

Value model

JavaScript values are a single 64-bit word (xt_value). Doubles are stored unboxed; everything else is a tagged pointer: a 16-bit tag in the high bits plus a 48-bit payload. The representation is defined once in src/codegen/values.ts and runtime/rt.h.

Calling convention

Every compiled function uses the same ABI:

xt_value fn(xt_value env, int32_t argc, xt_value *argv);

env threads captured variables (by reference, through boxes), so direct calls and closure calls share one code path. JavaScript semantics that are awkward to inline (+ coercion, property access, inspection) are delegated to @xt_* runtime calls. Numeric arithmetic and comparison are the exception: when both operands are doubles, codegen emits the hardware operation inline and only falls back to the runtime for the coercing cases.

Runtime

runtime/ implements strings, objects, arrays, closures, arithmetic, comparison, exceptions and Node-like console.log inspection. The C code is split by function across translation units (xt_alloc.c, xt_values.c, xt_containers.c, xt_stdlib.c, xt_builtins.c, xt_io.c) sharing the private runtime/rt_internal.h. Heap objects are allocated by xt_alloc and reclaimed by a non-moving mark-sweep collector: roots are explicit value slots (module globals, prototype caches), runtime root providers (the event loop, the promise microtask queue) and a conservative scan of the active C stack. Collection is armed automatically once the live heap crosses a threshold (XT_GC_THRESHOLD pins it) and can also run on demand. After every collection the threshold becomes twice the surviving heap (floor 1 MiB), so a program with a small live set collects often and keeps a small resident set; XT_GC_TRACE=1 prints one line per collection (heap and live-allocation counts, the next trigger and the live set by object kind) to stderr.

Errors capture a JavaScript-level stack (function name, source file and a function-level line) from a shadow call stack maintained by generated code; the same frames are dumped on a fatal native signal. Function symbols keep their source names (xt_fn_3_add), so native debuggers and crash reports are readable too.

Usage

Before you start: check the per-platform prerequisites in Requirements. xbintsc does not bundle a compiler, so every platform needs a clang-compatible toolchain installed (macOS: Xcode Command Line Tools; Linux: clang+lld; Windows: Visual Studio C++ build tools with their bundled LLVM/MSVC).

Prebuilt standalone archives

Every GitHub Release attaches a release archive per platform, plus a .sha256 checksum next to it:

Platform Archive
Windows x64 / arm64 xbintsc-<version>-win32-x64.tar.zst / xbintsc-<version>-win32-arm64.tar.zst
Linux x64 / arm64 xbintsc-<version>-linux-x64.tar.zst / xbintsc-<version>-linux-arm64.tar.zst
macOS x64 / arm64 (Apple Silicon) xbintsc-<version>-darwin-x64.tar.zst / xbintsc-<version>-darwin-arm64.tar.zst

(.tar.gz is used only when zstd is unavailable on the build machine.)

<version> is the release version (for example 0.3.8), embedded in the asset name so downloads from different releases do not collide.

Each archive unpacks to a xbintsc-<os>-<arch>/ folder containing the compiler (bin/xbintsc[.exe]) and the C runtime. It needs no Node.js, but you must have the platform toolchain installed (see Requirements). Unpack it and put bin/ on PATH (or call bin/xbintsc directly) — there is no install step for xbintsc itself.

macOS / Linux example — here using xbintsc-<version>-darwin-arm64.tar.zst (swap in -linux-x64, etc. for your platform):

# 1. Verify the checksum (optional but recommended)
shasum -a 256 -c xbintsc-<version>-darwin-arm64.tar.zst.sha256   # Linux: sha256sum -c

# 2. Unpack
tar -xf xbintsc-<version>-darwin-arm64.tar.zst

# 3. Compile and run hello.ts (below)
./xbintsc-darwin-arm64/bin/xbintsc run ./hello.ts

# 4. ...or emit a standalone binary
./xbintsc-darwin-arm64/bin/xbintsc build ./hello.ts --out ./build
./build/hello

Windows example — install the Visual Studio C++ build tools (MSVC, MSBuild and its bundled LLVM; see Requirements), then download an archive from the Releases page (here xbintsc-<version>-win32-x64.tar.zst; use -arm64 on Windows on ARM) and run the commands below in an x64 Native Tools Command Prompt for VS 2022 (Windows on ARM: ARM64 Native Tools) — Windows 10+ ships tar:

# 1. Verify the checksum (optional but recommended)
(Get-FileHash .\xbintsc-<version>-win32-x64.tar.zst -Algorithm SHA256).Hash
Get-Content .\xbintsc-<version>-win32-x64.tar.zst.sha256

# 2. Unpack
tar -xf .\xbintsc-<version>-win32-x64.tar.zst

# 3. Compile and run hello.ts (below)
.\xbintsc-win32-x64\bin\xbintsc.exe run .\hello.ts

# 4. ...or emit a standalone hello.exe
.\xbintsc-win32-x64\bin\xbintsc.exe build .\hello.ts --out .\build
.\build\hello.exe
// hello.ts
console.log("Hello from xbintsc!");

Tip: add xbintsc-win32-x64\bin to PATH to call xbintsc instead of the full path.

From npm

The compiler is also published as the xbintsc npm package. This is the shortest path for a Node project: install it as a dev dependency, then run it through npx or an npm script.

npm install --save-dev --save-exact xbintsc
npx xbintsc doctor
npx xbintsc build src/main.ts --out build

It needs Node.js 22+ and a clang 16+ toolchain. The install also brings the matching @xbintsc/<os>-<arch> package with prebuilt runtime archives (core, ext_node, and gui/media where the release builds them), so the first build links them instead of compiling C. If npm skips that optional package, the runtime is compiled from the shipped sources and cached in .xbintsc/. See Using xbintsc from npm for a complete guide, CI setup and caveats.

As a developer (from source)

In a source checkout, run the TypeScript sources directly through tsx (or use npm run xbintsc). The bin launcher also works here: without a dist/ build it falls back to tsx automatically.

# Install dependencies
npm install

# Compile and run a program
npm run xbintsc -- run examples/hello.ts
# equivalently
npx tsx src/cli/main.ts run examples/hello.ts

# Produce a native binary
npx tsx src/cli/main.ts build examples/hello.ts --out build/examples
./build/examples/hello

# Inspect the generated LLVM IR
npx tsx src/cli/main.ts emit examples/hello.ts | head

# Use an optional extension (here, Node's fs via import)
npx tsx src/cli/main.ts run examples/node/read.ts --ext node

CLI options:

-o, --output <path>   Output path
    --out <dir>       Output directory (default: build/)
    --emit <kind>     exe | obj | ir (default: exe)
-O0..-O3              Optimization level (default: -O2)
    --ext <names>     Comma separated extensions (e.g. node)
    --ext-native <m>  Register a C++/Rust extension from a JSON manifest
    --config <path>   Use a project config (default: xbintsc.config.json)
    --no-config       Do not read any project config
    --icon <path>     Embed an application icon (PNG/ICO/ICNS)
    --bundle          macOS: also produce a .app bundle
    --app-name <name> Bundle / display name
    --app-id <id>     macOS bundle identifier (e.g. com.example.demo)
    --force           Ignore the incremental cache
    --verbose         Print progress information
    --time            Print per-phase build timings
    --debug           Emit debug info (DWARF/CodeView) and keep frame pointers
    --check           Run the semantic checker (default)
    --no-check        Skip the semantic checker; only erase type syntax

Project config & application icon

Build options can be checked in as xbintsc.config.json, so xbintsc build needs no arguments. All paths resolve against the config file's directory, and any CLI flag overrides the matching config field:

{
  "entry": "src/app.ts",
  "outDir": "build",
  "extensions": ["gui"],
  "app": {
    "name": "Demo",
    "icon": "assets/app.png",
    "bundle": true,
    "bundleId": "com.example.demo"
  }
}

The icon is embedded into the binary at compile time (--icon / app.icon). On Windows it becomes a PE resource (Explorer/taskbar icon); on macOS --bundle lays out Demo.app with an AppIcon.icns; on every platform a gui program sets its window/Dock icon from the embedded image at startup. See doc/icon.md.

Programmatic API

import { build, compileString } from "xbintsc";

const { ir } = compileString("console.log(1 + 1);");
const result = build("program.ts", { emit: "exe", outDir: "build" });

GUI applications (HTML/CSS)

xbintsc can also build native desktop applications whose UI is HTML/CSS. The optional gui extension links a self-hosted, GPU-accelerated HTML/CSS renderer (its own HTML parser, CSS cascade, layout and SDL_GPU compositor — no system WebView, no bundled browser) into the executable and exposes it as an importable module:

// app.ts
import { createWindow, run } from "gui";
// The .html asset loader AOT-compiles the document's scripts (see below)
import page from "./page.html";

const win = createWindow({ title: "Demo", width: 900, height: 600 });
win.on("click", (e: any) => console.log(e.x, e.y, e.target));
win.loadHTML(page);
run();                          // main loop, until every window closes
# Source checkout: build the engine archive once (fetches and builds SDL3,
# FreeType/HarfBuzz and glslang; default text uses the bundled stb_truetype), then run the bundled example
npm run gui
npx tsx src/cli/main.ts run examples/gui/hello.ts --ext gui

# A release archive or an npm install already ships the engine:
xbintsc run app.ts --ext gui

# <video>/<audio> decoding is an optional link feature: the compiler sees the
# elements in the page and links the FFmpeg archive for that build only, so a
# GUI-only binary stays FFmpeg-free.
npm run media
xbintsc run examples/gui/media.ts --ext gui

A document's <script> bodies are AOT-compiled by xbintsc into the binary — inline scripts (a bare <script> is already treated as TypeScript) and external <script src="./app.ts"> files alike — so there is still no JavaScript engine at runtime. Element handles, DOM mutation, events and the rest of the scripting surface are documented in doc/gui-scripts.md.

In a project, list the extension in xbintsc.config.json ("extensions": ["gui"]) and the flag becomes unnecessary, exactly as for node. XT_GUI_AUTOCLOSE_MS=<ms> closes all windows after a delay, which is how the end-to-end tests run the examples headlessly.

examples/gui/pelican-bike is a playable 2D game whose document also opens unchanged in a browser.

Platforms and full details: doc/gui.md is the engine overview (architecture and roadmap); the per-function documents live under doc/gui/ — API, DOM/CSS, layout, rendering, media, input, animation, runtime, build and known issues. Linux and macOS are built and exercised in CI; their release archives and the optional @xbintsc/<os>-<arch> npm packages ship the engine (gui.a) plus the optional media archive (media.a). Windows x64 gets the same pair (gui.lib through SDL_GPU's Vulkan backend, and media.lib from FFmpeg compiled with clang-cl for the MSVC ABI), built in CI with the Visual Studio MSVC + LLVM toolchain a local build uses; Windows arm64 still skips them. Running the examples in CI stays provisional because hosted runners have no GPU/Vulkan driver. Building media.lib on Windows needs a POSIX shell and GNU make for FFmpeg's configure step: Git for Windows supplies the shell, and make is a native GNU make (Chocolatey). See doc/gui/build.md. Without the media archive <video> and <audio> fire error.

Incremental compilation

The driver keys each build on the entry source hash, compiler version, options, platform and the active extension set (src/driver/cache.ts). If every recorded output still exists, the build returns immediately. The C runtime and extension sources are compiled once and cached on the same principle.

Extensions

An extension is a plain object (src/extensions/registry.ts). Three are bundled: gui, a GPU HTML/CSS renderer (see doc/gui.md); media, the optional FFmpeg-backed <video>/<audio> decoder that requires gui; and node, itself split into one folder per Node module, each pairing its exports with the C sources that implement them:

src/extensions/node/       runtime/ext_node/
  index.ts   # nodeExtension  fs/read_file.c   fs/write_file.c   fs/fs_ops.c
  fs/index.ts                path/path.c      os/os.c           process/process.c
  fs/read-file.ts
  path/index.ts
  os/index.ts
  process/index.ts
// src/extensions/node/index.ts
const modules: readonly NodeModule[] = [fsModule, pathModule, osModule, processModule];

export const nodeExtension: Extension = {
  name: "node",
  runtimeSources: () => [...new Set(modules.flatMap((m) => m.runtimeSources()))],
  modules: () => Object.fromEntries(
    modules.flatMap((m) => {
      const entry = { namespace: m.namespace, exports: m.exports?.() ?? m.builtins() };
      return [[m.name, entry], [`node:${m.name}`, entry]];
    }),
  ),
};

Registering it links the extra C sources and makes the Node APIs available to import: import { readFileSync } from "fs" resolves to the C symbol with the uniform (argc, argv) calling convention. path, os and process additionally hook into namespace dispatch, so import path from "path" (or import * as path from "path") makes path.join(...) and process.cwd() lower to their runtime entries. Adding a module means dropping a folder under src/extensions/node/ and its C counterpart under runtime/ext_node/; the core compiler never changes.

Node module coverage:

Native extensions (C++ / Rust)

Extensions do not have to be written in C or TypeScript. Any library that exposes extern "C" entry points with the runtime ABI (xt_value fn(int32_t argc, xt_value *argv)) can be linked in. Build the C++ or Rust code into an object file or static archive, describe it with a small JSON manifest, and pass the manifest to --ext-native:

./examples/extensions/cpp/build.sh
xbintsc run examples/extensions/cpp/demo.ts \
  --ext-native examples/extensions/cpp/xbintsc.manifest.json
{
  "name": "mathx-cpp",
  "objects": ["build/mathx.o"],
  "linkerFlagsByPlatform": { "linux": ["-lstdc++", "-lm"], "darwin": ["-lc++"], "win32": ["-lmsvcprt"] },
  "builtins": { "cppClamp": { "symbol": "mathx_clamp" } },
  "modules": { "mathx": { "exports": { "add": { "symbol": "mathx_add" } } } }
}

import { add } from "mathx" then lowers to the C++/Rust symbol exactly like a C runtime binding. See examples/extensions/ for working C++ and Rust projects, and runtime/xt_ext.h / runtime/xt_ext.rs for the authoring helpers.

Tests

npm run typecheck        # tsc --noEmit
npm test                 # unit + end-to-end (runs real binaries when clang is present)
npm run test:e2e         # only the compile-and-run tests
npm run coverage         # V8 coverage of the TypeScript compiler (src/)
npm run coverage:runtime # LLVM coverage of the C runtime (runtime/) via clang

Tests are organised by module under tests/ (lexer, parser, binder, codegen, driver, extensions, cli, e2e).

V8 can only see the compiler's own sources, so npm run coverage:runtime covers the C runtime with clang's source instrumentation instead: it exports CCC_OVERRIDE_OPTIONS (every compile/link the driver performs gets -fprofile-instr-generate -fcoverage-mapping), runs the suite, and reports the collected profiles against runtime/ with llvm-cov. Pass -- --report-only to only re-report profiles from an earlier instrumented run, or -- --threshold 60 to fail below a coverage bar. CI runs the report with --threshold 80, so a runtime-coverage regression fails the job. The instrumented objects live in xbintsc_CACHE_DIR, a separate cache from .xbintsc, because the object cache key does not include compiler flags: sharing one cache would silently reuse un-instrumented objects. It also sets xbintsc_PREFER_PREBUILT=0 so the runtime is compiled from source rather than linked from an un-instrumented runtime/lib archive. Requires llvm-profdata/llvm-cov matching the resolved clang; the script skips with a warning when they are missing.

Requirements

See Requirements for the full, per-platform list.

  • Node.js 22+ — to run the compiler from npm or from source; released binaries are standalone
  • macOS: the Xcode Command Line Tools (xcode-select --install)
  • Windows: the Visual Studio C++ build tools (MSVC, MSBuild and the bundled LLVM; scripts/setup-vs-toolchain.ps1 imports that exact toolchain)
  • Linux: clang and lld from your distribution
  • To build a binary yourself: a clang-compatible compiler on PATH (override with xbintsc_CLANG)

Check your environment with xbintsc doctor.

Language subset

Using xbintsc with an AI agent

The compiler is strict about its language subset — types are structurally checked by a default checker and then erased (definite mismatches, missing properties and non-callable calls are rejected, but there is no full inference or strict-null mode), Node built-ins need --ext node, packages from node_modules are bundled at build time, strings follow UTF-16 length/indexing over UTF-8 storage, and await only suspends on a pending promise. An agent that assumes "TypeScript" means "what Node accepts" will write code that does not compile, so the documentation is layered for exactly that reader:

Failures point the way themselves: a compiler error ends with a hint: … — see <document> line naming the page that explains the fix. Pass --no-hints for plain output.

About

typescript binary compiler

Topics

Resources

Stars

25 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages