Worker programs on Linux tested a process-wide alert word after every call that may throw and called scr_exc_pending on its slow path to answer for the calling thread. Each of the ~55k slow-path calls in tsc-ts counted against LLVM's inline cost, and while any thread had an exception in flight every other thread took the slow path. ELF worker executables (the emitter's executableTls targets) now give every script thread an exact pending word in the executable's static TLS block, and the emitted check is one thread-pointer-relative load and a branch to the unwind, with no out-of-line call: - bit 0 mirrors "active cell kind != NONE". Only the owner thread writes it, at every change of its active cell (throws, rethrows, take/clear, fiber switches, payload moves, the termination sentinel). The uncaught printer now settles it after resetting the cell. - bit 1 requests the context's stop. worker.terminate() sets it through a pointer the worker registers under a mutex while its script runs (a terminate before registration is found through the cancel flag), and scr_context_stop sets it for process.exit() and fatal paths. The stop bit counts as the stop signal in scr_context_quiet/checkpoint. A set stop bit unwinds while the active cell may still be clear, so every consumer of an unwind installs the TERMINATE sentinel before inspecting the cell: emitted try regions test the bit before entering a handler and call scr_context_checkpoint, and fiber completion, the uncaught handlers and exit listeners checkpoint instead of only testing scr_context_stopping. The runtime's out-of-line scr_exc_pending already checkpoints. Darwin and Windows keep the alert word (a Darwin thread-local access is a call). New corpus programs pin terminate() inside catch and finally bodies, with exceptions stashed or propagating, inside generator and async bodies and runtime callbacks, and before the worker starts, plus every way an exception is raised, moved, consumed or discarded on the main thread and in a worker, each followed by a may-throw call that must not unwind.
scriptc
scriptc compiles TypeScript and JavaScript to native executables and WebAssembly. It uses TypeScript's type information to compile supported code to native instructions. Static builds run without Node.js or a JavaScript engine.
For npm dependencies and any-typed code, enable --dynamic to include the embedded quickjs-ng engine. Dependency JavaScript is included at build time.
scriptc is experimental and supports a subset of JavaScript, TypeScript, and Node.js APIs. See the limitations and Node.js compatibility reference before using it for an existing project.
Installation
Install with Node.js 24 or later and npm:
$ npm install -g scriptc
The installed compiler runs natively on supported macOS, Linux, and Windows hosts. Native compilation and execution do not require Node.js. Standalone compiler archives are also available from GitHub Releases.
Executable builds require a platform linker and SDK/sysroot. See the quickstart for prerequisites and platform support for host requirements, cross-compilation, and WebAssembly.
Build a program
Create hello.ts:
const who = process.argv.length > 2 ? process.argv[2] : "world";
console.log(`hello, ${who}`);
Compile and run it in one step:
$ scriptc run hello.ts
hello, world
Or write a standalone executable:
$ scriptc build hello.ts -o hello
$ ./hello scriptc
hello, scriptc
These examples use POSIX shell syntax. On Windows, build hello.exe and run it with .\hello.exe.
Use scriptc coverage hello.ts to check which operations compile statically, require dynamic execution, or are unsupported. See coverage reports for details.
Use Node APIs
Supported Node APIs compile to the native runtime. For example, server.ts:
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url }));
});
server.listen(8080, () => {
console.log("listening on http://localhost:8080");
});
$ scriptc build server.ts -o server
$ ./server
listening on http://localhost:8080
Use npm packages
Pass --dynamic to embed an npm package's JavaScript in the executable. The result does not read node_modules at runtime.
Create cli.ts:
import pc from "picocolors";
console.log(pc.green("hello from scriptc"));
$ npm install picocolors
$ scriptc build cli.ts --dynamic -o cli
$ ./cli
hello from scriptc
Enabling --dynamic does not add support for unsupported statically typed calls. See npm dependencies for package resolution and the boundary between static and dynamic code.
Documentation
- Quickstart: installation and your first executable.
- CLI reference: commands, options, and output formats.
- Native program objects: use compiler output in external builds.
- Native FFI: call C ABI functions from compiled programs.
- WebAssembly modules: build and embed WASI modules.
- Examples: programs and integration examples in this repository.
Contributing
See CONTRIBUTING.md for source setup, testing, native artifact builds, and docs development.