Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 func in WIT binds to an async function returning a Promise.
  • A result<T, E> export returns T directly, and a thrown exception becomes err. Returning { ok: true, value } or { ok: false, error } also works.
  • --core-only emits the standalone core WebAssembly module. --keep-names preserves function names in the custom name section for guest profiling.
  • WASI 0.3 packages resolve from WASI_WIT_PATH. Packages in wit/deps take 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, and delete removes 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 undefined when the element type admits it.
  • Writing at length appends. Writing at any other index outside 0..length throws RangeError.
  • Fresh empty arrays ([], including any[]) infer their element type from push calls and numeric-indexed writes across branches, loops, non-reassigned local aliases and helper returns, widening string literals to string and preserving nested record and array layouts. Inferred arrays support .length, index reads, Object.keys, Array.isArray and JSON.stringify without 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 charCodeAt indexes UTF-16 code units, so past a character outside that plane its positions differ from length and indexOf: 'πŸ˜€x'.length is 2, yet 'πŸ˜€x'.charCodeAt(2) reads the x.
  • String.fromCharCode and lone surrogate escapes (\uD800 to \uDFFF) are compile errors. Use String.fromCodePoint.
  • Text arriving through the Canonical ABI, JSON.parse or TextDecoder is validated as UTF-8.

Regular Expressions

  • Regular expressions require compile-time constant patterns and flags (regex literals or RegExp calls with string literal arguments) and compile to search bytecode.
  • test, exec, match, search, split, replace and replaceAll support named capture groups (.groups on exec and non-global match), $<name> replacement templates and synchronous replacement callbacks.

JSON

  • JSON.parse decodes 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.stringify supports synchronous replacer callbacks and property-allowlist arrays. The third indentation argument is a compile error.

Dates

  • Date operates in the UTC time zone. Date.parse and new Date(string) accept ISO 8601 year, year-month, date and date-time strings (offset-free strings use UTC). Malformed ISO strings return NaN, whereas non-ISO legacy date formats throw RangeError.
  • Temporal.Instant, Temporal.PlainDate and Temporal.PlainDateTime use the ISO 8601 calendar (calendarId is "iso8601", and non-ISO calendar annotations are rejected). PlainDate.from and PlainDateTime.from accept ISO strings, existing instances or field objects (year, month, day and optional hour through nanosecond) with { overflow: "constrain" | "reject" }. add and subtract accept { years?, months?, weeks?, days? } with optional overflow, and until and since accept largestUnit ("day", "week", "month", "year", "auto").

Exceptions

  • Error, TypeError, RangeError, ReferenceError and SyntaxError keep name, message and cause through try, catch and finally.
  • 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 into err. Without a result, an uncaught exception traps, and a trap does not run pending finally blocks.

Async

  • An async function 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.race leaves the losing tasks running. They must settle before the export returns, or the call traps.
  • ReadableStream supports default readers (getReader()), for await loops, BYOB readers (getReader({ mode: "byob" }) with read(view, { min }) into a caller-provided Uint8Array) and explicit cancellation. for await over 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 typeGuest representation
u8, u16, u32, s8, s16, s32numberRefined number (i32, i64 or f64)
u64, s64biginti64 at boundaries, arbitrary-width bigint in guest
f32, f64numberf64

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 to i64. Unrefined values stay in f64.
  • Bitwise results: &, |, ^, ~, <<, >> and Math.imul produce s32 values and >>> produces u32, so they lower to i32 instructions even when their operands are unrefined f64 values, which convert with ToInt32. When every use needs only the low bits, refined arithmetic feeding them, such as (a * b) | 0 on u32 values, computes in i32. This deliberately preserves integer wrapping before conversion: at a = b = 4294967295, the result is 1, whereas JavaScript rounds the product before converting and returns 0. 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 f64 until 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 NaN stays unknown.
  • Bounds checks: A proof of 0 <= i < length permits unchecked array reads and stores, and Uint8Array reads. 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:

