| Crates.io | tsrun |
| lib.rs | tsrun |
| version | 0.1.17 |
| created_at | 2025-12-28 11:31:20.015792+00 |
| updated_at | 2026-01-15 19:22:07.812744+00 |
| description | A TypeScript interpreter designed for embedding in applications |
| homepage | |
| repository | |
| max_upload_size | |
| id | 2008634 |
| size | 92,902,388 |
A minimal TypeScript runtime in Rust for embedding in applications.
tsrun is designed for configuration files where you want the full benefits of TypeScript in your editor: autocompletion, type checking, and error highlighting. The runtime executes TypeScript directly without transpilation, using a register-based bytecode VM.
Why TypeScript for configs?
Note: Types are parsed for IDE support but not checked at runtime. Type annotations, interfaces, and generics are stripped during execution. Use your editor's TypeScript language server for type checking.
constructor(public x: number) syntax supportx as T and <T>x syntaxescargo install tsrun
[dependencies]
tsrun = "0.1"
cargo build --release --features c-api
# Produces: target/release/libtsrun.so (Linux), .dylib (macOS), .dll (Windows)
# Run a TypeScript file
tsrun script.ts
# With ES modules
tsrun main.ts # automatically resolves imports
use tsrun::{Interpreter, StepResult};
fn main() -> Result<(), tsrun::JsError> {
let mut interp = Interpreter::new();
// Prepare code for execution
interp.prepare("1 + 2 * 3", None)?;
// Step until completion
loop {
match interp.step()? {
StepResult::Continue => continue,
StepResult::Complete(value) => {
println!("Result: {}", value.as_number().unwrap()); // 7.0
break;
}
_ => break,
}
}
Ok(())
}
#include "tsrun.h"
int main() {
TsRunContext* ctx = tsrun_new();
tsrun_prepare(ctx, "1 + 2 * 3", NULL);
TsRunStepResult result = tsrun_run(ctx);
if (result.status == TSRUN_STEP_COMPLETE) {
printf("Result: %g\n", tsrun_get_number(result.value));
tsrun_value_free(result.value);
}
tsrun_step_result_free(&result);
tsrun_free(ctx);
return 0;
}
use tsrun::{Interpreter, StepResult, JsValue};
let mut interp = Interpreter::new();
interp.prepare(r#"
const greeting = "Hello";
const target = "World";
`${greeting}, ${target}!`
"#, None)?;
loop {
match interp.step()? {
StepResult::Continue => continue,
StepResult::Complete(value) => {
assert_eq!(value.as_str(), Some("Hello, World!"));
break;
}
_ => break,
}
}
The interpreter uses step-based execution that pauses when imports are needed:
use tsrun::{Interpreter, StepResult, ModulePath};
let mut interp = Interpreter::new();
// Main module with imports
interp.prepare(r#"
import { add } from "./math.ts";
export const result = add(2, 3);
"#, Some(ModulePath::new("/main.ts")))?;
loop {
match interp.step()? {
StepResult::Continue => continue,
StepResult::NeedImports(imports) => {
for import in imports {
// Load module source from filesystem, network, etc.
let source = match import.resolved_path.as_str() {
"/math.ts" => "export function add(a: number, b: number) { return a + b; }",
_ => panic!("Unknown module"),
};
interp.provide_module(import.resolved_path, source)?;
}
}
StepResult::Complete(value) => {
println!("Done: {}", value);
break;
}
_ => break,
}
}
use tsrun::{Interpreter, api, JsValue};
use serde_json::json;
let mut interp = Interpreter::new();
let guard = api::create_guard(&interp);
// Create values from JSON
let user = api::create_from_json(&mut interp, &guard, &json!({
"name": "Alice",
"age": 30,
"tags": ["admin", "developer"]
}))?;
// Read properties
let name = api::get_property(&user, "name")?;
assert_eq!(name.as_str(), Some("Alice"));
// Modify properties
api::set_property(&user, "email", JsValue::from("alice@example.com"))?;
// Call methods
let tags = api::get_property(&user, "tags")?;
let joined = api::call_method(&mut interp, &guard, &tags, "join", &[JsValue::from(", ")])?;
assert_eq!(joined.as_str(), Some("admin, developer"));
For async operations, the interpreter pauses with pending "orders" that the host fulfills. The host examines the order payload to determine what operation to perform:
use tsrun::{Interpreter, StepResult, OrderResponse, RuntimeValue, JsError, api};
let mut interp = Interpreter::new();
// Code that uses the order system for async I/O
interp.prepare(r#"
import { order } from "tsrun:host";
const response = await order({ type: "fetch", url: "/api/users" });
response.data
"#, Some("/main.ts".into()))?;
loop {
match interp.step()? {
StepResult::Continue => continue,
StepResult::Suspended { pending, cancelled } => {
let mut responses = Vec::new();
for order in pending {
// Extract order type from payload to dispatch
let order_type = api::get_property(order.payload.value(), "type")
.ok()
.and_then(|v| v.as_str().map(String::from));
let result = match order_type.as_deref() {
Some("fetch") => {
let url = api::get_property(order.payload.value(), "url")
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
// In real code: perform actual HTTP fetch here
api::create_response_object(&mut interp, &serde_json::json!({
"data": [{"id": 1, "name": "Alice"}],
"url": url
}))
}
Some("timeout") => {
// In real code: sleep for the requested duration
Ok(RuntimeValue::unguarded(tsrun::JsValue::Undefined))
}
_ => Err(JsError::type_error("Unknown order type")),
};
responses.push(OrderResponse {
id: order.id,
result,
});
}
interp.fulfill_orders(responses);
}
StepResult::Complete(value) => {
println!("Result: {}", value);
break;
}
_ => break,
}
}
use tsrun::{Interpreter, StepResult, api};
let mut interp = Interpreter::new();
interp.prepare(r#"
export const VERSION = "1.0.0";
export const CONFIG = { debug: true };
"#, Some("/config.ts".into()))?;
// Run to completion
loop {
match interp.step()? {
StepResult::Continue => continue,
StepResult::Complete(_) => break,
_ => break,
}
}
// Access exports
let version = api::get_export(&interp, "VERSION");
assert_eq!(version.unwrap().as_str(), Some("1.0.0"));
let export_names = api::get_export_names(&interp);
assert!(export_names.contains(&"VERSION".to_string()));
assert!(export_names.contains(&"CONFIG".to_string()));
See examples/c-embedding/ for complete examples.
The interpreter compiles to WebAssembly with a C-style FFI, enabling use across multiple runtimes: browsers, Node.js, Go (via wazero), and others.
cd examples/wasm-playground
./build.sh # Build WASM module
./build.sh --test # Build and run browser tests
import "github.com/example/tsrun-go/tsrun"
ctx := context.Background()
rt, _ := tsrun.New(ctx, tsrun.ConsoleOption(func(level tsrun.ConsoleLevel, msg string) {
fmt.Printf("[%s] %s\n", level, msg)
}))
defer rt.Close(ctx)
interp, _ := rt.NewContext(ctx)
defer interp.Free(ctx)
interp.Prepare(ctx, `console.log("Hello from Go!")`, "/main.ts")
result, _ := interp.Run(ctx)
See examples/go-wazero/ for complete examples including async operations, modules, and native functions.
import init, { TsRunner, STEP_CONTINUE, STEP_COMPLETE, STEP_ERROR } from './pkg/tsrun.js';
await init();
const runner = new TsRunner();
// Status constants (functions that return values)
const Status = {
CONTINUE: STEP_CONTINUE(),
COMPLETE: STEP_COMPLETE(),
ERROR: STEP_ERROR()
};
// Prepare and run
runner.prepare('console.log("Hello!"); 1 + 2', 'script.ts');
while (true) {
const result = runner.step();
// Display console output
for (const entry of result.console_output) {
console.log(`[${entry.level}] ${entry.message}`);
}
if (result.status === Status.COMPLETE) {
console.log('Result:', result.value);
break;
} else if (result.status === Status.ERROR) {
console.error('Error:', result.error);
break;
}
}
TypeScript code can use order for async operations that the JavaScript host fulfills:
import { order } from "tsrun:host";
function fetch(url: string): Promise<any> {
return order({ type: "fetch", url });
}
const [user, posts] = await Promise.all([
fetch("/api/users/1"),
fetch("/api/posts")
]);
// In the step loop, handle STEP_SUSPENDED status:
if (result.status === STEP_SUSPENDED()) {
const orderIds = runner.get_pending_order_ids();
for (const orderId of orderIds) {
// Read payload now (before fulfilling)
const payload = runner.get_order_payload(orderId);
// Create an unresolved Promise - enables concurrent execution
const promiseHandle = runner.create_promise();
// Fulfill the order immediately with this Promise
runner.set_order_result(orderId, promiseHandle);
// Schedule async work based on order type - all run concurrently!
if (payload.type === "fetch") {
fetch(payload.url)
.then(r => r.json())
.then(data => runner.resolve_promise(promiseHandle, toHandle(data)));
} else if (payload.type === "timeout") {
setTimeout(() => {
runner.resolve_promise(promiseHandle, runner.create_undefined());
}, payload.ms);
} else {
runner.reject_promise(promiseHandle, `Unknown order type: ${payload.type}`);
}
}
runner.commit_fulfillments();
}
Register C functions callable from JavaScript:
static TsRunValue* native_add(TsRunContext* ctx, TsRunValue* this_arg,
TsRunValue** args, size_t argc,
void* userdata, const char** error_out) {
double a = tsrun_get_number(args[0]);
double b = tsrun_get_number(args[1]);
return tsrun_number(ctx, a + b);
}
// Register
TsRunValueResult fn = tsrun_native_function(ctx, "add", native_add, 2, NULL);
tsrun_set_global(ctx, "add", fn.value);
// Use from JS: add(10, 20) -> 30
TsRunStepResult result = tsrun_run(ctx);
while (result.status == TSRUN_STEP_NEED_IMPORTS) {
for (size_t i = 0; i < result.import_count; i++) {
const char* path = result.imports[i].resolved_path;
const char* source = load_from_filesystem(path);
tsrun_provide_module(ctx, path, source);
}
tsrun_step_result_free(&result);
result = tsrun_run(ctx);
}
| Flag | Description | Default |
|---|---|---|
std |
Full standard library support | Yes |
regex |
Regular expression support (requires std) |
Yes |
console |
Console.log builtin | Yes |
c-api |
C FFI for embedding (requires std) |
No |
wasm |
WebAssembly target support | No |
# Minimal build without regex
[dependencies]
tsrun = { version = "0.1", default-features = false, features = ["std"] }
# With C API
[dependencies]
tsrun = { version = "0.1", features = ["c-api"] }
# Build for WASM
cargo build --target wasm32-unknown-unknown --features wasm --no-default-features
Note: The type annotations in these examples provide IDE autocompletion and editor-based type checking, but tsrun does not validate types at runtime. Passing a wrong type will not throw an error - it will simply execute with whatever value is provided.
Generate type-safe Kubernetes manifests with IDE autocompletion:
interface DeploymentConfig {
name: string;
image: string;
replicas: number;
port: number;
}
function deployment(config: DeploymentConfig) {
return {
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: config.name },
spec: {
replicas: config.replicas,
selector: { matchLabels: { app: config.name } },
template: {
metadata: { labels: { app: config.name } },
spec: {
containers: [{
name: config.name,
image: config.image,
ports: [{ containerPort: config.port }]
}]
}
}
}
};
}
deployment({ name: "api", image: "myapp:v1.2.0", replicas: 3, port: 8080 })
Define game items with enums and computed loot tables:
enum Rarity { Common, Rare, Epic, Legendary }
interface Item {
name: string;
rarity: Rarity;
basePrice: number;
effects?: string[];
}
function createLootTable(items: Item[]) {
return items.map(item => ({
...item,
dropWeight: item.rarity === Rarity.Legendary ? 1 :
item.rarity === Rarity.Epic ? 5 :
item.rarity === Rarity.Rare ? 15 : 50,
sellPrice: Math.floor(item.basePrice * (1 + item.rarity * 0.5))
}));
}
createLootTable([
{ name: "Iron Sword", rarity: Rarity.Common, basePrice: 100 },
{ name: "Dragon Scale", rarity: Rarity.Legendary, basePrice: 5000,
effects: ["Fire Resistance", "+50 Defense"] }
])
// Result: [{ dropWeight: 50, sellPrice: 100, ... }, { dropWeight: 1, sellPrice: 12500, ... }]
Configure REST endpoints with typed middleware and rate limits:
interface Route {
method: "GET" | "POST" | "PUT" | "DELETE";
path: string;
handler: string;
middleware?: string[];
rateLimit?: { requests: number; window: string };
}
const routes: Route[] = [
{
method: "GET",
path: "/users/:id",
handler: "users::get",
middleware: ["auth", "cache"]
},
{
method: "POST",
path: "/users",
handler: "users::create",
middleware: ["auth", "validate"],
rateLimit: { requests: 10, window: "1m" }
},
{
method: "DELETE",
path: "/users/:id",
handler: "users::delete",
middleware: ["auth", "admin"]
}
];
routes
Create plugin-based build configurations like webpack or vite:
interface Plugin {
name: string;
options?: Record<string, any>;
}
interface BuildConfig {
entry: string;
output: { path: string; filename: string };
plugins: Plugin[];
minify: boolean;
}
const config: BuildConfig = {
entry: "./src/index.ts",
output: {
path: "./dist",
filename: "[name].[hash].js"
},
plugins: [
{ name: "typescript", options: { target: "ES2022" } },
{ name: "minify", options: { dropConsole: true } },
{ name: "bundle-analyzer" }
],
minify: true
};
config
Define form validation schemas with discriminated unions:
type Rule =
| { type: "required" }
| { type: "minLength"; value: number }
| { type: "maxLength"; value: number }
| { type: "pattern"; regex: string; message: string }
| { type: "email" };
interface FieldSchema {
name: string;
label: string;
rules: Rule[];
}
const userSchema: FieldSchema[] = [
{
name: "email",
label: "Email Address",
rules: [
{ type: "required" },
{ type: "email" }
]
},
{
name: "password",
label: "Password",
rules: [
{ type: "required" },
{ type: "minLength", value: 8 },
{ type: "pattern", regex: "[A-Z]", message: "Must contain uppercase" }
]
}
];
userSchema
# Run all tests
cargo test
# Run specific test
cargo test test_array_map
# Run with output
cargo test -- --nocapture
git submodule update --init --depth 1
cargo build --release --bin test262-runner
./target/release/test262-runner --strict-only language/types
The interpreter uses a register-based bytecode VM:
Release builds use LTO and are optimized for size (opt-level = "z").
MIT