Overview
Perry-WIT compiles a static subset of TypeScript ahead of time into WASI 0.3 WebAssembly components. It lowers TypeScript through typed SSA directly to WebAssembly instructions and links only referenced functions from its precompiled runtime and standard libraries, so a component exporting a string function is about 3 KB. These #![no_std] modules ship inside the compiler, so compiling needs no Rust or LLVM toolchain, whether you use the CLI, the Rust API or the compiler as a sandboxed WASI 0.2 component (see Embedding and Architecture).
Programs are ordinary TypeScript that tsc checks against types generated from the WIT world, and modules without WIT imports also run under Node.js. A module can be a CLI command with top-level await, export functions or an HTTP handler, or do both, in which case its top-level code initializes state once before the first export runs (see Authoring). WIT integer types refine number: a u8 parameter is an integer in 0..=255. Forward range and backward bitwidth analysis turn in-range arithmetic into i32 and i64 instructions and drop proven bounds checks, while other numbers keep IEEE 754 f64 semantics (see Semantics and Standard Library).
Memory stays flat across calls: the test suite runs 1,000 calls with a nested record argument within two 64 KiB pages. When no export can suspend, keep state, hold host resources or allocate without bound, each call allocates from a region freed in bulk when it returns. Otherwise the runtime uses precise mark-and-sweep collection at compiled safe points. async functions compile to stackless state machines without a guest microtask queue, so await, streams and Promise combinators suspend on WASI 0.3 waitables and resume from host callbacks. Conformance contracts run the same programs under Node.js and Wasmtime, with fuel and memory limits, and compare the results (see Conformance).
Authoring
A component is a TypeScript entry module plus a WIT world. Perry-WIT builds typed components that export WIT functions, CLI commands that run top-level code, and HTTP handlers.
Typed Components
The WIT world declares the exports.
Each kebab-case WIT export binds to the matching camelCase TypeScript export; remaining module functions stay internal.
The starter template contains this pair:
package example:task@0.1.0;
world task {
export run-task: func(input: string) -> string;
}
/**
* Example task component conforming to WIT world `task`.
* Exported functions map directly to the world contract.
*/
export function runTask(input: string): string {
return "[task] Processed input: " + input;
}
perry-wit src/index.ts --wit wit --world task -o dist/task.wasm
wasmtime run -C cache=n -S p3=y -W component-model-async=y --invoke 'run-task("hello")' dist/task.wasm
- An
async funcin WIT binds to anasync functionreturning aPromise. - A
result<T, E>export returnsTdirectly, and a thrown exception becomeserr. Returning{ ok: true, value }or{ ok: false, error }also works. --core-onlyemits the standalone core WebAssembly module.--keep-namespreserves function names in the customnamesection for guest profiling.- WASI 0.3 packages resolve from
WASI_WIT_PATH. Packages inwit/depstake precedence over them.
Generated Types
perry-wit gen-types writes .d.ts declarations for a worldβs imports and exports, plus a tsconfig.json that checks the entry module against them:
perry-wit gen-types --wit wit --world task --entry src/index.ts -o .perry/types
tsc --noEmit -p .perry/types
WIT integer types appear as number and bigint. See Semantics for how the compiler uses them.
Commands
A world that includes wasi:cli/command@0.3.0 turns the moduleβs top-level statements, including top-level await, into the wasi:cli/run export:
package example:cli@0.1.0;
world app {
include wasi:cli/command@0.3.0;
}
import { readFile } from "node:fs/promises";
const text = await readFile("input.json", "utf8");
const items: number[] = JSON.parse(text);
console.log("sum:", items.reduce((a, b) => a + b, 0));
if (items.length === 0) process.exitCode = 1;
perry-wit src/main.ts --wit wit --world app -o app.wasm
wasmtime run -S p3=y -W component-model-async=y --dir . app.wasm
console writes to WASI standard output and error, process.env reads the WASI environment, and process.exitCode or process.exit(code) sets the exit status.
If the module also exports functions, its top-level statements initialize the module once instead, and wasi:cli/run and every export wait for that initialization.
Top-level statements that start asynchronous work without awaiting it are then a compile error.
HTTP Handlers
A module implementing the perry:http-handler world receives a request record and returns a response record.
The compiler wraps it in an adapter, so the component exports wasi:http/handler@0.3.0.
The world ships in wit/http-handler:
package perry:http-handler;
interface types {
variant method { get, head, post, put, delete, connect, options, trace, patch, other(string) }
variant scheme { http, https, other(string) }
record request {
method: method,
scheme: option<scheme>,
authority: option<string>,
path-with-query: option<string>,
headers: list<tuple<string, list<u8>>>,
body: list<u8>,
}
record response { status: u16, headers: list<tuple<string, list<u8>>>, body: list<u8> }
}
world handler {
use types.{request, response};
export handle: func(request: request) -> response;
}
import type { Request, Response } from "./handler-types/world";
const encoder = new TextEncoder();
export function handle(request: Request): Response {
const path = request.pathWithQuery ?? "/";
return {
status: 200,
headers: [["content-type", encoder.encode("text/plain")]],
body: encoder.encode(`${request.method.tag} ${path}\n`),
};
}
perry-wit gen-types --wit wit/http-handler --world handler -o handler-types --no-tsconfig
perry-wit handler.ts --wit wit/http-handler --world handler -o handler.wasm
wasmtime serve -S p3=y -W component-model-async=y handler.wasm
The adapter reads the whole request body before calling handle.
An uncaught exception becomes a 500 response, and the instance keeps serving later requests.
New Projects
The flake template creates a standalone project. Its development shell generates .perry/types on entry, and nix build type-checks and compiles the component:
mkdir my-component && cd my-component
nix flake init -t github:ahirner/perry-wit
nix develop
nix build
After editing wit/*.wit inside the shell, run perry-wit gen-types --no-tsconfig --wit wit --entry src/index.ts again before tsc --noEmit -p .perry/types.
Embedding
The compiler ships as the perry_wit Rust crate and as a standalone WASI 0.2 component.
The runtime and standard libraries are precompiled and embedded in the compiler binary, so compilation runs entirely in memory.
Rust
perry_wit::api::compile compiles an entry module, auxiliary ESM modules and WIT packages from byte buffers:
use perry_wit::api::{self, Options, Program, Wit};
let program = Program {
source: br#"
export function authorize(token: string): boolean {
return token.startsWith("Bearer ") && token.length > 16;
}
"#
.as_slice()
.into(),
modules: vec![],
};
let wit = Wit {
application: br#"
package example:auth@0.1.0;
world filter {
export authorize: func(token: string) -> bool;
}
"#
.as_slice()
.into(),
dependencies: vec![],
};
let compiled = api::compile(&program, &wit, &Options::default())?;
assert!(!compiled.core.is_empty());
assert!(compiled.component.is_some_and(|component| !component.bytes.is_empty()));
Errors are CompileError values holding PerryDiagnostic records with a source span and a DiagnosticCode.
WebAssembly Component
perry-wit.wasm is a WASI 0.2 component that exports this interface (compiler.wit) next to a wasi:cli/run command:
package perry:compiler@0.1.2;
interface api {
/// Optional diagnostic name; for an entrypoint, also its logical module identity.
/// Anonymous single-source compilation needs no name. Names never access host files.
record source {
name: option<string>,
contents: list<u8>,
}
/// Dependency modules require package-relative logical identifiers for import resolution.
record module {
name: string,
contents: list<u8>,
}
record program {
source: source,
modules: list<module>,
}
variant wit-package {
sources(list<source>),
encoded(list<u8>),
}
record wit-input {
application: wit-package,
dependencies: list<wit-package>,
}
record compile-options {
%world: option<string>,
/// Omit component wrapping, retaining the selected WIT world's canonical ABI.
core-only: bool,
}
record component-output {
bytes: list<u8>,
stripped: list<u8>,
}
record compiled {
core: list<u8>,
component: option<component-output>,
allocation-report: string,
}
record artifact {
name: string,
contents: list<u8>,
}
enum diagnostic-code {
parse-error,
unsupported-operator,
unsupported-spread,
missing-type-annotation,
unsupported-type,
unsupported-expression,
incompatible-record-layout,
missing-required-field,
absence-mismatch,
interface-mismatch,
export-mismatch,
import-signature-mismatch,
module-resolution,
invalid-input,
wit-resolution,
codegen-failure,
missing-error-channel,
io,
internal-compiler-error,
}
enum diagnostic-severity { error, warning, info }
record source-span {
file: string,
start-line: u32,
start-column: u32,
end-line: u32,
end-column: u32,
start-byte: u64,
end-byte: u64,
}
record diagnostic {
code: diagnostic-code,
severity: diagnostic-severity,
message: string,
span: option<source-span>,
notes: list<string>,
help: option<string>,
}
compile: func(program: program, wit: wit-input, options: compile-options) -> result<compiled, list<diagnostic>>;
/// Raw TypeScript exports without WIT. This differs from compile with core-only,
/// which still checks a WIT world and emits its canonical ABI.
compile-core: func(program: program) -> result<list<u8>, list<diagnostic>>;
generate-sdk: func(wit: wit-input, %world: option<string>) -> result<list<artifact>, list<diagnostic>>;
}
world compiler {
export api;
}
The playground runs it in a Web Worker.
Because the api export operates on in-memory buffers, a server can instantiate it with empty filesystem preopens, fuel limits and memory caps to compile untrusted TypeScript.
Semantics
Perry-WIT accepts a statically typed subset of TypeScript. Names, object layouts and host capabilities are resolved at compile time, and a construct outside the subset is a compile error with a source location.
Objects
- Interfaces, type aliases and object literals compile to records with fixed field offsets. Types with the same fields share a layout; classes and recursive types get their own.
- Records keep their declared fields, and prototype mutation is rejected.
Record<string, V>dictionaries accept new keys, anddeleteremoves dictionary keys and optional properties. - An empty object, including
const object: any = {}, becomes a dictionary when every write stores the same value type. Writes through local aliases, branches and loops count. An object that escapes, or whose writes store different types, needs a type annotation. - Unions of literals, records and WIT variants narrow through
typeof, equality and discriminant properties.
Arrays
- Arrays (
T[]) are dense. Elements share one type or a finite union of numbers, strings, booleans and one plain record shape. - Reading an index outside the array, or a fractional index, yields
undefinedwhen the element type admits it. - Writing at
lengthappends. Writing at any other index outside0..lengththrowsRangeError. - Fresh empty arrays (
[], includingany[]) infer their element type frompushcalls and numeric-indexed writes across branches, loops, non-reassigned local aliases and helper returns, widening string literals tostringand preserving nested record and array layouts. Inferred arrays support.length, index reads,Object.keys,Array.isArrayandJSON.stringifywithout a replacer; other uses, unresolved writes or no writes require an explicit element type.
Strings
- Strings are UTF-8. Length and indexing count Unicode scalar values and agree with Node.js for text in the Basic Multilingual Plane. Only
charCodeAtindexes UTF-16 code units, so past a character outside that plane its positions differ fromlengthandindexOf:'πx'.lengthis 2, yet'πx'.charCodeAt(2)reads thex. String.fromCharCodeand lone surrogate escapes (\uD800to\uDFFF) are compile errors. UseString.fromCodePoint.- Text arriving through the Canonical ABI,
JSON.parseorTextDecoderis validated as UTF-8.
Regular Expressions
- Regular expressions require compile-time constant patterns and flags (regex literals or
RegExpcalls with string literal arguments) and compile to search bytecode. test,exec,match,search,split,replaceandreplaceAllsupport named capture groups (.groupsonexecand non-globalmatch),$<name>replacement templates and synchronous replacement callbacks.
JSON
JSON.parsedecodes into the expected static target type (retaining extra object properties for serialization) or into a dynamic JSON value that is validated when assigned to a typed binding. Reviver callbacks are compile errors.JSON.stringifysupports synchronous replacer callbacks and property-allowlist arrays. The third indentation argument is a compile error.
Dates
Dateoperates in the UTC time zone.Date.parseandnew Date(string)accept ISO 8601 year, year-month, date and date-time strings (offset-free strings use UTC). Malformed ISO strings returnNaN, whereas non-ISO legacy date formats throwRangeError.Temporal.Instant,Temporal.PlainDateandTemporal.PlainDateTimeuse the ISO 8601 calendar (calendarIdis"iso8601", and non-ISO calendar annotations are rejected).PlainDate.fromandPlainDateTime.fromaccept ISO strings, existing instances or field objects (year,month,dayand optionalhourthroughnanosecond) with{ overflow: "constrain" | "reject" }.addandsubtractaccept{ years?, months?, weeks?, days? }with optionaloverflow, anduntilandsinceacceptlargestUnit("day","week","month","year","auto").
Exceptions
Error,TypeError,RangeError,ReferenceErrorandSyntaxErrorkeepname,messageandcausethroughtry,catchandfinally.- A failed implicit check produces a fresh error object when caught. Successful checks bypass the exception runtime; explicitly thrown values retain their identity.
- An export declared with a WIT
result<T, E>turns an uncaught exception intoerr. Without aresult, an uncaught exception traps, and a trap does not run pendingfinallyblocks.
Async
- An
asyncfunction runs eagerly until its first suspension. Awaiting an already settled promise continues inline. - There is no microtask queue in the guest. Waiting tasks resume from WASI 0.3 callbacks, so the order of continuations for different host events follows the host, not Node.js. Observers of the same promise resume in registration order.
Promise.raceleaves the losing tasks running. They must settle before the export returns, or the call traps.ReadableStreamsupports default readers (getReader()),for awaitloops, BYOB readers (getReader({ mode: "byob" })withread(view, { min })into a caller-providedUint8Array) and explicit cancellation.for awaitover a byte stream releases the reader lock and cancels the stream when the loop exits early. Releasing a reader lock rejects pending reads but keeps buffered chunks for the next reader.
Numbers
WIT provides fixed-width integers (u8..u64, s8..s64) alongside f32 and f64, while TypeScript provides number (IEEE 754 f64) and bigint.
| WIT type | .d.ts type | Guest representation |
|---|---|---|
u8, u16, u32, s8, s16, s32 | number | Refined number (i32, i64 or f64) |
u64, s64 | bigint | i64 at boundaries, arbitrary-width bigint in guest |
f32, f64 | number | f64 |
Keeping u8..s32 as number in generated .d.ts files lets guest code use standard TypeScript arithmetic while giving the compiler range information to emit integer instructions.
Refinement Analysis
Each numeric SSA value carries an optional integer type (width, signedness) and a value interval [min, max].
ProgramFacts propagates ranges forward through arithmetic, branches and calls (including recursion), and propagates required register widths and observation requirements backward through the same worklist to a fixed point [1].
- Register selection: Refined integers whose interval fits in 32 bits lower to
i32. Wider refined integers lower toi64. Unrefined values stay inf64. - Bitwise results:
&,|,^,~,<<,>>andMath.imulproduces32values and>>>producesu32, so they lower toi32instructions even when their operands are unrefinedf64values, which convert withToInt32. When every use needs only the low bits, refined arithmetic feeding them, such as(a * b) | 0onu32values, computes ini32. This deliberately preserves integer wrapping before conversion: ata = b = 4294967295, the result is1, whereas JavaScript rounds the product before converting and returns0. A use that observes the full value, such as arithmetic or a comparison, requires a wider register when the result does not fit; a bitwise consumer then truncates at its own use. - Register-width intermediates: Required widths stay at register size (32 or 64 bits) during computation. Narrowing to 8 or 16 bits happens only at the WIT boundary.
- Literals: An integer literal adopts a required width only when its value fits. Larger literals stay in
f64until converted at the boundary. - Loops: At a join point, an interval may grow once before widening to the bounds of its register type, or to unbounded endpoints for unrefined numbers. A specialized loop counter wraps at 32 or 64 bits on overflow.
- Guarded ranges: Dominating comparisons of integral operands refine index ranges through the numeric solverβs shared arithmetic and integer bounds. Derived arithmetic and cast ranges must fit the result type. Addition and subtraction preserve one-sided bounds; arithmetic that may produce
NaNstays unknown. - Bounds checks: A proof of
0 <= i < lengthpermits unchecked array reads and stores, andUint8Arrayreads. Array proofs account for mutations through aliases. Byte-view proofs use length comparisons or constructor, copy and subarray extents, and require the view to stay attached on every path to the access. Calls, awaits, foreign operations and BYOB reads invalidate these byte-view proofs.
Type Join
Combining two operands takes the least upper bound of their refinements:
| Operands | Join | Reason |
|---|---|---|
u8, u16 | u16 | Wider type of the same signedness. |
u8, s8 | s16 | Holds both 0..=255 and -128..=127. |
u16, s16 | s32 | Next signed width up. |
u32, s32 | s64 | Next signed width up. |
u8, s32 | s32 | Signed operand is already wider. |
integer, unrefined number | f64 | Unrefined number may be fractional. |
Generated Code
package example:numerics@0.1.0;
world numerics {
export combine-delta: func(base: u8, delta: s8) -> s16;
export scale-bytes: func(count: u32, stride: u32) -> u32;
}
export function combineDelta(base: number, delta: number): number {
return base + delta;
}
export function scaleBytes(count: number, stride: number): number {
return Math.trunc((count * stride) / 1024);
}
Compiling this pair with --core-only emits:
(module
(type (;0;) (func (param i32 i32) (result i32)))
(export "scale-bytes" (func $__perry_integer_3_scaleBytes))
(export "combine-delta" (func $wit_combine_delta))
(func $__perry_integer_1_combineDelta (;0;) (type 0) (param $v35 i32) (param $v36 i32) (result i32)
local.get $v35
local.get $v36
i32.add
i32.extend16_s
)
(func $__perry_integer_3_scaleBytes (;1;) (type 0) (param $v39 i32) (param $v40 i32) (result i32)
(local $v47 f64)
local.get $v39
i64.extend_i32_u
local.get $v40
i64.extend_i32_u
i64.mul
f64.convert_i64_u
f64.const 0x1p+10 (;=1024;)
f64.div
f64.trunc
local.tee $v47
local.get $v47
f64.const 0x1p-32 (;=0.00000000023283064365386963;)
f64.mul
f64.trunc
f64.const 0x1p+32 (;=4294967296;)
f64.mul
f64.sub
i64.trunc_sat_f64_s
i32.wrap_i64
)
(func $wit_combine_delta (;2;) (type 0) (param $v56 i32) (param $v57 i32) (result i32)
local.get $v56
i32.const 255
i32.and
local.get $v57
i32.extend8_s
call $__perry_integer_1_combineDelta
)
)
combine-delta: The export adapter$wit_combine_deltamasksbase(i32.and) and sign-extendsdelta(i32.extend8_s). The sum lies in-128..=382, so the body adds directly ini32.scale-bytes: The product of twou32values is below 264, soi64.mulcomputes it exactly. Converting that exact product tof64rounds once, matching JavaScript floating-point multiplication./is floating-point division, so the division stays inf64.
Boundary Conversion
- Incoming
u8..s16: The export adapter masksu8andu16arguments and sign-extendss8ands16arguments. - Outgoing
u8..s32: Values convert with JavaScript typed-array semantics (ToUint8throughToInt32), truncating toward zero, mappingNaNandΒ±Infinityto0, and wrapping modulo 2N. Integer andf64code paths produce identical results. u64,s64(bigint): Out-of-rangebigintreturns trap at the boundary. Inside guest code,+,-, unary-, comparisons andtoString()support arbitrary width.*,/,%andBigInt(number)require 64-bit operands.
package example:boundaries@0.1.0;
world boundaries {
export scale8: func(a: u8, b: u8) -> u8;
export to-u8: func(x: f64) -> u8;
export to-s32: func(x: f64) -> s32;
export dec-u64: func(x: u64) -> u64;
}
export function scale8(a: number, b: number): number { return a * b; }
export function toU8(x: number): number { return x; }
export function toS32(x: number): number { return x; }
export function decU64(x: bigint): bigint { return x - 1n; }
| Call | Result | JavaScript |
|---|---|---|
scale8(200, 2) | 144 | new Uint8Array([400])[0] |
to-u8(-1) | 255 | new Uint8Array([-1])[0] |
to-s32(3000000000) | -1294967296 | 3000000000 | 0 |
dec-u64(1) | 0 | 1n - 1n |
dec-u64(0) | trap | -1n is outside u64 |
References
- M. Stephenson, J. Babb, S. Amarasinghe. Bitwidth Analysis with Application to Silicon Compilation. ACM PLDI, 2000.
Standard Library
| Globals | |
|---|---|
| Number | Number.isFinite, Number.isInteger, Number.isNaN, Number.isSafeInteger, Number.parseInt, Number.parseFloat, parseInt, parseFloat, isNaN, Number() |
| String | String(), String.fromCodePoint |
| Array | Array.isArray, Array.from, Array.of |
| Object | Object.keys, Object.values, Object.entries, Object.is, structuredClone |
| JSON | JSON.parse, JSON.stringify |
| Errors | Error, TypeError, RangeError, ReferenceError, SyntaxError |
| Promise | Promise.resolve, Promise.all, Promise.allSettled, Promise.race |
| Time | Date, Date.now, Date.parse, Date.UTC, performance.now, Temporal.Now.instant, Temporal.Instant.from, Temporal.Instant.fromEpochMilliseconds, Temporal.PlainDate.from, Temporal.PlainDate.compare, Temporal.PlainDateTime.from |
| Binary, text | Uint8Array, TextEncoder, TextDecoder |
| Collections | Map, Set |
| Web | URL, AbortController, crypto.getRandomValues, crypto.randomUUID, fetch, Headers, Request, Response |
| Process | console.log, console.info, console.warn, console.error, console.debug, process.env, process.exitCode, process.exit |
| Modules | Functions |
|---|---|
Math | floor, ceil, trunc, abs, round, sqrt, imul, min, max, random |
node:fs/promises | readFile, writeFile, stat, readdir, mkdir, unlink, rmdir |
node:timers/promises | setTimeout |
node:stream | Writable.toWeb |
| Receiver | Members |
|---|---|
| Array | push, concat, map, filter, flatMap, find, findIndex, findLast, findLastIndex, some, every, forEach, sort, toSorted, reduce, reduceRight, join, slice, reverse, toReversed, flat, at, pop, with, includes, indexOf, lastIndexOf, keys, values, entries |
| Map | get, set, has, delete, clear, forEach, keys, values, entries |
| Set | add, has, delete, difference, intersection, union, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom, clear, forEach, keys, values, entries |
| string | charAt, charCodeAt, codePointAt, at, padStart, padEnd, slice, substring, indexOf, lastIndexOf, includes, startsWith, endsWith, trim, trimStart, trimLeft, trimEnd, trimRight, toLowerCase, toLocaleLowerCase, toUpperCase, toLocaleUpperCase, repeat, concat, normalize, localeCompare, split, replace, replaceAll, match, search, toString |
| Uint8Array | subarray, slice, fill, set |
| Date | getTime, valueOf, toISOString, toJSON, getUTCFullYear, getFullYear, getUTCMonth, getMonth, getUTCDate, getDate, getUTCDay, getDay, getUTCHours, getHours, getUTCMinutes, getMinutes, getUTCSeconds, getSeconds, getUTCMilliseconds, getMilliseconds, getTimezoneOffset, setUTCFullYear, setFullYear, setUTCMonth, setMonth, setUTCDate, setDate, setUTCHours, setHours, setUTCMinutes, setMinutes, setUTCSeconds, setSeconds, setUTCMilliseconds, setMilliseconds, setTime |
| fs.Stats | isFile, isDirectory |
| RegExp | test, exec |
| TextDecoder | decode |
| URL | toString, toJSON |
| URLSearchParams | append |
| AbortController | abort |
| number | toString |
| Temporal.Instant | toString, toJSON |
| Temporal.PlainDate | toString, toJSON, equals, until, since, add, subtract |
| Temporal.PlainDateTime | toString, toJSON, add, subtract |
| Headers | append, set, delete, get, has |
| Response | status, statusText, ok, headers, body, bodyUsed, bytes, text, json |
| Request | method, url, headers, body, bodyUsed, bytes, text, json |
| WritableStream | locked, getWriter |
| WritableStreamDefaultWriter | write, close, releaseLock |
| ReadableStream | locked, getReader, cancel |
| ReadableStreamDefaultReader | read, cancel, releaseLock |
| ReadableStreamBYOBReader | read, cancel, releaseLock |
| ReadableStream read result | done, value |
Conformance
perry-conformance tests Perry-WIT differentially against Node.js.
A contract names one capability, such as ecma.string.operations, and supplies directed cases plus a property-based generator (proptest) of TypeScript programs and inputs.
Executable cases and their metamorphic variants run as compiled components in a Wasmtime host with WASI 0.3, fuel metering and memory limits, and under Node.js, except aggregate cases with exact integers beyond 2^53, which use an expected-value model.
The runner checks that each variant matches its oracle and that transformations preserve return values, evaluation traces and standard streams. Rejection cases and their variants must report the required diagnostic.
Expected Outcomes
Each case declares what must happen:
| Outcome | Meaning |
|---|---|
EquivalentExecution | The component produces the same observable result as Node.js. |
CompilerRejection | The compiler rejects the program with a diagnostic, for constructs outside the subset. |
InjectedFaultDetection | The harness detects a deliberately broken export adapter: a wrong value, a dropped effect or a leaked host resource. These cases test the harness itself. |
BoundedGrowth | Fuel for an input of size 4n is at most 6 times the fuel for size n. This admits O(n log n) work and rejects quadratic growth (16 times). |
Expected-Pass List
passing.txt in perry-conformance lists the contracts that must pass.
A listed contract that fails is a regression, and an unlisted contract that passes fails the run until it is added, so the list only grows.
deferred.txt records contracts outside the supported domain with their exclusion rationale.
cargo run -p perry-conformance -- check
cargo run -p perry-conformance -- check --select node.fs
cargo run -p perry-conformance -- list
cargo run -p perry-conformance -- doc
--select filters by contract ID prefix. doc prints the catalog table below.
Catalog
The registry holds 63 capability contracts: 63 passing, 0 deferred, 0 pending.
ECMAScript
| Contract | Capability | Outcome | Partitions | Domain |
|---|---|---|---|---|
ecma.number.arithmetic | IEEE arithmetic and evaluation order | Equivalent execution | signed-zero, division, trace, call, record-field-update, byte-alias, checked-json, handled-throw | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.number.bitwise | Number bitwise operators, shifts and Math.imul | Equivalent execution | and, or, xor, not, left, right, unsigned-right, imul, trace, counts-and-compound-assignments | Number operands over the IEEE-754 boundary corpus; ToInt32/ToUint32 wrapping, non-finite inputs, masked shift counts, signed and unsigned results, evaluation order, compound assignments and loop-carried checksums; direct/local/branch/loop/loop-carried/inert-exception forms |
ecma.math.rounding | Numeric Math operations | Equivalent execution | floor, ceil, trunc, abs, round, abs-widened-loop | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.string.operations | String search, casing, slicing and length | Equivalent execution | unicode-and-nul, search, lower, upper, slice, char-at | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.array.dense | Dense numeric arrays preserve values and effects | Equivalent execution | index, branch-true, branch-false, first-index, array-effects | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.object.records | Record fields preserve values and effects | Equivalent execution | field-order | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.date.epoch | Date TimeClip and epoch reads | Equivalent execution | timeclip, nested-codecs | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.uint8array.conversion | Byte conversion and indexed access | Equivalent execution | wrapping, byte-input, byte-effects | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.json.roundtrip | JSON text survives direct and stored decoding | Equivalent execution | direct, stored, stored-unicode, direct-unicode | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
ecma.json.command | CLI JSON serialization and schema-guided parsing | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.command.arrays | CLI dense array manipulation and iteration | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.command.text | CLI UTF-8 string formatting, UTF-16 code units and regular expressions | Equivalent execution | standard-source | Standard CLI source; charCodeAt over ASCII/NUL, BMP and supplementary text with omitted, undefined and IEEE-754 boundary positions; exact output bytes and successful exit |
ecma.command.records | CLI closed record layouts and nested objects | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.command.numbers | CLI numeric formatting and boundary arithmetic | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.command.web | CLI Web API codecs and byte views | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.command.temporal | CLI ISO Temporal calendar and instant operations | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.promise.all | Concurrent Promise.all settlement ordering | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.date.clock | Platform wall-clock reads and ISO Date formatting | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
ecma.uint8array.set | Overlapping byte copies and atomic range rejection | Equivalent execution | empty, binary-subview | Uint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly |
ecma.module.unawaited_initialization | Explicit compiler rejection of module initialization that entry points cannot await | Compiler rejection | unawaited-async-call | Synchronous top-level statements of a module with exported functions that start asynchronous work without awaiting it |
ecma.temporal.iso | Typed ISO Instant, PlainDate and PlainDateTime values | Equivalent execution | negative-millisecond, epoch, lower-limit, upper-limit | Integral epoch milliseconds in +/-8640000000000000, ISO formatting, leap-day addition and the ISO 8601 calendarId |
ecma.promise.all_settled | Fulfilled and rejected results retain their status and payload | Equivalent execution | zero, negative | Two numeric tasks; one fulfillment and one rejection for allSettled, two already-completed contenders for race; repeated component calls and IEEE-754 inputs |
ecma.promise.race | Already-settled contenders retain input order and repeated outcomes | Equivalent execution | zero, negative | Two numeric tasks; one fulfillment and one rejection for allSettled, two already-completed contenders for race; repeated component calls and IEEE-754 inputs |
ecma.error.values | Error messages, causes and identity across promise rejection and rethrow | Equivalent execution | empty-message, nul-and-unicode | TypeError with a string message and cause; repeated awaits, all/allSettled/race, rethrow and finally; hidden built-in fields; Unicode messages of 0..32 scalar values |
ecma.array.inferred_storage | Fresh empty arrays infer uniform or finite mixed elements from visible writes | Equivalent execution, Compiler rejection | uniform-records-and-aliases, dependent-arrays, mixed-index-writes, mixed-alias, mixed-branch, returned-alias, mixed-function-write, string-key-write, conflicting-conditional-field, conflicting-nested-array, unknown-call, closure-capture, reassigned-alias, stored-alias, mutable-element-alias, no-writes, conditional-temporary, number-writes, string-writes, boolean-writes, record-writes, nested-array-writes | Up to seven writes and four aliases over numbers, strings, booleans, records and nested arrays; annotations versus alias chains, branches, loops, temporaries, helper-return chains and indexed appends; three calls compare exact serialized contents and evaluation traces; finite mixed writes and returned arrays preserve values; unresolved writes remain rejected through temporaries and control flow; conservative escape boundaries |
ecma.object.inferred_storage | Fresh empty dictionaries share array inference for visible writes and aliases | Equivalent execution, Compiler rejection | computed-keys-and-json-residue, conflicting-alias, conflicting-branch, numeric-key, unknown-call, reassigned-alias, stored-alias, returned-alias, mutable-element-alias, open-json-source, number-writes, string-writes, boolean-writes, record-writes, nested-array-writes | Uniform string-keyed writes over numbers, strings, booleans, records and arrays; annotation versus alias, branch, loop and temporary forms; computed keys, assignment values, absent reads, reserved array property names, nested typed JSON residue; unknown sources and escaping mutations remain rejected |
ecma.aggregate.specialization | Orthogonal aggregate layout specialization across recursive shapes, exact 64-bit parseInt, origin scaling, and WIT boundaries | Equivalent execution | self-recursive-wide-parseint-and-float, nested-record-option-boundary-special-values-and-overflow | Product of flat/nested/self-recursive/mutually-recursive/array-bearing records, multi-field/list/option WIT exports, float/NaN/-0.0/int-arith/wide-parseInt value sources, branch joins, shared helpers, array concat/spread, and >32-origin summarization |
ecma.collection.iterators | Live Set and Map iterators across active, paused, and abandoned lifetimes under delete compaction and clear | Equivalent execution | set-paused-clear-foreach-and-reinsert, map-entries-compaction-and-abandoned-cursor-churn | Orthogonal state-machine sequences of NewIter, partial Step and ForEach with in-body mutations, Insert/InsertBatch, Delete/DeleteBatch across the 32-tombstone compaction threshold, Clear, and abandoned cursor churn over Set.values, Set.entries, Map.keys, Map.values, and Map.entries; Node is the oracle |
Web Platform
| Contract | Capability | Outcome | Partitions | Domain |
|---|---|---|---|---|
web.textencoder.subviews | UTF-8 encoding and byte subviews | Equivalent execution | subview-bounds | Bounded typed functions, control flow, closed aggregates, byte aliases, JSON conversion and exceptions over the IEEE-754 boundary corpus; BMP strings; direct/local/branch/loop/loop-carried/inert-exception forms and overlapping timer completion schedules |
web.console.routing | Standard output and error stream routing via console | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
web.performance.monotonic | Monotonic clock reads via performance.now | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
web.crypto.random | Cryptographic random byte fill via crypto.getRandomValues | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
web.streams.byob_reader | BYOB readers transfer shared buffers and fill visible views directly | Equivalent execution | empty, byte-boundary, transfer-boundary, beyond-guest-memory, cancel-and-reuse | Uint8Array destinations with offsets, alias detachment, buffer reuse, min:8192, short EOF, locking and cancellation; three calls per instance; exact output bytes within 64 KiB guest memory |
web.fetch.bounded_body | Headers-first Fetch, bounded byte consumption, completion failure and reuse | Equivalent execution | empty, binary-exact-limit, non-success-status, overflow-cancellation, completion-error | GET loopback HTTP; 200 and 404; binary bodies, explicit body cap and producer truncation; three calls per instance |
web.fetch.bounded_byob_body | BYOB bounded Fetch consumes directly into its final buffer | Equivalent execution | empty, binary-exact-limit, non-success-status, overflow-cancellation, completion-error | GET loopback HTTP; 200 and 404; binary bodies, explicit body cap and producer truncation; three calls per instance |
web.response.bytes | Constructed response snapshot, body use and repeated consumption | Equivalent execution | empty, binary-subview | Uint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly |
web.request.bytes | Constructed request snapshot, body use and repeated consumption | Equivalent execution | empty, binary-subview | Uint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly |
web.textdecoder.streaming | Fatal UTF-8 decoding across byte boundaries | Equivalent execution | empty, bom-and-split-scalar, invalid-sequence, incomplete-sequence | Fatal UTF-8, one-byte chunks, final flush; compare successful prefix bytes and normalized decoding failure |
web.headers.multimap | Header name folding, joined duplicates and mutations | Equivalent execution | empty-value, trim-and-duplicates | ASCII values, case-insensitive names, whitespace trimming, append/get/set/has/delete |
Node.js
| Contract | Capability | Outcome | Partitions | Domain |
|---|---|---|---|---|
node.timers.promises.value | Timer result | Equivalent execution | retained-operation | Retained async operation interleaved with typed numeric evaluation; instance reused across boundary inputs |
node.timers.promises.observers | Repeated timer observation | Equivalent execution | retained-operation | Retained async operation interleaved with typed numeric evaluation; instance reused across boundary inputs |
node.fs.readFile.utf8 | UTF-8 file read across retained and dynamic Unicode payloads | Equivalent execution | retained-operation, unicode-and-concurrent-read | Retained async UTF-8 read interleaved with numeric evaluation, plus dynamic Unicode/NUL payloads of 0..48 scalar values and concurrent reads |
node.fs.writeFile.text | Text file round trip, overwrite and truncation | Equivalent execution | retained-operation, overwrite-and-truncate | Retained JSON text round trip plus sequential UTF-8 writes testing file truncation and growth across arbitrary Unicode strings |
node.fs.metadata | File stat and directory listing across dynamic files and cleanup | Equivalent execution | retained-operation, multi-file-stat-and-unlink | Retained stat/readdir on fixture plus multi-file creation, exact byte-length stat verification, directory entry count and unlink cleanup |
node.fs.readFile.bytes | Binary file read, buffer isolation and UTF-8 decoding | Equivalent execution | retained-operation, binary-buffer-isolation | Retained binary read plus arbitrary 0..256-byte payloads, returned Uint8Array mutation isolation across repeated reads and TextDecoder verification |
node.fs.writeFile.subviews | Byte view and empty file round trip across arbitrary subarray slices | Equivalent execution | retained-operation, arbitrary-subview-slice | Retained subarray write plus arbitrary 0..128-byte buffers and subarray(start, end) slices, post-write view mutation isolation and empty subview truncation |
node.fs.readFile.recovery | Repeated rejection across missing UTF-8, binary and stat calls followed by recovery | Equivalent execution | retained-operation, multi-op-rejection-recovery | Retained rejected readFile plus repeated rejection observation across UTF-8 readFile, binary readFile and stat, followed by successful write/read recovery |
node.process.environment | Environment variable reads via process.env | Equivalent execution | standard-source | Standard CLI source; exact output bytes and successful exit |
node.timers.promises.lifecycle | Start, host release, completion, cancellation, observation and reuse | Equivalent execution | settlement-and-reobserve, cancel-and-reuse | Complete sequential lifecycle traces with controlled Node/WASI clocks; no claim of exhaustive async scheduling |
node.stream.writable_to_web | Queued acknowledged writes, writer locks and closed-writer rejection | Equivalent execution | empty, binary-and-close | stdout/stderr Uint8Array writes and subviews; two queued writes, repeat observation, close, rejected released-writer use and a new stdio wrapper; exact output bytes |
node.timers.callbacks.unsupported | Explicit compiler rejection of callback timers | Compiler rejection | callback-timer | Callback timers are outside the supported source domain |
node.fs.directories | Directory creation, listing, metadata and deletion | Equivalent execution | empty-file, binary-file | One directory and binary file; exact size, one listed entry, cleanup and reuse across the numeric boundary corpus |
node.process.exit_status | Explicit failure status and immediate process exit | Equivalent execution | exit-code, immediate-exit | Exit status 1 via process.exitCode or process.exit; exact preceding output and unreachable following output |
WASI 0.3 Host
| Contract | Capability | Outcome | Partitions | Domain |
|---|---|---|---|---|
wasi.execution.detect_value_fault | Injected faults must be distinguished from the unchanged program | Fault detection | injected-fault | Value corruption, omitted effects, and retained host resources; only the component adapter is faulted |
wasi.execution.detect_trace_fault | Injected faults must be distinguished from the unchanged program | Fault detection | injected-fault | Value corruption, omitted effects, and retained host resources; only the component adapter is faulted |
wasi.execution.detect_resource_fault | Injected faults must be distinguished from the unchanged program | Fault detection | injected-fault | Value corruption, omitted effects, and retained host resources; only the component adapter is faulted |
wasi.streams.incoming_bytes | Owned WIT byte streams use Web readers and acknowledged Web writes | Equivalent execution | empty, byte-boundary, transfer-boundary, beyond-guest-memory, cancel-and-reuse | Three calls per instance; input chunks of 8192 bytes; 64 KiB guest memory; exact output bytes, locking, cancellation and completion |
wasi.http.handler | Native HTTP ABI with WIT-generated request and response declarations | Equivalent execution | empty, binary-and-duplicates, request-response-limit | PUT requests with binary bodies up to 64 KiB, duplicate headers, statuses 200/201/404; three exchanges reuse one component instance |
wasi.execution.scaling.retained_records | Pushing n records into a local array and reading them back costs O(n) guest fuel | Bounded fuel growth | n-128 | Warm-up n, n, and 4n calls on one instance; n in 32..=128 generated, 128 directed; values equal Node, fuel ratio of the last two calls at most 6 |
wasi.execution.scaling.number_push | Pushing n numbers into a dense array and summing them costs O(n) guest fuel | Bounded fuel growth | n-128 | Warm-up n, n, and 4n calls on one instance; n in 32..=128 generated, 128 directed; values equal Node, fuel ratio of the last two calls at most 6 |
wasi.execution.scaling.temporary_records | One temporary record per loop iteration costs O(n) guest fuel in total | Bounded fuel growth | n-128 | Warm-up n, n, and 4n calls on one instance; n in 32..=128 generated, 128 directed; values equal Node, fuel ratio of the last two calls at most 6 |
wasi.execution.scaling.string_append | Appending one character n times costs at most nΒ·log n guest fuel | Bounded fuel growth | n-128 | Warm-up n, n, and 4n calls on one instance; n in 32..=128 generated, 128 directed; values equal Node, fuel ratio of the last two calls at most 6 |
wasi.execution.scaling.collection_churn | Abandoned Set/Map iterators and insert-delete churn cost O(n) guest fuel | Bounded fuel growth | n-128 | Warm-up n, n, and 4n calls on one instance; n in 32..=128 generated, 128 directed; values equal Node, fuel ratio of the last two calls at most 6 |
Perry-WIT Architecture
Perry-WIT compiles static TypeScript into WASI 0.3 components through Perry HIR and typed SSA MIR built on pliron.
Pipeline
The resolved WIT world feeds five compiler stages:
βββββββββββββ¬βββ resolved WIT world ββββ¬βββββββββββββββββββββββββ¬βββββββββββββ
βΌ βΌ βΌ βΌ βΌ
TypeScript βββΊ Source βββΊ Frontend βββΊ Elaborate βββΊ Contract βββΊ Optimize βββΊ Codegen βββΊ Component βββΊ .wasm
1 2 3 4 5 6 7
| Stage | Representation | Role |
|---|---|---|
| 1. Source analysis | TypeScript AST | Binds import { f } from "pkg:iface" against the WIT world before import syntax is erased. |
| 2. Frontend | Specialized Perry HIR | Applies HIR compatibility rewrites, types WIT imports under their SDK declaration names, resolves types and specializes function instances. Preserves inferred binding origins for contextual completion. |
| 3. Elaboration | HIR to MIR | Completes types from context, fixes eager evaluation order and chooses MIR representations. Lowers try/catch/finally to shared unwinding blocks using a status-return convention rather than Wasm stack unwinding. Callers retain root ownership of pointer arguments while callees borrow parameters and return fresh allocations. |
| 4. Contract binding | pliron MIR and WIT | Binds WIT exports to MIR functions, checks their signatures and async requirements, and constrains numeric domains from the contract. |
| 5. Optimization | SSA MIR | Promotes local slots with mem2reg, specializes callees from ProgramFacts, and repeats canonicalization, SCCP, record SROA + mem2reg, DCE, and CFG simplification to fixed point (guarded by a lexicographical IR complexity measure). |
| 6. Code generation | Core Wasm (pliron-wasm) | Splits async functions into stackless ramp and step functions, structures control flow with Ramseyβs dominator-tree method [1], folds single-use values into operand-stack trees, emits Canonical ABI marshalling, and links referenced runtime and standard-library functions. |
| 7. Component encoding | WASI 0.3 component | Emits Canonical ABI [2] lift and lower adapters, cabi_realloc, cabi_post_<export>, and for each async export the callback ABI entry points [async-lift]<export>, [callback][async-lift]<export>, and finish_<export>. |
Top-level statements collect into one function: the wasi:cli/run body in a command-only module, or a one-time initializer that wasi:cli/run and every export await before running. Module bindings persist across calls.
ProgramFacts infers numeric refinements (integer type and interval with two-step loop widening), purity and allocation effects, and parameter escape provenance across functions, including recursive call cycles. The numeric analysis propagates ranges forward and bitwidth demand backward [3].
Numeric refinements select i32, i64, or f64 representations, specialize synchronous callees for the integer argument types known at each call site, and elide bounds checks on proven in-bounds array reads.
Closed non-escaping records decompose into scalar SSA values, read-only record parameters of internal synchronous functions promote to one scalar argument per field read, and JSON.parse / JSON.stringify on statically known shapes lower to specialized field decoders and schema-normalizing deep copies.
Repeated string appends reuse spare capacity in place when ownership is unique, and single characters read by indexing stay Unicode scalars through comparisons and appends.
During code generation, optional values avoid boxing by reserving sentinel encodings inside the valueβs own representation. undefined, null and an omitted property are the signaling-NaN payloads 0x7ff0000000000001 to 0x7ff0000000000003 in an f64, the addresses 0 to 2 for heap references, and 2 to 4 for booleans. WebAssembly arithmetic only produces quiet NaNs, so a computed number never matches a sentinel.
Constants rematerialize at use sites, single-use expressions in the same basic block stay on the WebAssembly operand stack when effect and trap ordering permits, and remaining values share WebAssembly locals across non-overlapping live ranges.
Workspace
The compiler workspace separates these responsibilities:
| Crate | Responsibility |
|---|---|
perry-contract | Traverses resolved WIT contracts and defines source naming conventions. |
perry-frontend | Loads source modules, reads Perry HIR, applies compatibility rewrites, resolves types, specializes instances and generates the TypeScript view of WIT. |
perry-lower | Elaborates the frontend program into eager MIR, completes types from context and chooses representations. |
perry-mir | Defines typed executable IR and its analyses. |
perry-codegen | Lowers MIR to Wasm and implements Canonical ABI operations. |
perry-wit-diagnostics | Holds source spans and portable diagnostics. |
pliron-control, pliron-wasm | Provide control-flow and Wasm dialect infrastructure. |
perry-rt-build | Builds the runtime workspaceβs artifacts for the compiler build script. |
perry-wit | Loads WIT, binds exports, links runtime artifacts and encodes components. |
The frontend returns a read-only program and metadata. Record identity and function instance specialization belong to this stage. Evaluation order, numeric refinement, effect analysis, allocation, ABI marshalling and linking belong to the compiler stages below it.
Aggregate inference
The frontend infers the contents of empty arrays and objects from the writes it can see, including writes through local aliases. Numeric indices make an array, and string keys or property names make a dictionary. An array that stores several types gets a value union, while a dictionary needs a single value type.
A dictionary is an array of key-value entries, so the MIR heap analysis treats its values like array elements. That analysis also tracks which parsed JSON records carry undeclared fields, and storing such a record in a dictionary keeps them.
A value union holds a number, string, boolean or one plain record shape, with number | string as the common case. Each value stores a tag, an eight-byte payload and a traced reference, and operators, property reads and typeof checks branch on the tag. Mixed arrays box every element, because packed numeric storage with type transitions is not implemented.
Memory
A component has one linear memory, and the compiler picks its heap strategy per module from the effect summaries of ProgramFacts.
perry-wit --explain-tiers prints the choice:
- Task regions when every reachable function allocates a bounded amount, cannot suspend or re-enter, uses no host resources, and the module has no top-level state. Each export call bump-allocates in chunks that grow from 4 KiB to 64 KiB, with a dedicated chunk per larger object, and frees them in bulk when the call ends. A synchronous export frees its region in
cabi_post_<export>, after the host has lifted the results, so arguments and results go with the region. - Precise collection otherwise. Objects up to 512 bytes allocate from non-moving 1 KiB pages across 11 size classes (powers of two and three-quarter midpoints) with mark bitmaps and lazy sweep. A larger object gets a dedicated page rounded up to a 1 KiB multiple, allocated from
talcand marked and swept like the rest. Each record type carries a static pointer-offset descriptor so byte buffers and scalar payloads are never scanned for pointers. Compiled safe points clear dead reference slots in suspended async frames, publish exact live roots, and collect mid-call as well as when the last active task ends.
Non-escaping scalar records that still require an address (such as arguments passed to a non-retaining callee) pack into a single per-function activation frame (up to 448 bytes), reusing the same slots across loop iterations and freeing the frame on return.
For streams, backward liveness analysis detects when reader.read() or writer.write() results die before the next operation on the same stream, leasing and reusing a single promise, { done, value } record, and chunk buffer in constant memory.
Strings are stored as validated UTF-8 byte sequences with cached Unicode scalar counts (precomputed in the data section for static literals) so character indexing avoids re-scanning ASCII strings.
Canonical ABI return areas are zero-initialized and rooted before the host populates them, and export adapters release canonical scratch storage only after task.return completes or all pending host operations acknowledge cancellation.
Static data starts at address 16, above the reference sentinels, followed by relocated library data and the shared scratch stack. The talc allocator claims the remaining bytes of the initial 64 KiB page before growing memory, keeping small components within a single page.
Async
Codegen splits each async function into a ramp function and a resume-state step function. Backward liveness analysis spills only values whose live ranges cross an await into the heap-allocated coroutine frame. Awaiting an already-settled promise continues inline without suspending to the host.
Each pending call has a task record holding its settlement state, outcome, and an intrusive observer list whose nodes live directly inside suspended coroutine frames or combinator records. A settled outcome is retained for repeated observation [4], Β§3, so later observers borrow it without copying its object graph.
Observers awaiting the same native operation share a single WASI 0.3 waitable handle, whose callback resumes them in registration order.
Promise.all, allSettled, and race register as observers of their inputs and count settlements, advancing arbitrarily deep dependency chains through host events with bounded guest stack depth.
Non-winning branches in Promise.race continue as background tasks, and the export adapter traps if unresolved background work remains when the top-level export finishes.
Readable stream readers decouple public read promises from native transfer completion: releasing a reader lock rejects pending reads while preserving buffered chunks for subsequent readers, explicit cancellation retains the native transfer owner and its pinned buffers until the host acknowledges cancellation, and for await loops cancel and unlock the stream on early exit while giving loop exceptions precedence over cancellation errors.
Runtime
runtime/ is a separate Cargo workspace containing the freestanding #![no_std] runtime and standard libraries. perry-abi supplies layouts, pure algorithms and the Wasm panic handler. perry-rt-sys supplies imports, guest-memory access and native stand-ins. perry-runtime owns allocation and instance state.
Standard-library functions implement UTF-8 validation, Unicode normalization, regex bytecode execution, JSON parsing, number and Decimal conversion, URL and HTTP header parsing, and calendar math. Each artifact package declares its builds in Cargo metadata. The host crate perry-rt-build reads those declarations, builds wasm32-unknown-unknown modules and generates the JSON WAT codecs. The compiler build script embeds the resulting artifacts and the pinned WASI WIT packages, so compiling TypeScript requires no Rust or LLVM toolchain. PERRY_PREBUILT_RUNTIME_DIR selects a directory of prebuilt runtime modules.
Runtime release builds optimize each artifact as one unit: optimization level 2, fat LTO, one codegen unit and aborting panics. The compact profile uses size optimization for the bounded runtime. Target-specific Cargo configuration applies position-independent code to Wasm dependencies; each artifact invocation supplies the shared-library linker arguments.
During linking, a reachability pass from the runtime and standard-library calls emitted by MIR strips unused functions, globals and element tables, compacts type signatures, and relocates the survivors into direct local call instructions that share the guest moduleβs linear memory, scratch stack and talc allocator.
Runtime crates run their unit tests on the host in their own workspace. The compiler integration suite tests the embedded modules through the linker and Wasmtime.
References
- N. Ramsey. Beyond Relooper: Recursive Translation of Unstructured Control Flow to Structured Control Flow (Functional Pearl). Proceedings of the ACM on Programming Languages 6(ICFP), 2022.
- WebAssembly Community Group. Canonical ABI Explainer. WebAssembly/component-model, living design document.
- M. Stephenson, J. Babb, S. Amarasinghe. Bitwidth Analysis with Application to Silicon Compilation. ACM PLDI, 2000.
- B. Liskov, L. Shrira. Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems. ACM PLDI, pp. 260-267, 1988.