OperandsJoinReason
u8, u16u16Wider type of the same signedness.
u8, s8s16Holds both 0..=255 and -128..=127.
u16, s16s32Next signed width up.
u32, s32s64Next signed width up.
u8, s32s32Signed operand is already wider.
integer, unrefined numberf64Unrefined 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_delta masks base (i32.and) and sign-extends delta (i32.extend8_s). The sum lies in -128..=382, so the body adds directly in i32.
  • scale-bytes: The product of two u32 values is below 264, so i64.mul computes it exactly. Converting that exact product to f64 rounds once, matching JavaScript floating-point multiplication. / is floating-point division, so the division stays in f64.

Boundary Conversion

  • Incoming u8..s16: The export adapter masks u8 and u16 arguments and sign-extends s8 and s16 arguments.
  • Outgoing u8..s32: Values convert with JavaScript typed-array semantics (ToUint8 through ToInt32), truncating toward zero, mapping NaN and Β±Infinity to 0, and wrapping modulo 2N. Integer and f64 code paths produce identical results.
  • u64, s64 (bigint): Out-of-range bigint returns trap at the boundary. Inside guest code, +, -, unary -, comparisons and toString() support arbitrary width. *, /, % and BigInt(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; }
CallResultJavaScript
scale8(200, 2)144new Uint8Array([400])[0]
to-u8(-1)255new Uint8Array([-1])[0]
to-s32(3000000000)-12949672963000000000 | 0
dec-u64(1)01n - 1n
dec-u64(0)trap-1n is outside u64

References

  1. M. Stephenson, J. Babb, S. Amarasinghe. Bitwidth Analysis with Application to Silicon Compilation. ACM PLDI, 2000.

Standard Library

Globals
NumberNumber.isFinite, Number.isInteger, Number.isNaN, Number.isSafeInteger, Number.parseInt, Number.parseFloat, parseInt, parseFloat, isNaN, Number()
StringString(), String.fromCodePoint
ArrayArray.isArray, Array.from, Array.of
ObjectObject.keys, Object.values, Object.entries, Object.is, structuredClone
JSONJSON.parse, JSON.stringify
ErrorsError, TypeError, RangeError, ReferenceError, SyntaxError
PromisePromise.resolve, Promise.all, Promise.allSettled, Promise.race
TimeDate, 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, textUint8Array, TextEncoder, TextDecoder
CollectionsMap, Set
WebURL, AbortController, crypto.getRandomValues, crypto.randomUUID, fetch, Headers, Request, Response
Processconsole.log, console.info, console.warn, console.error, console.debug, process.env, process.exitCode, process.exit
ModulesFunctions
Mathfloor, ceil, trunc, abs, round, sqrt, imul, min, max, random
node:fs/promisesreadFile, writeFile, stat, readdir, mkdir, unlink, rmdir
node:timers/promisessetTimeout
node:streamWritable.toWeb
ReceiverMembers
Arraypush, 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
Mapget, set, has, delete, clear, forEach, keys, values, entries
Setadd, has, delete, difference, intersection, union, symmetricDifference, isSubsetOf, isSupersetOf, isDisjointFrom, clear, forEach, keys, values, entries
stringcharAt, 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
Uint8Arraysubarray, slice, fill, set
DategetTime, 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.StatsisFile, isDirectory
RegExptest, exec
TextDecoderdecode
URLtoString, toJSON
URLSearchParamsappend
AbortControllerabort
numbertoString
Temporal.InstanttoString, toJSON
Temporal.PlainDatetoString, toJSON, equals, until, since, add, subtract
Temporal.PlainDateTimetoString, toJSON, add, subtract
Headersappend, set, delete, get, has
Responsestatus, statusText, ok, headers, body, bodyUsed, bytes, text, json
Requestmethod, url, headers, body, bodyUsed, bytes, text, json
WritableStreamlocked, getWriter
WritableStreamDefaultWriterwrite, close, releaseLock
ReadableStreamlocked, getReader, cancel
ReadableStreamDefaultReaderread, cancel, releaseLock
ReadableStreamBYOBReaderread, cancel, releaseLock
ReadableStream read resultdone, 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:

OutcomeMeaning
EquivalentExecutionThe component produces the same observable result as Node.js.
CompilerRejectionThe compiler rejects the program with a diagnostic, for constructs outside the subset.
InjectedFaultDetectionThe harness detects a deliberately broken export adapter: a wrong value, a dropped effect or a leaked host resource. These cases test the harness itself.
BoundedGrowthFuel 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

