@jtrb/runtime
v0.2.2
Published
A browser-based C runtime powered by WASM, with stdin/stdout/stderr I/O and a debugger
Downloads
86
Readme
@jtrb/runtime
A browser-based C++ runtime powered by WebAssembly. Compile and run C++ programs entirely in the browser, with stdin/stdout/stderr I/O and a built-in debugger.
Installation
npm install @jtrb/runtimeRequirements: Your bundler or server must set these HTTP headers, as the runtime uses
SharedArrayBufferfor stdin:Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Opener-Policy: same-originIn Next.js, add these in
next.config.js. In Vite, use thevitePluginor configure your dev server headers.
Quick start
import { Runtime } from '@jtrb/runtime';
// 1. Create the runtime (loads and compiles the WASM module)
const rt = await Runtime.create('c');
// 2. Set the virtual filesystem — the program sees these as real files
rt.fs = {
'main.c': `
#include <iostream>
int main() {
std::cout << "Hello, world!" << std::endl;
return 0;
}
`
};
// 3. Subscribe to stdout / stderr (chunks are UTF-8 bytes as Uint8Array)
const decoder = new TextDecoder();
const printChunk = (chunk: Uint8Array) => {
process.stdout.write(decoder.decode(chunk));
};
rt.stdout.on('data', printChunk);
rt.stderr.on('data', printChunk);
// 4. Perform the required DAP handshake (see below), then run
await rt.run();The DAP handshake
The runtime compiles programs in debug mode, which means a DAP (Debug Adapter Protocol) debugger is always active. The worker blocks at startup and will not execute your program until the handshake is complete.
This is a required step — skipping it will cause rt.run() to hang forever.
// Keep a monotonically increasing sequence number for DAP messages
let dapSeq = 1;
// Helper: send a DAP request and log the synchronous response
const dapSend = (command: string, args: Record<string, unknown>) => {
return rt.debugger.send({
type: 'request',
seq: dapSeq++,
command,
arguments: args
});
};
// Register the event listener BEFORE sending initialize.
// All async events from the runtime arrive here — initialized, stopped, etc.
rt.debugger.on('event', (msg) => {
const m = msg as { type?: string; event?: string };
if (m?.type === 'event' && m?.event === 'initialized') {
// The runtime is ready to receive configuration.
// Send an empty breakpoint list for now.
dapSend('setBreakpoints', { source: { path: '/main.c' }, breakpoints: [] });
dapSend('setExceptionBreakpoints', { filters: [] });
// This unblocks the worker — the program starts executing after this
dapSend('configurationDone', {});
}
});
// Kick off the handshake
dapSend('initialize', {});
// Now run — resolves when the program exits
await rt.run();Why DAP?
The runtime exposes a full DAP interface so that IDEs can add debugging features (breakpoints, stepping, variable inspection) without any special runtime changes. Everything goes through standard DAP requests and events.
What works today:
initialize/initialized/configurationDone— required startup handshakesetBreakpoints— accepted (does nothing, support coming soon)setExceptionBreakpoints— accepted (does nothing, might support later)
Coming soon:
- Breakpoint hits (
stoppedevent) - Variable inspection (
scopes,variablesrequests) - Step over / step in / step out
Wiring stdin
rt.stdin.write() accepts a UTF-8 string or a Uint8Array. The program reads via cin, scanf, read(), etc.
// Send a line of input (programs typically expect a trailing newline)
await rt.stdin.write('42\n');
// Or raw bytes:
await rt.stdin.write(new TextEncoder().encode('42\n'));For interactive terminals (e.g. xterm.js), buffer keystrokes locally and flush on Enter:
let inputBuf = '';
terminal.onData((data) => {
if (data === '\r') {
terminal.write('\r\n');
void rt.stdin.write(inputBuf + '\n');
inputBuf = '';
} else if (data === '\x7f') {
if (inputBuf.length > 0) {
inputBuf = inputBuf.slice(0, -1);
terminal.write('\b \b');
}
} else {
inputBuf += data;
terminal.write(data);
}
});Stopping a program
rt.stop(); // terminates the worker immediately; rt.run() resolvesFull API
// Create a runtime for the given language ('c' is currently supported)
const rt = await Runtime.create('c');
rt.fs; // DirNode — virtual filesystem, set before calling run()
rt.stdout; // RuntimeOutput — program stdout; use .on('data', fn) / .off('data', fn)
rt.stderr; // RuntimeOutput — program stderr
rt.stdin; // RuntimeStdin — program stdin; use .write(string | Uint8Array)
rt.debugger; // Debugger — DAP interface
rt.lang; // Lang — language this runtime was created for
rt.run(); // Promise<void> — start execution, resolves on exit
rt.stop(); // void — kill the worker
rt.debugger.send(message); // send a DAP request, returns response synchronously
rt.debugger.on('event', handler); // receive async DAP eventsExample project
See the ide/ folder for a complete Next.js example that wires up CodeMirror 6, xterm.js, and @jtrb/runtime into a working in-browser IDE. The CodeEditor.tsx component is heavily commented and walks through every step of the integration.
Contributing / building from source
Requires Cargo 1.91+, wasm-pack, and Node v22+.
cargo install wasm-pack
npm install
npm run build # wasm-pack build --target web && vite buildFor local development, use:
npm run devTo run the built-in demo:
npm link
cd demo
npm link @jtrb/runtime
npm run dev