ContractCapabilityOutcomePartitionsDomain
ecma.number.arithmeticIEEE arithmetic and evaluation orderEquivalent executionsigned-zero, division, trace, call, record-field-update, byte-alias, checked-json, handled-throwBounded 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.bitwiseNumber bitwise operators, shifts and Math.imulEquivalent executionand, or, xor, not, left, right, unsigned-right, imul, trace, counts-and-compound-assignmentsNumber 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.roundingNumeric Math operationsEquivalent executionfloor, ceil, trunc, abs, round, abs-widened-loopBounded 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.operationsString search, casing, slicing and lengthEquivalent executionunicode-and-nul, search, lower, upper, slice, char-atBounded 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.denseDense numeric arrays preserve values and effectsEquivalent executionindex, branch-true, branch-false, first-index, array-effectsBounded 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.recordsRecord fields preserve values and effectsEquivalent executionfield-orderBounded 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.epochDate TimeClip and epoch readsEquivalent executiontimeclip, nested-codecsBounded 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.conversionByte conversion and indexed accessEquivalent executionwrapping, byte-input, byte-effectsBounded 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.roundtripJSON text survives direct and stored decodingEquivalent executiondirect, stored, stored-unicode, direct-unicodeBounded 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.commandCLI JSON serialization and schema-guided parsingEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.command.arraysCLI dense array manipulation and iterationEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.command.textCLI UTF-8 string formatting, UTF-16 code units and regular expressionsEquivalent executionstandard-sourceStandard 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.recordsCLI closed record layouts and nested objectsEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.command.numbersCLI numeric formatting and boundary arithmeticEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.command.webCLI Web API codecs and byte viewsEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.command.temporalCLI ISO Temporal calendar and instant operationsEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.promise.allConcurrent Promise.all settlement orderingEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.date.clockPlatform wall-clock reads and ISO Date formattingEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
ecma.uint8array.setOverlapping byte copies and atomic range rejectionEquivalent executionempty, binary-subviewUint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly
ecma.module.unawaited_initializationExplicit compiler rejection of module initialization that entry points cannot awaitCompiler rejectionunawaited-async-callSynchronous top-level statements of a module with exported functions that start asynchronous work without awaiting it
ecma.temporal.isoTyped ISO Instant, PlainDate and PlainDateTime valuesEquivalent executionnegative-millisecond, epoch, lower-limit, upper-limitIntegral epoch milliseconds in +/-8640000000000000, ISO formatting, leap-day addition and the ISO 8601 calendarId
ecma.promise.all_settledFulfilled and rejected results retain their status and payloadEquivalent executionzero, negativeTwo numeric tasks; one fulfillment and one rejection for allSettled, two already-completed contenders for race; repeated component calls and IEEE-754 inputs
ecma.promise.raceAlready-settled contenders retain input order and repeated outcomesEquivalent executionzero, negativeTwo numeric tasks; one fulfillment and one rejection for allSettled, two already-completed contenders for race; repeated component calls and IEEE-754 inputs
ecma.error.valuesError messages, causes and identity across promise rejection and rethrowEquivalent executionempty-message, nul-and-unicodeTypeError 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_storageFresh empty arrays infer uniform or finite mixed elements from visible writesEquivalent execution, Compiler rejectionuniform-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-writesUp 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_storageFresh empty dictionaries share array inference for visible writes and aliasesEquivalent execution, Compiler rejectioncomputed-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-writesUniform 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.specializationOrthogonal aggregate layout specialization across recursive shapes, exact 64-bit parseInt, origin scaling, and WIT boundariesEquivalent executionself-recursive-wide-parseint-and-float, nested-record-option-boundary-special-values-and-overflowProduct 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.iteratorsLive Set and Map iterators across active, paused, and abandoned lifetimes under delete compaction and clearEquivalent executionset-paused-clear-foreach-and-reinsert, map-entries-compaction-and-abandoned-cursor-churnOrthogonal 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

ContractCapabilityOutcomePartitionsDomain
web.textencoder.subviewsUTF-8 encoding and byte subviewsEquivalent executionsubview-boundsBounded 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.routingStandard output and error stream routing via consoleEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
web.performance.monotonicMonotonic clock reads via performance.nowEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
web.crypto.randomCryptographic random byte fill via crypto.getRandomValuesEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
web.streams.byob_readerBYOB readers transfer shared buffers and fill visible views directlyEquivalent executionempty, byte-boundary, transfer-boundary, beyond-guest-memory, cancel-and-reuseUint8Array 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_bodyHeaders-first Fetch, bounded byte consumption, completion failure and reuseEquivalent executionempty, binary-exact-limit, non-success-status, overflow-cancellation, completion-errorGET loopback HTTP; 200 and 404; binary bodies, explicit body cap and producer truncation; three calls per instance
web.fetch.bounded_byob_bodyBYOB bounded Fetch consumes directly into its final bufferEquivalent executionempty, binary-exact-limit, non-success-status, overflow-cancellation, completion-errorGET loopback HTTP; 200 and 404; binary bodies, explicit body cap and producer truncation; three calls per instance
web.response.bytesConstructed response snapshot, body use and repeated consumptionEquivalent executionempty, binary-subviewUint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly
web.request.bytesConstructed request snapshot, body use and repeated consumptionEquivalent executionempty, binary-subviewUint8Array sources of 0..256 bytes; each source evaluated over every IEEE-754 boundary input; byte reads checked explicitly
web.textdecoder.streamingFatal UTF-8 decoding across byte boundariesEquivalent executionempty, bom-and-split-scalar, invalid-sequence, incomplete-sequenceFatal UTF-8, one-byte chunks, final flush; compare successful prefix bytes and normalized decoding failure
web.headers.multimapHeader name folding, joined duplicates and mutationsEquivalent executionempty-value, trim-and-duplicatesASCII values, case-insensitive names, whitespace trimming, append/get/set/has/delete

Node.js

ContractCapabilityOutcomePartitionsDomain
node.timers.promises.valueTimer resultEquivalent executionretained-operationRetained async operation interleaved with typed numeric evaluation; instance reused across boundary inputs
node.timers.promises.observersRepeated timer observationEquivalent executionretained-operationRetained async operation interleaved with typed numeric evaluation; instance reused across boundary inputs
node.fs.readFile.utf8UTF-8 file read across retained and dynamic Unicode payloadsEquivalent executionretained-operation, unicode-and-concurrent-readRetained async UTF-8 read interleaved with numeric evaluation, plus dynamic Unicode/NUL payloads of 0..48 scalar values and concurrent reads
node.fs.writeFile.textText file round trip, overwrite and truncationEquivalent executionretained-operation, overwrite-and-truncateRetained JSON text round trip plus sequential UTF-8 writes testing file truncation and growth across arbitrary Unicode strings
node.fs.metadataFile stat and directory listing across dynamic files and cleanupEquivalent executionretained-operation, multi-file-stat-and-unlinkRetained stat/readdir on fixture plus multi-file creation, exact byte-length stat verification, directory entry count and unlink cleanup
node.fs.readFile.bytesBinary file read, buffer isolation and UTF-8 decodingEquivalent executionretained-operation, binary-buffer-isolationRetained binary read plus arbitrary 0..256-byte payloads, returned Uint8Array mutation isolation across repeated reads and TextDecoder verification
node.fs.writeFile.subviewsByte view and empty file round trip across arbitrary subarray slicesEquivalent executionretained-operation, arbitrary-subview-sliceRetained 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.recoveryRepeated rejection across missing UTF-8, binary and stat calls followed by recoveryEquivalent executionretained-operation, multi-op-rejection-recoveryRetained rejected readFile plus repeated rejection observation across UTF-8 readFile, binary readFile and stat, followed by successful write/read recovery
node.process.environmentEnvironment variable reads via process.envEquivalent executionstandard-sourceStandard CLI source; exact output bytes and successful exit
node.timers.promises.lifecycleStart, host release, completion, cancellation, observation and reuseEquivalent executionsettlement-and-reobserve, cancel-and-reuseComplete sequential lifecycle traces with controlled Node/WASI clocks; no claim of exhaustive async scheduling
node.stream.writable_to_webQueued acknowledged writes, writer locks and closed-writer rejectionEquivalent executionempty, binary-and-closestdout/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.unsupportedExplicit compiler rejection of callback timersCompiler rejectioncallback-timerCallback timers are outside the supported source domain
node.fs.directoriesDirectory creation, listing, metadata and deletionEquivalent executionempty-file, binary-fileOne directory and binary file; exact size, one listed entry, cleanup and reuse across the numeric boundary corpus
node.process.exit_statusExplicit failure status and immediate process exitEquivalent executionexit-code, immediate-exitExit status 1 via process.exitCode or process.exit; exact preceding output and unreachable following output

WASI 0.3 Host

ContractCapabilityOutcomePartitionsDomain
wasi.execution.detect_value_faultInjected faults must be distinguished from the unchanged programFault detectioninjected-faultValue corruption, omitted effects, and retained host resources; only the component adapter is faulted
wasi.execution.detect_trace_faultInjected faults must be distinguished from the unchanged programFault detectioninjected-faultValue corruption, omitted effects, and retained host resources; only the component adapter is faulted
wasi.execution.detect_resource_faultInjected faults must be distinguished from the unchanged programFault detectioninjected-faultValue corruption, omitted effects, and retained host resources; only the component adapter is faulted
wasi.streams.incoming_bytesOwned WIT byte streams use Web readers and acknowledged Web writesEquivalent executionempty, byte-boundary, transfer-boundary, beyond-guest-memory, cancel-and-reuseThree calls per instance; input chunks of 8192 bytes; 64 KiB guest memory; exact output bytes, locking, cancellation and completion
wasi.http.handlerNative HTTP ABI with WIT-generated request and response declarationsEquivalent executionempty, binary-and-duplicates, request-response-limitPUT requests with binary bodies up to 64 KiB, duplicate headers, statuses 200/201/404; three exchanges reuse one component instance
wasi.execution.scaling.retained_recordsPushing n records into a local array and reading them back costs O(n) guest fuelBounded fuel growthn-128Warm-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_pushPushing n numbers into a dense array and summing them costs O(n) guest fuelBounded fuel growthn-128Warm-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_recordsOne temporary record per loop iteration costs O(n) guest fuel in totalBounded fuel growthn-128Warm-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_appendAppending one character n times costs at most nΒ·log n guest fuelBounded fuel growthn-128Warm-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_churnAbandoned Set/Map iterators and insert-delete churn cost O(n) guest fuelBounded fuel growthn-128Warm-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
StageRepresentationRole
1. Source analysisTypeScript ASTBinds import { f } from "pkg:iface" against the WIT world before import syntax is erased.
2. FrontendSpecialized Perry HIRApplies 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. ElaborationHIR to MIRCompletes 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 bindingpliron MIR and WITBinds WIT exports to MIR functions, checks their signatures and async requirements, and constrains numeric domains from the contract.
5. OptimizationSSA MIRPromotes 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 generationCore 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 encodingWASI 0.3 componentEmits 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:

CrateResponsibility
perry-contractTraverses resolved WIT contracts and defines source naming conventions.
perry-frontendLoads source modules, reads Perry HIR, applies compatibility rewrites, resolves types, specializes instances and generates the TypeScript view of WIT.
perry-lowerElaborates the frontend program into eager MIR, completes types from context and chooses representations.
perry-mirDefines typed executable IR and its analyses.
perry-codegenLowers MIR to Wasm and implements Canonical ABI operations.
perry-wit-diagnosticsHolds source spans and portable diagnostics.
pliron-control, pliron-wasmProvide control-flow and Wasm dialect infrastructure.
perry-rt-buildBuilds the runtime workspace’s artifacts for the compiler build script.
perry-witLoads 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 talc and 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

  1. 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.
  2. WebAssembly Community Group. Canonical ABI Explainer. WebAssembly/component-model, living design document.
  3. M. Stephenson, J. Babb, S. Amarasinghe. Bitwidth Analysis with Application to Silicon Compilation. ACM PLDI, 2000.
  4. B. Liskov, L. Shrira. Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems. ACM PLDI, pp. 260-267, 1988.