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

Introduction

Perry is a native TypeScript compiler that compiles TypeScript source code directly to native executables. No JavaScript runtime, no JIT warmup, no V8 — your TypeScript compiles to a real binary.

// demonstrates: the one-liner hello shown on the docs landing page
// docs: docs/src/introduction.md
// platforms: macos, linux, windows
// targets: wasm, web, android

console.log("Hello from Perry!")
$ perry hello.ts -o hello
$ ./hello
Hello from Perry!

Why Perry?

  • Native performance — Compiles to machine code via LLVM. Integer-heavy code like Fibonacci runs 2x faster than Node.js.
  • Real multi-threadingparallelMap and spawn give you actual OS threads with compile-time safety. No isolates, no message passing overhead. Something no JS runtime can do.
  • Small binaries — A hello world is ~300KB. Perry detects what runtime features you use and only links what’s needed.
  • Native UI — Build desktop and mobile apps with declarative TypeScript that compiles to real AppKit, UIKit, GTK4, Win32, or DOM widgets.
  • Terminal UI — Build interactive CLIs with ink-shape React hooks (useState, useEffect, useApp) on a native cell-grid renderer. No Node, no reconciler — just a single static binary.
  • 7 targets — macOS, iOS, Android, Windows, Linux, Web, and WebAssembly from the same source code.
  • Familiar ecosystem — Use npm packages like fastify, mysql2, redis, bcrypt, lodash, and more — compiled natively.
  • Zero config — Point Perry at a .ts file and get a binary. No tsconfig.json required.

What Perry Compiles

Perry supports a practical subset of TypeScript:

  • Variables, functions, classes, enums, interfaces
  • Async/await, closures, generators
  • Destructuring, spread, template literals
  • Arrays, Maps, Sets, typed arrays
  • Regular expressions, JSON, Promises
  • Module imports/exports
  • Generic type erasure

See Supported Features for the complete list.

Quick Example: Native App

// demonstrates: minimal stateful UI — label + increment button
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, visionos-simulator, tvos-simulator, watchos-simulator, android, web, wasm

import { App, VStack, Text, Button, State } from "perry/ui"

const count = State(0)

App({
    title: "Counter",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text(`Count: ${count.value}`),
        Button("Increment", () => count.set(count.value + 1)),
    ]),
})
$ perry counter.ts -o counter
$ ./counter  # Opens a native macOS/Windows/Linux window

This produces a ~3MB native app with real platform widgets — no Electron, no WebView.

How It Works

TypeScript (.ts)
    ↓ Parse (SWC)
    ↓ Lower to HIR
    ↓ Transform (inline, closure conversion, async)
    ↓ Codegen (LLVM)
    ↓ Link (system linker)
    ↓
Native Executable

Perry uses SWC for TypeScript parsing and LLVM for native code generation. Types are erased at compile time (like tsc), and values are represented at runtime using NaN-boxing for efficient 64-bit tagged values.

Next Steps

Installation

Prerequisites

Perry compiles TypeScript to native binaries by linking with your system’s C toolchain, so every install path needs a linker:

  • macOS: Xcode Command Line Tools (xcode-select --install)
  • Linux: gcc or clang (apt install build-essential on Debian/Ubuntu, apk add build-base on Alpine)
  • Windows: LLVM (winget install LLVM.LLVM) + perry setup windows (lightweight, ~1.5 GB, no Visual Studio needed), or MSVC Build Tools with the “Desktop development with C++” workload — see the Windows platform guide for both options

The source install additionally needs the Rust toolchain via rustup.

Install Perry

Perry ships as a prebuilt-binary npm package. This is the fastest way to get started and the only path that covers all seven supported platforms (macOS arm64/x64, Linux x64/arm64 glibc + musl, Windows x64) with a single command:

# Project-local (pins Perry's version alongside your deps)
npm install @perryts/perry
npx perry compile src/main.ts -o myapp && ./myapp

# Global
npm install -g @perryts/perry
perry compile src/main.ts -o myapp

# Zero-install, one-shot
npx -y @perryts/perry compile src/main.ts -o myapp

@perryts/perry is a thin launcher; npm automatically picks the matching prebuilt via optionalDependencies (@perryts/perry-darwin-arm64, @perryts/perry-linux-x64-musl, etc.) based on your os / cpu / libc. Requires Node.js ≥ 16.

PlatformPrebuilt package
macOS arm64 (Apple Silicon)@perryts/perry-darwin-arm64
macOS x64 (Intel)@perryts/perry-darwin-x64
Linux x64 (glibc)@perryts/perry-linux-x64
Linux arm64 (glibc)@perryts/perry-linux-arm64
Linux x64 (musl / Alpine)@perryts/perry-linux-x64-musl
Linux arm64 (musl / Alpine)@perryts/perry-linux-arm64-musl
Windows x64@perryts/perry-win32-x64

Homebrew (macOS)

brew install perryts/perry/perry

winget (Windows)

winget install PerryTS.Perry

APT (Debian / Ubuntu)

curl -fsSL https://perryts.github.io/perry-apt/perry.gpg.pub | sudo gpg --dearmor -o /usr/share/keyrings/perry.gpg
echo "deb [signed-by=/usr/share/keyrings/perry.gpg] https://perryts.github.io/perry-apt stable main" | sudo tee /etc/apt/sources.list.d/perry.list
sudo apt update && sudo apt install perry

From Source

git clone https://github.com/PerryTS/perry.git
cd perry
cargo build --release

The binary is at target/release/perry. Add it to your PATH:

# Add to ~/.zshrc or ~/.bashrc
export PATH="/path/to/perry/target/release:$PATH"

Self-Update

Once installed, Perry can update itself:

perry update

This downloads the latest release and atomically replaces the binary.

Verify Installation

perry doctor

This checks your installation, shows the current version, and reports if an update is available.

perry --version

Platform-Specific Setup

macOS

No additional setup needed. Perry uses the system cc linker and AppKit for UI apps.

For iOS development, install Xcode (not just Command Line Tools) for the iOS SDK and simulator.

Linux

Install GTK4 + libshumate + GStreamer development libraries for UI apps. (You only need these if you build for --target linux — pure-CLI / cross-compile to other platforms doesn’t require them.)

# Ubuntu / Debian
sudo apt install libgtk-4-dev libshumate-dev libgstreamer1.0-dev

# Fedora
sudo dnf install gtk4-devel libshumate-devel gstreamer1-devel \
                 gstreamer1-plugins-base-devel

# Arch
sudo pacman -S gtk4 libshumate gstreamer gst-plugins-base

Windows

Two toolchain options — pick one. Both produce identical binaries.

Lightweight (recommended, ~1.5 GB, no Visual Studio):

winget install LLVM.LLVM
perry setup windows

perry setup windows downloads the Microsoft CRT + Windows SDK libraries via xwin after prompting for license acceptance. Pass --accept-license to skip the prompt in CI.

MSVC Build Tools (~8 GB):

Install Visual Studio Build Tools with the “Desktop development with C++” workload — via the Visual Studio Installer, or:

winget install Microsoft.VisualStudio.2022.BuildTools --override `
  "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"

Run perry doctor to verify the toolchain. See the Windows platform guide for details.

What’s Next

Hello World

Your First Program

Create a file called hello.ts:

// demonstrates: the minimal Perry program in the docs
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows
// targets: wasm, web, android

console.log("Hello, Perry!")

Compile and run it:

perry hello.ts -o hello
./hello

Output:

Hello, Perry!

That’s it. Perry compiled your TypeScript to a native executable — no Node.js, no bundler, no runtime.

A Slightly Bigger Example

// demonstrates: recursive fib as a perf-vs-node talking point
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows
// targets: wasm, web, android

function fibonacci(n: number): number {
    if (n <= 1) return n
    return fibonacci(n - 1) + fibonacci(n - 2)
}

const start = Date.now()
const result = fibonacci(35)
const elapsed = Date.now() - start

console.log(`fibonacci(35) = ${result}`)
console.log(`Completed in ${elapsed}ms`)
perry fib.ts -o fib
./fib

This runs about 2x faster than Node.js because Perry compiles to native machine code with integer specialization.

Using Variables and Functions

const name: string = "World"
const items: number[] = [1, 2, 3, 4, 5]

const doubled = items.map((x) => x * 2)
const sum = doubled.reduce((acc, x) => acc + x, 0)

console.log(`Hello, ${name}!`)
console.log(`Sum of doubled: ${sum}`)

Async Code

// demonstrates: async/await fetch shown in hello-world.md
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows
// run: false

async function fetchData(): Promise<string> {
    const response = await fetch("https://httpbin.org/get")
    const data = await response.json() as { origin: string }
    return data.origin
}

const ip = await fetchData()
console.log(`Your IP: ${ip}`)
perry fetch.ts -o fetch
./fetch

Perry compiles async/await to a native async runtime backed by Tokio.

Multi-Threading

Perry can do something no JavaScript runtime can — run your code on multiple CPU cores:

// demonstrates: parallelMap + spawn shown in hello-world.md
// docs: docs/src/getting-started/hello-world.md
// platforms: macos, linux, windows

import { parallelMap, parallelFilter, spawn } from "perry/thread"

const data = [1, 2, 3, 4, 5, 6, 7, 8]

// Process all elements across all CPU cores
const doubled = parallelMap(data, (x: number) => x * 2)
console.log(doubled) // [2, 4, 6, 8, 10, 12, 14, 16]

// Run heavy work in the background
const result = await spawn(() => {
    let sum = 0
    for (let i = 0; i < 100_000_000; i++) sum += i
    return sum
})
console.log(result)

// parallelFilter is also available for the lift-and-parallelize case:
const evens = parallelFilter(data, (x: number) => x % 2 === 0)
console.log(evens)

This is real OS-level parallelism, not web workers or separate isolates. See Multi-Threading for details.

What the Compiler Produces

When you run perry file.ts -o output, Perry:

  1. Parses your TypeScript with SWC
  2. Lowers the AST to an intermediate representation (HIR)
  3. Applies optimizations (inlining, closure conversion, etc.)
  4. Generates native machine code with LLVM
  5. Links with your system’s C compiler

The result is a standalone executable with no external dependencies.

Binary Size

ProgramBinary Size
Hello world~300KB
CLI with fs/path~3MB
UI app~3MB
Full app with stdlib~48MB

Perry automatically detects which runtime features you use and only links what’s needed.

Next Steps

First Native App

Perry compiles declarative TypeScript UI code to native platform widgets. No Electron, no WebView — real AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows. Every example on this page is a real source file under docs/examples/ that CI compiles and runs on every PR.

A Simple Counter

// demonstrates: minimal stateful UI — label + increment button
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, visionos-simulator, tvos-simulator, watchos-simulator, android, web, wasm

import { App, VStack, Text, Button, State } from "perry/ui"

const count = State(0)

App({
    title: "Counter",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text(`Count: ${count.value}`),
        Button("Increment", () => count.set(count.value + 1)),
    ]),
})

Compile and run:

perry counter.ts -o counter
./counter

A native window opens with a label and two buttons. Clicking “Increment” updates the count in real-time.

How It Works

  • App({ title, width, height, body }) — Creates a native application window. body is the root widget.
  • State(initialValue) — Creates reactive state. .value reads, .set(v) writes and triggers UI updates.
  • VStack(spacing, [...]) — Vertical stack layout (like SwiftUI’s VStack or CSS flexbox column). The first arg is the gap in points between children.
  • Text(string) — A text label. Template literals referencing ${state.value} bind reactively.
  • Button(label, onClick) — A native button with a click handler.

A Todo App

// demonstrates: complete reactive todo app combining State, ForEach, and widget tree mutation
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm

import {
    App,
    Text,
    Button,
    TextField,
    VStack,
    HStack,
    State,
    ForEach,
    Spacer,
    Divider,
} from "perry/ui"

const todos = State<string[]>([])
const count = State(0)
const input = State("")

App({
    title: "Todo App",
    width: 480,
    height: 600,
    body: VStack(16, [
        Text("My Todos"),

        HStack(8, [
            TextField("What needs to be done?", (value: string) => input.set(value)),
            Button("Add", () => {
                const text = input.value
                if (text.length > 0) {
                    todos.set([...todos.value, text])
                    count.set(count.value + 1)
                    input.set("")
                }
            }),
        ]),

        Divider(),

        ForEach(count, (i: number) =>
            HStack(8, [
                Text(todos.value[i]),
                Spacer(),
                Button("Delete", () => {
                    todos.set(todos.value.filter((_, idx) => idx !== i))
                    count.set(count.value - 1)
                }),
            ]),
        ),

        Spacer(),
        Text(`${count.value} items`),
    ]),
})

ForEach(count, render) iterates by index — keep an item array and a count state in sync, then read items via array.value[i] inside the closure. See State Management for the full pattern.

Cross-Platform

The same code runs on all 6 platforms:

# macOS (default)
perry app.ts -o app
./app

# iOS Simulator
perry app.ts -o app --target ios-simulator

# Web (compiles to WebAssembly + DOM bridge in a self-contained HTML file)
perry app.ts -o app --target web   # alias: --target wasm
open app.html

# Other platforms
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android

Each target compiles to the platform’s native widget toolkit. See Platforms for details.

Adding Styling

Styling is applied via free functions that take the widget handle as their first argument. Colors are RGBA floats in [0.0, 1.0] — divide a hex byte by 255 to convert (0x33 / 255 ≈ 0.2).

// demonstrates: counter from getting-started/first-app.md with styled widgets
// docs: docs/src/getting-started/first-app.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm
// run: false

import {
    App, VStack, Text, Button, State,
    textSetFontSize, textSetColor,
    setCornerRadius, setPadding,
    widgetSetBackgroundColor,
} from "perry/ui"

const count = State(0)

const label = Text(`Count: ${count.value}`)
textSetFontSize(label, 24)
textSetColor(label, 0.2, 0.2, 0.2, 1.0)        // RGBA in [0,1] — same as #333333

const btn = Button("Increment", () => count.set(count.value + 1))
setCornerRadius(btn, 8)
widgetSetBackgroundColor(btn, 0.0, 0.478, 1.0, 1.0)  // system blue

const stack = VStack(20, [label, btn])
setPadding(stack, 20, 20, 20, 20)

App({
    title: "Styled Counter",
    width: 400,
    height: 300,
    body: stack,
})

See Styling for all available style properties.

Next Steps

Project Configuration

Perry projects use perry.toml and package.json for configuration. No special config file is required for basic usage, but larger projects benefit from Perry-specific settings.

Looking for the full perry.toml reference? See perry.toml Reference for every field, section, platform option, and environment variable.

Basic Setup

perry init my-project
cd my-project

This creates a package.json and a starter src/index.ts.

package.json

{
  "name": "my-project",
  "version": "1.0.0",
  "main": "src/index.ts",
  "perry": {
    "compilePackages": []
  }
}

Perry Configuration

The perry field in package.json controls compiler behavior:

compilePackages

List npm packages to compile natively instead of routing through the JavaScript runtime:

{
  "perry": {
    "compilePackages": ["@noble/curves", "@noble/hashes"]
  }
}

When a package is listed here, Perry:

  1. Resolves the package in node_modules/
  2. Prefers TypeScript source (src/index.ts) over compiled JavaScript (lib/index.js)
  3. Compiles all functions natively through LLVM
  4. Deduplicates across nested node_modules/ to prevent duplicate linker symbols

This is useful for pure TypeScript/JavaScript packages that don’t rely on Node.js APIs. Packages that use native bindings, eval(), or dynamic require() won’t work.

codegen

Perry is an ahead-of-time compiler: it never runs a code string at runtime. Many libraries that would normally JIT a function from a schema or a config (ajv, fast-json-stringify, Prisma, Drizzle, …) ship a build-time mode that emits plain, eval-free source instead. The codegen field declares the commands that produce that source. Perry runs them before compiling, then compiles the generated output natively — so the shipped binary links no JavaScript engine.

{
  "perry": {
    "codegen": [
      { "label": "ajv validators", "command": "node scripts/generate-validators.mjs" }
    ]
  }
}

Each entry is either a bare command string or an object with command (required) and an optional label shown in build output. Commands run in declaration order, with the working directory set to the folder containing this package.json, so relative script paths resolve as expected. If a command exits non-zero the build fails and prints its captured stdout/stderr.

Security: codegen is read only from the host project’s package.json — never from a dependency’s — so a transitive dependency can’t smuggle in a build command (the same trust boundary as compilePackages). Skip the steps for a reproducible or sandboxed build (where the generated output is already committed) with perry compile --no-codegen or PERRY_SKIP_CODEGEN=1.

Worked example: ajv/standalone

ajv validates against a JSON Schema. Its default mode JITs the validator with new Function; its standalone mode emits the same validator as plain source. The generator script:

// scripts/generate-validators.mjs
import Ajv from "ajv";
import standaloneCode from "ajv/dist/standalone/index.js";
import { writeFileSync } from "node:fs";

const schema = {
  $id: "Config",
  type: "object",
  properties: { host: {}, port: {} },
  required: ["host", "port"],
  additionalProperties: false,
};

const ajv = new Ajv({ code: { source: true } }); // standalone source
const moduleCode = standaloneCode(ajv, ajv.compile(schema));
writeFileSync(new URL("../generated/validator.cjs", import.meta.url), moduleCode);

Then import the generated validator like any other module:

import validate from "./generated/validator.cjs";
if (!validate(input)) throw new Error("invalid config");

perry compile runs the codegen step, ajv emits generated/validator.cjs (no new Function), and Perry compiles it natively. See test-files/test_ajv_standalone.ts for a runnable, byte-parity-tested sample.

Same convention, other tools

The convention is library-agnostic — point a codegen command at any build-time generator and import its output:

ToolcommandOutput to import
ajvnode scripts/generate-validators.mjs (uses ajv/standalone)generated validator module
Prismaprisma generategenerated client
Drizzledrizzle-kit introspectgenerated schema/types
kysely-codegenkysely-codegen --out-file src/db.d.tsgenerated DB types
Vue SFCvue-tsc / your SFC compile stepcompiled .vue output

Libraries that JIT at runtime with no standalone mode (e.g. fast-json-stringify, find-my-way) are handled separately — see the eval / new Function strategy.

splash

Configure a native splash screen for iOS and Android. The splash screen appears instantly during cold start, before your app code runs.

Minimal (both platforms share the same splash):

{
  "perry": {
    "splash": {
      "image": "logo/icon-256.png",
      "background": "#FFF5EE"
    }
  }
}

Per-platform overrides:

{
  "perry": {
    "splash": {
      "image": "logo/icon-256.png",
      "background": "#FFF5EE",
      "ios": {
        "image": "logo/splash-ios.png",
        "background": "#FFFFFF"
      },
      "android": {
        "image": "logo/splash-android.png",
        "background": "#FFFFFF"
      }
    }
  }
}

Full custom override (complete control):

{
  "perry": {
    "splash": {
      "ios": {
        "storyboard": "splash/LaunchScreen.storyboard"
      },
      "android": {
        "layout": "splash/splash_background.xml",
        "theme": "splash/themes.xml"
      }
    }
  }
}
FieldDescription
splash.imagePath to a PNG image, centered on the splash screen (both platforms)
splash.backgroundHex color for the background (default: #FFFFFF)
splash.ios.imageiOS-specific image override
splash.ios.backgroundiOS-specific background color
splash.ios.storyboardCustom LaunchScreen.storyboard (compiled with ibtool)
splash.android.imageAndroid-specific image override
splash.android.backgroundAndroid-specific background color
splash.android.layoutCustom drawable XML for windowBackground
splash.android.themeCustom themes.xml

Resolution order per platform:

  1. Custom file override (storyboard / layout+theme)
  2. Platform-specific image/color (splash.{platform}.image)
  3. Universal image/color (splash.image)
  4. No splash key → blank white screen (backward compatible)

Using npm Packages

Perry natively supports many popular npm packages without any configuration:

// demonstrates: importing built-in stdlib npm packages (project-config.md)
// docs: docs/src/getting-started/project-config.md
// platforms: macos, linux, windows
// run: false

// These four imports are Perry's most-used built-in stdlib shims:
// fastify (HTTP server), mysql2 (db), ioredis (Redis), bcrypt (password
// hashing). They're compiled to native code via Perry's per-package
// implementations — no `compilePackages` needed.
//
// `// run: false` because each one needs a live external service (DB,
// Redis, network port) to actually do anything; the binary still has to
// link cleanly, which is the drift check we want.

import fastify from "fastify"
import mysql from "mysql2/promise"
import Redis from "ioredis"
import bcrypt from "bcrypt"

const app = fastify({ logger: false })
const db = mysql.createPool({ host: "localhost", user: "root", database: "test" })
const redis = new Redis()
const hashed = await bcrypt.hash("hunter2", 10)

console.log(typeof app, typeof db, typeof redis, hashed.length)

These are compiled to native code using Perry’s built-in implementations. See Standard Library for the full list.

For packages not natively supported, use compilePackages for pure TS/JS packages, or the JavaScript runtime fallback for complex packages.

Project Structure

Perry is flexible about project structure. Common patterns:

my-project/
├── package.json
├── src/
│   └── index.ts
└── node_modules/      # Only needed for compilePackages

For UI apps:

my-app/
├── package.json
├── src/
│   ├── index.ts       # Main app entry
│   └── components/    # UI components
└── assets/            # Images, etc.

Compilation

# Compile a file
perry src/index.ts -o build/app

# Compile with a specific target
perry src/index.ts -o build/app --target ios-simulator

# Debug: print intermediate representation
perry src/index.ts --print-hir

See CLI Commands for all options.

Next Steps

Supported TypeScript Features

Perry compiles a practical subset of TypeScript to native code. This page lists what’s supported.

Primitive Types

function primitives(): void {
    const n: number = 42;
    const s: string = "hello";
    const b: boolean = true;
    const u: undefined = undefined;
    const nl: null = null;

    console.log(`primitives: n=${n} s=${s} b=${b} u=${u} nl=${nl}`)
}

All primitives are represented as 64-bit NaN-boxed values at runtime.

Variables and Constants

function variables(): void {
    let x = 10;
    const y = "immutable";
    var z = true; // var is supported but let/const preferred

    console.log(`variables: x=${x} y=${y} z=${z}`)
}

Perry infers types from initializers — let x = 5 is inferred as number without an explicit annotation.

Functions

function functionsDemo(): void {
    function add(a: number, b: number): number {
        return a + b;
    }

    // Optional parameters
    function greet(name: string, greeting: string = "Hello"): string {
        return `${greeting}, ${name}!`;
    }

    // Rest parameters
    function sum(...nums: number[]): number {
        return nums.reduce((a, b) => a + b, 0);
    }

    // Arrow functions
    const double = (x: number) => x * 2;

    console.log(`functions: add=${add(2, 3)} greet=${greet("Perry")} sum=${sum(1, 2, 3)} double=${double(5)}`)
}

Classes

class Animal {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    speak(): string {
        return `${this.name} makes a noise`;
    }
}

class Dog extends Animal {
    speak(): string {
        return `${this.name} barks`;
    }
}

// Static methods
class Counter {
    private static instance: Counter;
    private count: number = 0;

    static getInstance(): Counter {
        if (!Counter.instance) {
            Counter.instance = new Counter();
        }
        return Counter.instance;
    }
}

Supported class features:

  • Constructors
  • Instance and static methods
  • Instance and static properties
  • Inheritance (extends)
  • Method overriding
  • instanceof checks (via class ID chain)
  • Singleton patterns (static method return type inference)

Enums

// Numeric enums
enum Direction {
    Up,
    Down,
    Left,
    Right,
}

// String enums
enum Color {
    Red = "RED",
    Green = "GREEN",
    Blue = "BLUE",
}

const dir = Direction.Up;
const color = Color.Red;

Enums are compiled to constants and work across modules.

Interfaces and Type Aliases

interface User {
    name: string;
    age: number;
    email?: string;
}

type Point = { x: number; y: number };
type StringOrNumber = string | number;
type Callback = (value: number) => void;

Interfaces and type aliases are erased at compile time (like tsc). They exist only for documentation and editor tooling.

Arrays

function arraysDemo(): void {
    const nums: number[] = [1, 2, 3];

    // Array methods
    nums.push(4);
    nums.pop();
    const len = nums.length;
    const doubled = nums.map((x) => x * 2);
    const filtered = nums.filter((x) => x > 2);
    const sum = nums.reduce((acc, x) => acc + x, 0);
    const found = nums.find((x) => x === 3);
    const idx = nums.indexOf(3);
    const joined = nums.join(", ");
    const sliced = nums.slice(1, 3);
    nums.splice(1, 1);
    nums.unshift(0);
    const sorted = nums.sort((a, b) => a - b);
    const reversed = nums.reverse();
    const includes = nums.includes(3);
    const every = nums.every((x) => x > 0);
    const some = nums.some((x) => x > 2);
    nums.forEach((x) => console.log(x));
    const flat = [[1, 2], [3]].flat();
    const concatted = nums.concat([5, 6]);

    // Array.from
    const arr = Array.from([10, 20, 30]);

    // Array.isArray
    const value: any = [1, 2, 3]
    if (Array.isArray(value)) { /* ... */ }

    // for...of iteration
    for (const item of nums) {
        console.log(item);
    }

    console.log(`arrays: len=${len} doubled=${doubled.length} filtered=${filtered.length} sum=${sum} found=${found} idx=${idx} joined=${joined} sliced=${sliced.length} sorted=${sorted.length} reversed=${reversed.length} includes=${includes} every=${every} some=${some} flat=${flat.length} concatted=${concatted.length} arr=${arr.length}`)
}

Objects

function objectsDemo(): void {
    const obj: { name: string; version: number; [k: string]: any } = { name: "Perry", version: 1 };
    obj.name = "Perry 2";

    // Dynamic property access
    const key = "name";
    const val = obj[key];

    // Object.keys, Object.values, Object.entries
    const keys = Object.keys(obj);
    const values = Object.values(obj);
    const entries = Object.entries(obj);

    // Spread
    const copy = { ...obj, extra: true };

    // delete
    delete obj[key];

    console.log(`objects: val=${val} keys=${keys.length} values=${values.length} entries=${entries.length} copy=${copy.extra}`)
}

Destructuring

function destructuringDemo(): void {
    // Array destructuring
    const [a, b, ...rest] = [1, 2, 3, 4, 5];

    const user = { name: "Alice", age: 30, email: "a@example.com", id: 1 }
    const obj = { id: 2, role: "admin", level: 5 }

    // Object destructuring
    const { name, age, email = "none" } = user;

    // Rename
    const { name: userName } = user;

    // Rest pattern
    const { id, ...remaining } = obj;

    // Function parameter destructuring
    function process({ name, age }: { name: string; age: number }) {
        console.log(name, age);
    }

    process(user)
    console.log(`destructuring: a=${a} b=${b} rest=${rest.length} name=${name} age=${age} email=${email} userName=${userName} id=${id}`)
}

Template Literals

function templateLiteralsDemo(): void {
    const name = "world";
    const greeting = `Hello, ${name}!`;
    const multiline = `
  Line 1
  Line 2
`;
    const expr = `Result: ${1 + 2}`;

    console.log(`template-literals: greeting=${greeting} multiline_len=${multiline.length} expr=${expr}`)
}

Spread and Rest

function spreadRestDemo(): void {
    const arr1 = [1, 2]
    const arr2 = [3, 4]
    const defaults = { theme: "light", size: "md" }
    const overrides = { size: "lg" }

    // Array spread
    const combined = [...arr1, ...arr2];

    // Object spread
    const merged = { ...defaults, ...overrides };

    // Rest parameters
    function log(...args: any[]) { /* ... */ }

    log("a", "b", "c")
    console.log(`spread-rest: combined=${combined.length} merged=${merged.size}`)
}

Closures

function closuresDemo(): void {
    function makeCounter() {
        let count = 0;
        return {
            increment: () => ++count,
            get: () => count,
        };
    }

    const counter = makeCounter();
    counter.increment();
    console.log(counter.get()); // 1
}

Perry performs closure conversion — captured variables are stored in heap-allocated closure objects.

Async/Await

async function asyncAwaitDemo(): Promise<void> {
    interface Profile { id: number; name: string }

    async function fetchUser(id: number): Promise<Profile> {
        // The docs example uses fetch(...) here; we inline a synthetic
        // result so the snippet compiles and runs hermetically.
        return { id, name: `user-${id}` }
    }

    const data = await fetchUser(1);
    console.log(`async-await: id=${data.id} name=${data.name}`)
}

Perry compiles async functions to a state machine backed by Tokio’s async runtime.

Promises

async function promisesDemo(): Promise<void> {
    const p = new Promise<number>((resolve, reject) => {
        resolve(42);
    });

    p.then((value) => console.log(value));

    // Promise.all
    const results = await Promise.all([
        Promise.resolve("a"),
        Promise.resolve("b"),
    ]);

    console.log(`promises: results=${results.length}`)
}

Generators

function generatorsDemo(): void {
    function* range(start: number, end: number) {
        for (let i = start; i < end; i++) {
            yield i;
        }
    }

    for (const n of range(0, 10)) {
        console.log(n);
    }
}

Map and Set

function mapSetDemo(): void {
    const map = new Map<string, number>();
    map.set("a", 1);
    map.get("a");
    map.has("a");
    map.delete("a");
    map.size;

    const set = new Set<number>();
    set.add(1);
    set.has(1);
    set.delete(1);
    set.size;

    console.log(`map-set: map_size=${map.size} set_size=${set.size}`)
}

Regular Expressions

function regexDemo(): void {
    const re = /hello\s+(\w+)/;
    const match = "hello world".match(re);

    if (re.test("hello perry")) {
        console.log("Matched!");
    }

    const replaced = "hello world".replace(/world/, "perry");

    console.log(`regex: match=${match !== null} replaced=${replaced}`)
}

Error Handling

function errorsDemo(): void {
    try {
        throw new Error("something went wrong");
    } catch (e: any) {
        console.log(e.message);
    } finally {
        console.log("cleanup");
    }
}

JSON

function jsonDemo(): void {
    const obj = JSON.parse('{"key": "value"}');
    const str = JSON.stringify(obj);
    const pretty = JSON.stringify(obj, null, 2);

    console.log(`json: str_len=${str.length} pretty_len=${pretty.length}`)
}

typeof and instanceof

function typeofInstanceofDemo(): void {
    const x: any = "hello"
    if (typeof x === "string") {
        console.log(x.length);
    }

    const obj: any = new Dog("Rex")
    if (obj instanceof Dog) {
        obj.speak();
    }
}

typeof checks NaN-boxing tags at runtime. instanceof walks the class ID chain.

Modules

ES module syntax is fully supported: named exports, default exports, and re-exports.

The exporting module:

// Named exports
export function helper(x: number): number { return x + 1 }
export const VALUE = 42

// Default export
export default class MyClass {
    name: string
    constructor(name: string) {
        this.name = name
    }
}

The importing module:

// Default + named imports from a sibling module
import MyClass, { helper, VALUE } from "./utils"

// Re-export
export { helper } from "./utils"

BigInt

function bigintDemo(): void {
    const big = BigInt(9007199254740991);
    const result = big + BigInt(1);

    // Bitwise operations
    const and = big & BigInt(0xFF);
    const or = big | BigInt(0xFF);
    const xor = big ^ BigInt(0xFF);
    const shl = big << BigInt(2);
    const shr = big >> BigInt(2);
    const not = ~big;

    console.log(`bigint: result_ok=${result !== null} and_ok=${and !== null} or_ok=${or !== null}`)
}

String Methods

function stringMethodsDemo(): void {
    const s = "Hello, World!";
    s.length;
    s.toUpperCase();
    s.toLowerCase();
    s.trim();
    s.split(", ");
    s.includes("World");
    s.startsWith("Hello");
    s.endsWith("!");
    s.indexOf("World");
    s.slice(0, 5);
    s.substring(0, 5);
    s.replace("World", "Perry");
    s.repeat(3);
    s.charAt(0);
    s.padStart(20);
    s.padEnd(20);

    console.log(`string-methods: ${s.toUpperCase()}`)
}

Math

function mathDemo(): void {
    Math.floor(3.7);
    Math.ceil(3.2);
    Math.round(3.5);
    Math.abs(-5);
    Math.max(1, 2, 3);
    Math.min(1, 2, 3);
    Math.sqrt(16);
    Math.pow(2, 10);
    Math.random();
    Math.PI;
    Math.E;
    Math.log(10);
    Math.sin(0);
    Math.cos(0);

    console.log(`math: floor=${Math.floor(3.7)} sqrt=${Math.sqrt(16)}`)
}

Date

function dateDemo(): void {
    const now = Date.now();
    const d = new Date();
    d.getTime();
    d.toISOString();

    console.log(`date: now_positive=${now > 0}`)
}

Console

function consoleDemo(): void {
    console.log("message");
    console.error("error");
    console.warn("warning");
    console.time("label");
    console.timeEnd("label");
}

Garbage Collection

Perry includes a mark-sweep garbage collector. It runs automatically when memory pressure is detected (~8MB arena blocks), but you can also trigger it manually:

function gcDemo(): void {
    gc(); // Explicit garbage collection
}

The GC uses conservative stack scanning to find roots and supports arena-allocated objects (arrays, objects) and malloc-allocated objects (strings, closures, promises, BigInts, errors).

JSX/TSX

Perry’s parser and HIR understand JSX syntax (parsed via SWC, lowered in crates/perry-hir/src/jsx.rs) and .tsx files link through Perry’s built-in jsx() / jsxs() runtime path. You do not need a local react/jsx-runtime package just to compile TSX.

import { Box, Text } from "perry/tui";

function Greeting({ name }: { name: string }) {
  return <Text>{`Hello, ${name}!`}</Text>;
}

const page = <div className="card"><Greeting name="Perry" /></div>;
const app = <Box><Greeting name="TUI" /></Box>;

JSX elements are transformed to function calls via the jsx() / jsxs() runtime. Perry’s built-in adapter supports HTML-style intrinsic tags, fragments, function components, and compile-time rewrites for perry/tui Box / Text so those TUI JSX forms lower to the same native builders as the function-call form.

Caveat: this is Perry’s TSX runtime, not React DOM or full React reconciler semantics. For perry/ui, or for perry/tui intrinsics whose JSX rewrite has not landed yet, the function-call form remains the canonical native API.

Next Steps

Type System

Perry erases types at compile time, similar to how tsc removes type annotations when emitting JavaScript. However, Perry also performs type inference to generate efficient native code.

Type Inference

Perry infers types from expressions without requiring annotations:

function inferenceBasics(): void {
    let x = 5;           // inferred as number
    let s = "hello";     // inferred as string
    let b = true;        // inferred as boolean
    let arr = [1, 2, 3]; // inferred as number[]

    console.log(`inference: x=${x} s=${s} b=${b} arr_len=${arr.length}`)
}

Inference works through:

  • Literal values: 5number, "hi"string
  • Binary operations: a + b where both are numbers → number
  • Variable propagation: if x is number, then let y = x is number
  • Method returns: "hello".trim()string, [1,2].lengthnumber
  • Function returns: user-defined function return types are propagated to callers
function inferenceFunction(): void {
    function double(n: number): number {
        return n * 2;
    }
    let result = double(5); // inferred as number

    console.log(`inference-function: result=${result}`)
}

Type Annotations

Standard TypeScript annotations work:

interface Config {
    port: number;
    host: string;
}

function annotations(): void {
    let name: string = "Perry";
    let count: number = 0;
    let items: string[] = [];

    function greet(name: string): string {
        return `Hello, ${name}`;
    }

    const cfg: Config = { port: 8080, host: "localhost" }
    console.log(`annotations: ${greet(name)} count=${count} items=${items.length} port=${cfg.port}`)
}

Utility Types

Common TypeScript utility types are erased at compile time (they don’t affect code generation):

type MyPartial<T> = { [P in keyof T]?: T[P] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyRecord<K extends string, V> = { [P in K]: V };
type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;
type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : never;
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };

These are all recognized and erased — they won’t cause compilation errors.

Generics

Generic type parameters are erased:

function identity<T>(value: T): T {
    return value;
}

class Box<T> {
    value: T;
    constructor(value: T) {
        this.value = value;
    }
}

function genericsDemo(): void {
    const box = new Box<number>(42);
    const id = identity<string>("hello")
    console.log(`generics: box.value=${box.value} id=${id}`)
}

At runtime, all values are NaN-boxed — the generic parameter doesn’t affect code generation.

Type Checking with --type-check

For stricter type checking, Perry can integrate with Microsoft’s TypeScript checker:

perry file.ts --type-check

This resolves cross-file types, interfaces, and generics via an IPC protocol. It falls back gracefully if the type checker is not installed.

Without --type-check, Perry relies on its own inference engine, which handles common patterns but doesn’t perform full TypeScript type checking.

Union and Intersection Types

Union types are recognized syntactically but don’t affect code generation:

type StringOrNumber = string | number;

function process(value: StringOrNumber) {
    if (typeof value === "string") {
        console.log(value.toUpperCase());
    } else {
        console.log(value + 1);
    }
}

Use typeof checks for runtime type narrowing.

Type Guards

function isString(value: any): value is string {
    return typeof value === "string";
}

function typeGuardsDemo(): void {
    const x: any = "hello"
    if (isString(x)) {
        console.log(x.toUpperCase());
    }
}

The value is string annotation is erased, but the typeof check works at runtime.

Next Steps

Decorators

This page states Perry’s stance on TypeScript decorators and shows the recommended decorator-free pattern for porting Angular / NestJS / TypeORM code.

Stance

Perry treats decorators as a legacy compatibility surface, not a language primitive. The TypeScript ecosystem has been steadily migrating away from decorators since around 2020 — modern frameworks like Drizzle, Hono, tRPC, Prisma, Zod, SolidJS, and Vue 3’s Composition API use plain functions and schema-as-code. Even Angular’s Ivy compiler already AOT-deletes most decorator metadata at build time, and TC39’s new stage-3 decorator spec deliberately drops the runtime type reflection that NestJS and TypeORM rely on.

Perry still follows the modern direction: types are erased at compile time (see Limitations) and there is no runtime DI container. A small legacy compatibility path exists for libraries that only need AOT-lowerable decorator side effects and metadata. Code that depends on richer decorator behavior still needs one of the patterns below.

What works today

Perry parses legacy / experimental TypeScript decorator syntax and supports two paths:

  • Legacy class decorators, method decorators, property decorators, constructor parameter decorators, and method parameter decorators for Nest-style DI and route metadata canaries. Decorator functions run for side effects, Reflect.defineMetadata, Reflect.getMetadata, Reflect.getOwnMetadata, Reflect.hasMetadata, Reflect.hasOwnMetadata, Reflect.getMetadataKeys, Reflect.getOwnMetadataKeys, Reflect.deleteMetadata, and @Reflect.metadata(...) are available. Perry emits design:paramtypes for decorated classes/methods and design:type for decorated properties.
  • Compile-time-only transforms. The bundled @log transform is the canonical example — it rewrites a decorated method into a wrapper that prints entry/exit at compile time, with zero runtime decorator machinery. See crates/perry-hir/src/decorator_log.rs for the implementation.

What does not work

  • Accessor decorators and descriptor replacement
  • Decorator class replacement return values. If a class decorator returns anything other than undefined, Perry throws a TypeError at decorator application time. Real-world decorators like @Memoize, @Throttle, and GraphQL resolver wrappers that return wrapped classes need a Perry-aware port — the lowered class is fixed in the IR and cannot be replaced at runtime.
  • General Reflect.metadata(...) helper calls outside decorator syntax
  • Symbol(...) as a metadata key
  • emitDecoratorMetadata beyond class/method design:paramtypes and property design:type
  • Runtime DI containers that resolve dependencies by type beyond the reduced class-constructor canary (tsyringe, full NestJS injector behavior, Angular’s root injector)
  • class-validator, type-graphql, TypeORM runtime metadata flows

If your code depends on any of these, the port path is still explicit wiring or a dedicated AOT transform, not relying on the full legacy TypeScript decorator runtime.

The Perry-native idiom is plain classes wired together in a single services.ts module in dependency order. This is how a Go or Rust program would compose services, and it is how decorator-free TS frameworks (Hono, tRPC servers, Drizzle apps) already work.

// services.ts
export const api = new ApiService();
export const rating = new RatingService(api);
export const chat = new ChatService(api, rating);

There is no container, no @Injectable, no providedIn: 'root' — construction order is the dependency graph, and it is checked by the TypeScript compiler.

Migration recipe: an Angular service

The example below is a real service from sharity-app (src/app/services/rating.service.ts, ~80 lines), shown in its original Angular form and ported to Perry.

Before — Angular

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Rating } from '../models/user';

@Injectable({
  providedIn: 'root'
})
export class RatingService {
  private basePath = '/api/ratings';

  constructor(private api: ApiService) { }

  getUserRatings(userId: string): Observable<any> {
    return this.api.get(`${this.basePath}/user/${userId}`);
  }

  createRating(recipientId: string, rating: { stars: number; comment?: string }): Observable<any> {
    return this.api.post(this.basePath, {
      recipientId,
      stars: rating.stars,
      comment: rating.comment,
    });
  }

  calculateAverageRating(ratings: Rating[]): number {
    if (!ratings || ratings.length === 0) return 0;
    const sum = ratings.reduce((acc, curr) => acc + curr.rating, 0);
    return sum / ratings.length;
  }
}

After — Perry

Three mechanical changes:

  1. Drop @Injectable. It carried no information that the class shape does not already carry.
  2. Replace Observable<T> with Promise<T> for HTTP calls. Most Angular Observables-from-HTTP are single-value and behave like Promises. (For multi-value streams, use AsyncIterable.)
  3. Replace constructor-parameter properties (private api: ApiService) with explicit field declarations. Perry supports parameter properties, but explicit fields read more clearly when the class is instantiated by hand rather than by a container.
import { ApiService } from './api.service';
import { Rating } from '../models/user';

export class RatingService {
  private basePath = '/api/ratings';
  private api: ApiService;

  constructor(api: ApiService) {
    this.api = api;
  }

  async getUserRatings(userId: string): Promise<unknown> {
    return this.api.get(`${this.basePath}/user/${userId}`);
  }

  async createRating(
    recipientId: string,
    rating: { stars: number; comment?: string },
  ): Promise<unknown> {
    return this.api.post(this.basePath, {
      recipientId,
      stars: rating.stars,
      comment: rating.comment,
    });
  }

  calculateAverageRating(ratings: Rating[]): number {
    if (!ratings || ratings.length === 0) return 0;
    const sum = ratings.reduce((acc, curr) => acc + curr.rating, 0);
    return sum / ratings.length;
  }
}

Wiring

// services.ts — single source of truth for service construction
import { ApiService } from './services/api.service';
import { RatingService } from './services/rating.service';

export const api = new ApiService();
export const rating = new RatingService(api);
// any consumer
import { rating } from './services';

const avg = rating.calculateAverageRating(myRatings);
const list = await rating.getUserRatings('user-123');

That is the entire migration. The @Injectable decorator, the providedIn: 'root' token, the implicit container lookup — all of it collapses into one new RatingService(api) line in services.ts.

What about Angular components, NestJS controllers, TypeORM entities?

Perry’s reduced legacy path is enough for small Nest-style constructor-injection and route-metadata canaries, but it is not full Angular, NestJS, or TypeORM compatibility. The Path-B option of recognizing @Component / @Controller / @Entity at the compiler level (analogous to Angular Ivy’s AOT step) is reserved for if and when a concrete port needs it — see issue #581 for the tracking discussion. For now, the recommendation is the same: drop the decorator where possible, write the equivalent explicit construction, register routes or schema as plain function calls / module-level constants.

Future direction

New feature work should prefer the TC39 stage-3 form because it aligns better with Perry’s “types erased, compile to native” architecture. The legacy TypeScript path exists for compatibility and will stay focused on narrow AOT-lowerable metadata cases rather than becoming a full tsc decorator runtime.

Limitations

Perry compiles a practical subset of TypeScript. This page documents what’s not supported or works differently from Node.js/tsc.

No Runtime Type Validation

Declared TypeScript types are not enforced at runtime — Perry doesn’t generate type guards from annotations, so a parameter typed string will accept a number without throwing.

function someFunction(): number {
    return 42
}

function erasedTypes(): void {
    // These annotations are erased — no runtime effect
    const x: number = someFunction(); // No runtime check that result is actually a number
    console.log(`erased-types: x=${x}`)
}

Annotations are mostly erased, with one exception: when emitDecoratorMetadata applies, the design:type / design:paramtypes reflection metadata is derived from the annotations on decorated members and survives to runtime (see Decorators). Runtime type discrimination is available via explicit typeof checks and instanceof.

No eval() or Dynamic Code

Perry compiles to native code ahead of time. Dynamic code execution is not possible:

// Not supported
eval("console.log('hi')");
new Function("return 42");

Test262 rows that only observe parsing or executing a code string remain intentional AOT exclusions, not runtime dynamic-code work. This includes the language/white-space/comment-{multi,single}-{form-feed,horizontal-tab,nbsp,space,vertical-tab}.js rows and the direct-eval reference row language/types/reference/8.7.2-1-s.js; they map to the AOT eval tracker (#1677), eval classifier diagnostics (#1678), and the limited literal Function folding work (#1679).

Decorators

Perry parses decorator syntax, supports compile-time-only transforms (see the bundled @log example), and has a reduced legacy TypeScript compatibility path for class decorators, method decorators, constructor parameter decorators, method parameter decorators, and property decorators. That path emits design:paramtypes for decorated classes/methods, design:type for decorated properties, and implements Reflect.defineMetadata, Reflect.getMetadata, Reflect.getOwnMetadata, Reflect.hasMetadata, Reflect.hasOwnMetadata, Reflect.getMetadataKeys, Reflect.getOwnMetadataKeys, Reflect.deleteMetadata, and @Reflect.metadata(...).

Accessor decorators, descriptor replacement, general Reflect.metadata(...) calls outside decorator syntax, Symbol metadata keys, and full Angular / NestJS / TypeORM runtime metadata flows are not supported. See Decorators for details and a worked migration recipe.

No Runtime Metadata Reflection

Perry implements a small metadata subset for legacy decorators. General runtime reflection is not supported:

Reflect.getMetadata("design:type", target, key);
Reflect.getMetadataKeys(target, key);
// Not supported as a general helper call outside decorator syntax
Reflect.metadata("design:type", String)(target, key);

No User-Space CommonJS require()

Use static ESM imports in Perry source:

// Supported
import { foo } from "./module";

// Not supported
const mod = require("./module");
const mod = await import("./module");

Perry has internal CommonJS compatibility paths for some npm package wrappers, but user-written modules should use static import declarations.

Limited Prototype Manipulation

Perry compiles classes to fixed structures. Dynamic prototype modification is not supported:

// Not supported
MyClass.prototype.newMethod = function() {};
Object.setPrototypeOf(obj, proto);

Object.getPrototypeOf(...) and Reflect.getPrototypeOf(...) are supported for class/prototype inspection patterns, but Object.setPrototypeOf(...) / Reflect.setPrototypeOf(...) do not mutate Perry’s fixed class layout.

Weak References Retain Their Targets

WeakMap, WeakSet, WeakRef, and FinalizationRegistry are implemented and their APIs behave as expected — set / get / has / delete, add, deref(), and register / unregister all work and return the right values. WeakMap and WeakSet use reference equality, so two distinct objects never collide on the same slot.

The one caveat is that Perry’s garbage collector does not yet treat these references as weak, so targets are retained rather than collected. The current runtime stores WeakRef targets and FinalizationRegistry registrations in ordinary object/array fields (crates/perry-runtime/src/weakref.rs), and the adjacent GC root scanners do not have a weak-slot clearing/finalizer queue hook yet. In practice:

  • WeakRef.deref() always returns the original target (it is never reported as collected).
  • FinalizationRegistry records registrations but never fires its cleanup callback.
  • WeakMap / WeakSet keep their keys alive (they behave like a reference-keyed Map / Set).

This is safe for correctness — code that reads through these APIs gets the right values. It only matters if you depend on collection timing to reclaim memory or to run finalizer side effects.

Limited Proxy Trapping

Proxy support is not a full engine-level trap layer for every possible dynamic object access. Prefer plain objects and explicit APIs unless a package only needs Perry’s supported Proxy surface.

Threading Model

Perry supports real multi-threading via parallelMap and spawn from perry/thread. See Multi-Threading.

Threads do not share mutable state — closures passed to thread primitives cannot capture mutable variables (enforced at compile time). Values are deep-copied across thread boundaries. There is no SharedArrayBuffer or Atomics.

npm Package Compatibility

Not all npm packages work with Perry:

  • Natively supported: ~50 popular packages (fastify, mysql2, redis, etc.) — these are compiled natively. See Standard Library.
  • compilePackages: Pure TS/JS packages can be compiled natively via configuration.
  • Not supported: Packages requiring native addons (.node files), eval(), dynamic require(), or Node.js internals.

Workarounds

Dynamic Behavior

For cases where you need dynamic behavior, use the JavaScript runtime fallback:

import { jsEval } from "perry/jsruntime";
// Routes specific code through QuickJS for dynamic evaluation

Type Narrowing

Since there’s no runtime type checking, use explicit checks:

function processValue(value: string | number) {
    // Instead of relying on type narrowing from generics
    if (typeof value === "string") {
        // String path
        console.log(`string path: ${value}`)
    } else if (typeof value === "number") {
        // Number path
        console.log(`number path: ${value}`)
    }
}

Next Steps

Porting npm Packages

Status: experimental. This guide — and the port-npm-to-perry skill that ships alongside it — is a first pass at systematizing what Perry contributors have been doing ad-hoc. Results will vary by package. Feedback at issue #115.

Perry compiles a practical subset of TypeScript. Most pure TS/JS packages can be pulled into a native compile via perry.compilePackages, but some will need small patches to avoid the constructs Perry doesn’t support. This page is a field guide for doing that port — by hand, or by driving a coding agent with the prompt template below.

When porting makes sense

SituationTry this first
Package uses native addons (.node files, binding.gyp, node-gyp)Don’t port — no path forward. Find an alternative package or use the QuickJS fallback.
Package is pure TS/JS with only light use of dynamic featuresGood candidate. Add to compilePackages, patch whatever trips the compiler.
Package’s core API is built on Proxy (ORMs, validation DSLs, reactive stores)Probably not portable. The surface Perry-users touch is the Proxy.
Package is pure TS/JS but uses lookbehind regex, Symbol, WeakMap, etc.Patchable. See Common gaps below.

Known-working packages

These work end-to-end via compilePackages with no patches required:

  • hono — the full hono app.fetch surface including middleware (hono/logger, hono/cors, hono/jwt), route groups, JSON responses. Enough for testing and edge-runtime deploys (CF Workers / Vercel Edge / Lambda / Deno Deploy). See HTTP & Networking → Hono. Long-lived HTTP server deployment via @hono/node-server or hand-rolled node:http is currently blocked on #589. Closed via issues #421 / #486 / #487 / #577.
  • @bradenmacdonald/s3-lite-client — pure-TS AWS S3 / S3-compatible storage client (R2, MinIO, B2, Spaces, Supabase, LocalStack). Full SigV4 signing chain (Put/Get/Head/Delete/List + presigned URLs) verified byte-identical to bun. See HTTP & Networking → AWS S3 for the usage pattern. Closed via #551
    • 15 general-purpose stdlib fixes (Web Crypto, Web Streams subclassing, typed-array marshalling, extends Error, namespace imports, etc.).

The workflow

1. Add it to compilePackages

In your project’s package.json:

{
  "perry": {
    "compilePackages": ["@noble/curves", "@noble/hashes"]
  }
}

This is what tells Perry to pull the package into the native compile instead of routing it through a JavaScript runtime. See Project Configuration for the full semantics — including how first-resolved directories get cached so transitive copies dedup.

2. Try compiling

perry compile src/main.ts -o /tmp/port-test && /tmp/port-test

Most of the time this is where you find out what’s actually broken. Compile-time errors cite a file:line in the package — that’s your patch list.

3. Patch the gaps

See Common gaps for the typical fixes. Keep patches minimal and localized — the goal is a clean compile, not a refactor.

Record each patch in a file at your project root (convention: perry-patches/<package>.md) so you can reapply them after npm install blows them away. Until compilePackages grows a native patch-file convention, this is the one bit of maintenance overhead.

4. Re-check after each compile

Iterate: compile, patch the next error, compile again. Don’t try to catch everything in a single pass — some errors only surface after earlier ones are fixed.

Common gaps

Perry’s full limitations list is the canonical reference. In practice, these are the ones you hit when porting:

Lookbehind regex

Perry uses Rust’s regex crate, which doesn’t support lookbehind ((?<=…) / (?<!…)).

// Not supported
str.match(/(?<=prefix)\w+/);

// Rewrite — capture the prefix and slice
const m = str.match(/prefix(\w+)/);
const rest = m ? m[1] : null;

Symbol

Not supported as a primitive. When a package uses Symbol as a sentinel (the common case — e.g., for unique keys in a registry), swap for a string:

// Before
const REGISTRY_KEY = Symbol("registry");

// After
const REGISTRY_KEY = "__pkg_registry__";

When Symbol is used to implement Symbol.iterator/Symbol.asyncIterator, check whether the iteration is actually reached in your use case — often the class has a for-loop method alongside the iterator and you can ignore the iterator path.

Proxy, Reflect

Not supported. These are usually load-bearing for the package’s public API, so porting is often not feasible. If the Proxy is only in an optional path (e.g., dev-mode warnings), delete that branch.

WeakMap / WeakRef / FinalizationRegistry

Not implemented. Swap WeakMap for a regular Map if the GC semantics aren’t critical for correctness (most caches can tolerate this — they’ll just hold references slightly longer).

Decorators

// Not supported
@Component
class Foo {}

// Remove the decorator and inline the behavior, or use a factory function
const Foo = Component(class Foo {});

Dynamic require() / await import(…)

Perry only supports static imports. If a package branches on typeof require !== "undefined" for a Node/browser split, pick the branch that works natively and delete the other.

Prototype manipulation

// Not supported
Object.setPrototypeOf(obj, proto);
MyClass.prototype.newMethod = function() {};

Usually appears in fallback shims for older runtimes. Often dead code in the Perry path — just delete it.

Computed property keys in object literals

// Not supported
const obj = { [key]: value };

// Rewrite
const obj: Record<string, V> = {};
obj[key] = value;

Using a coding agent

A general coding agent (Claude Code, Cursor, Codex, Aider) can drive most of this workflow. If you’re using a skill-aware agent, invoke the port-npm-to-perry skill directly. Otherwise, paste this prompt:

I want to port the npm package <NAME> to run under Perry
(https://github.com/PerryTS/perry). Perry compiles a subset of TypeScript
natively; the subset's gaps are documented at
https://github.com/PerryTS/perry/blob/main/docs/src/language/limitations.md.

Please:

1. Read the package at node_modules/<NAME>/. Check package.json for
   native addons (binding.gyp, gypfile, prebuilds/ — stop if present).
2. Scan for unsupported constructs: eval, new Function, dynamic require,
   Symbol, Proxy, WeakMap, WeakRef, Reflect, decorators, lookbehind
   regex (?<= / ?<!), Object.setPrototypeOf, computed property keys.
3. Report a triage: what rules the package out vs. what's patchable.
4. If patchable: add the package to perry.compilePackages in
   package.json, apply minimal localized patches, and record each
   patch in perry-patches/<NAME>.md.
5. Verify by running `perry compile` against a small file that imports
   the package.

Don't patch blindly — a grep hit inside a string or comment isn't real.
Show me the triage before applying substantial patches.

This is intentionally an agent-agnostic prompt — it’ll work with any competent coding agent. The skill version bundles the same instructions with richer context and is auto-discovered by Claude Code.

Giving feedback

This whole workflow is experimental. If a port fails in a way that feels like Perry should handle it — or if the guide misses a common gap — please comment on issue #115 so we can iterate.

Native bindings — overview

Perry compiles TypeScript to native executables. When user code says import { createConnection } from "mysql2", the call doesn’t bottom out in JavaScript-engine glue — it lands on a Rust function that’s been linked into the binary as extern "C". This page is the map of how that works end-to-end.

The big picture

There are four layers, from most stable to most flexible:

┌─────────────────────────────────────────────────────────────────┐
│  Layer 4: User TypeScript                                        │
│    import { createConnection } from "mysql2";                     │
│    const c = await createConnection({ host, user, password });    │
│    const [rows] = await c.query("SELECT 1");                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ resolved at compile time → maps to
                              │ js_mysql2_* extern "C" symbols
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 3: Bindings packages                                      │
│    Three sources, queried in this order:                          │
│                                                                   │
│    a. node_modules/<name>/ with perry.nativeLibrary               │
│       → the user installed an external binding via                │
│         `bun add @scope/<name>`. Wins over (b) and (c).           │
│                                                                   │
│    b. node_modules/<name>/ without perry.nativeLibrary            │
│       → fall through to V8/JS interpretation.                     │
│                                                                   │
│    c. well-known table (well_known_bindings.toml)                 │
│       → Perry ships the binding in its install. ~30 names like   │
│         dotenv / mysql2 / axios / ws / lru-cache / commander.     │
│                                                                   │
│    d. nothing matches → resolution error at compile time.         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ all wrapper crates depend on this:
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 2: perry-ffi crate (the stable ABI)                       │
│    pub fn alloc_string(s: &str) -> JsString                       │
│    pub fn read_string(JsString) -> Option<&'static str>           │
│    pub struct JsValue(u64); JsPromise; JsClosure; ...             │
│                                                                   │
│    9 surface dimensions: strings, async/Promise, handle           │
│    registry, JsValue/objects/arrays, binary bytes, closures,     │
│    GC root scanner, BigInt, Buffer, JSON-stringify, event-pump.  │
│                                                                   │
│    Wrapper authors depend ONLY on perry-ffi. perry-runtime's     │
│    internals (NaN-box tags, struct layouts) can change between    │
│    releases without breaking wrappers.                            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ implementation detail of:
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  Layer 1: perry-runtime / perry-stdlib internals                 │
│    StringHeader / ArrayHeader / ObjectHeader layouts, NaN-      │
│    boxing tags, generational GC, arena allocator, async runtime,│
│    the 30+ in-tree native modules (perry/ui, perry/thread, ...).│
│    Free to change between Perry releases — the perry-ffi semver  │
│    is the only stable contract.                                  │
└─────────────────────────────────────────────────────────────────┘

The whole point: anyone can publish a binding. A third-party crate ships an npm package containing a Rust crate, a package.json with a perry.nativeLibrary block, and prebuilt staticlibs. Users bun add it. Perry’s compiler picks it up automatically. No PR to the Perry repo, no central registry approval, no @perryts/ namespace required.

Worked example: import { createConnection } from "mysql2"

Step by step, what happens when you perry compile a program with that import:

1. Module resolution

Perry’s resolver (crates/perry/src/commands/compile/resolve.rs) walks each search path looking for node_modules/mysql2/:

  • If node_modules/mysql2/package.json exists with a perry.nativeLibrary block: parse the manifest, treat the package as a native binding. Skip layers (c) and (d).
  • If node_modules/mysql2/ exists without a perry.nativeLibrary block: this is a JS-only npm package; fall through to the V8 / JS interpretation path (separate compilation flow).
  • If node_modules/mysql2/ doesn’t exist at all: consult the well-known table at crates/perry/well_known_bindings.toml. The table maps mysql2perry-ext-mysql2 (a Rust crate that ships in the Perry install). The user didn’t npm install anything; Perry handles it.
  • If nothing matches: compile error pointing at the import line.

2. ABI version check

If the resolved binding has a perry.nativeLibrary.abiVersion field (required from v0.6.0 onwards; warning-only in v0.5.x), Perry verifies the declared semver range covers the bundled perry-ffi version. A binding declaring "0.5" loads under any 0.5.x Perry; one declaring "^1.0" loads only under 1.x. Mismatches are a hard compile error with a recipe pointing at the offending package.

See manifest-v1.md for the full schema.

3. Symbol mapping

The manifest’s functions[] block lists every extern "C" symbol the staticlib exports plus their TypeScript-visible signature:

{
  "functions": [
    {
      "name": "js_mysql2_create_connection",
      "params": ["jsvalue"],
      "returns": "promise"
    },
    {
      "name": "js_mysql2_connection_query",
      "params": ["i64", "string", "jsvalue"],
      "returns": "promise"
    }
  ]
}

Perry’s codegen translates the user’s TS-side calls (mysql.createConnection(config), c.query(sql, params)) into direct calls to these symbols, with the right argument coercion (JsValue NaN-box ↔ f64 ABI shim, string-pointer extraction, etc.).

Those manifest entries are native ABI descriptors, not TypeScript types. A descriptor like f32 or usize chooses the native slot used for the C call and still appears to user code as a JavaScript number; buffer+len consumes one Buffer/Uint8Array-shaped argument and emits (ptr, usize) native slots; handle<T> and promise<T> carry metadata while remaining opaque runtime handles at the boundary. See manifest-v1.md for the full descriptor vocabulary and the legacy string aliases that remain accepted.

4. Linking

The staticlib (libperry_ext_mysql2.a for the well-known case, or a prebuilt artifact in node_modules/mysql2/prebuilt/<target>/ for the external case) joins the link line alongside libperry_runtime.a and libperry_stdlib.a. The js_mysql2_* symbol references in the user’s compiled code resolve at link time.

If the binding ships only Rust source (no prebuilt), Perry runs cargo build --release on the wrapper at compile time. Slow first build, then cached.

5. Runtime

User code runs. Calls into js_mysql2_* happen at native speed — function call overhead is one register-pass for the receiver handle plus one each per param. Promise resolution / closure invocation / async work bridge through perry-ffi’s surface (JsPromise, JsClosure, spawn_blocking + tokio::Handle::current().block_on). The wrapper sees Perry’s NaN-boxed JsValues directly; user TypeScript sees a normal Promise / object / array.

What perry-ffi guarantees

The 9 surface dimensions perry-ffi exposes today are:

SurfaceWhat it doesDocumented at
StringsJsString / alloc_string / read_string / read_bytes / alloc_bytesabi.md
Async / PromiseJsPromise (new / resolve / reject_string), spawn_blockingabi.md
Handlesregister_handle / get_handle / with_handle / take_handle / iter_handles_ofabi.md
JsValue + objects/arraysJsValue, js_array_alloc/push/get/set, js_object_alloc_with_shape, js_object_get_field, js_object_set_field, build_object_shapeabi.md
ClosuresJsClosure::call0..4abi.md
GC root scannergc_register_root_scannerabi.md
BigIntBigIntHeader, alloc_bigint_from_str, read_bigint_limbsabi.md
BufferBufferHeader, alloc_buffer, read_buffer_bytesabi.md
JSON-stringifyjson_stringify(JsValue) -> Option<String>abi.md
Event pumpnotify_main_threadabi.md

A wrapper that uses anything outside this list (e.g. reaches into perry_runtime::* types directly) is off-contract — its build will break the next time those types change. Stay on perry-ffi.

The abi.md page is the source of truth for what’s in each surface. The semver promise: breaking changes to anything documented there bump perry-ffi major, regardless of what perry-runtime does internally.

Code organization

crates/
  perry-ffi/              ← Layer 2: the stable ABI surface
  perry-runtime/          ← Layer 1: NaN-boxing, GC, arena, JS objects
  perry-stdlib/           ← Layer 1: in-tree wrappers (perry/ui, fs,
                            crypto helpers, etc. — anything genuinely
                            coupled to runtime internals)
  perry-ext-<name>/       ← Layer 3, well-known: mysql2, pg, ioredis,
                            cron, decimal, dayjs, axios, ethers,
                            commander, … (~27 today). All depend on
                            perry-ffi only.

External native bindings (Layer 3, third-party — Rust + perry-ffi):
  PerryTS/tursodb-bindings    → bun add @perryts/tursodb
  PerryTS/iroh-bindings       → bun add @perryts/iroh
  <anyone>/whatever-bindings  → user publishes themselves

External pure-TypeScript drivers (compiled via compilePackages):
  PerryTS/postgres            → bun add @perryts/postgres
  PerryTS/mysql               → bun add @perryts/mysql
  PerryTS/mongodb             → bun add @perryts/mongodb
  PerryTS/redis               → bun add @perryts/redis

The split between well-known in-tree wrappers and external is a packaging convention, not a technical distinction. Both depend only on perry-ffi; both ship extern "C" symbols Perry’s codegen calls. The well-known set is the ~30 packages every JS dev expects to import without an npm install step (dotenv, axios, mysql2, …). External wrappers are everything else.

The two existing external native wrappers (tursodb, iroh) cover functionality that doesn’t have an in-tree perry-stdlib equivalent — they’re net-new bindings that originated as third-party packages. That validates the contract: perry-ffi is sufficient to write a real wrapper without forking Perry.

Three paths to a database driver (postgres / mysql / mongodb / redis)

Perry currently ships two parallel database-driver families. Picking one is a packaging trade-off, not a feature trade-off:

PathInstallResolver layerWhat it is
Well-known native bindingnothing (bundled)(c)import 'mysql2' / import 'pg' / import 'mongodb' route to in-tree perry-ext-mysql2 / perry-ext-pg / perry-ext-mongodb. Rust wrappers around sqlx / mongodb crates. Versioned in lockstep with Perry.
@perryts/{postgres,mysql,mongodb,redis}bun add @perryts/postgres(a)Pure-TypeScript wire-protocol drivers — no Rust, no native dep. Use Perry’s compilePackages to compile the TS to native via LLVM. Also run unmodified on Node.js / Bun. Independent semver.
External native bindingbun add @perryts/tursodb(a)Third-party Rust crate using perry-ffi, manifest at package.json::perry.nativeLibrary. Today: @perryts/tursodb, @perryts/iroh.

Resolution precedence (per layer (a) → (b) → (c) above): an installed @perryts/mysql does not override import 'mysql2' because the package names are different. If you bun add @perryts/mysql and also import 'mysql2' in the same program, both drivers ship in the binary — they’re independent. To opt out of the well-known mysql2 shim, just don’t import mysql2.

When to pick which:

  • Well-known native (mysql2 / pg / mongodb) — zero install step, fastest path to “it works”; you accept that the driver’s feature set tracks Perry’s release cadence.
  • @perryts/postgres / @perryts/mysql / @perryts/mongodb / @perryts/redis — you want to read / fork / patch the driver in plain TypeScript; you want the same code running on Node.js or Bun for fallback; you need a feature ahead of Perry’s next release.
  • External native binding — you’re wrapping a Rust crate that doesn’t have a JS-only equivalent (Tursodb’s embedded SQLite- compatible engine, Iroh’s QUIC transport).

Concrete how-tos

If you want to …Read
Use mysql2 / dotenv / etc. in a Perry programNothing! import and go — Perry ships them in the well-known set.
Use a third-party native bindingbun add <package>, then import. Perry’s resolver finds it via node_modules/<pkg>/package.json.
Find which packages ship out-of-the-boxperry native list
Write your own native bindingperry native init my-bindings scaffolds the Cargo crate + package.json + release.yml for prebuilds. Then read abi.md for the perry-ffi surface and manifest-v1.md for the manifest schema.
Verify your binding’s manifest matches its .acd my-bindings && perry native validate (runs cargo build --release, walks nm -gP over the staticlib, diffs against functions[], reports missing or undeclared symbols).
Override a well-known bindingInstall your fork into node_modules/<name>/ with a perry.nativeLibrary block. Resolution layer (a) wins over layer (c).
See what stdlib APIs Perry implementsAuto-generated from the manifest: docs/src/api/reference.md. The perry types command writes a current snapshot to .perry/types/stdlib/index.d.ts for editor squiggles.

Authoring a binding — the 60-second tour

# Scaffold
perry native init my-pdf --description "PDF rendering bindings" \
  --upstream-dep 'pdfium-render = "0.8"'
cd my-pdf

# Edit src/lib.rs — add your `js_*` functions, all using only
# `perry_ffi::*` types
$EDITOR src/lib.rs

# Edit src/index.ts — declare the TS surface user code imports
$EDITOR src/index.ts

# Edit package.json — list every js_* export in the
# perry.nativeLibrary.functions[] block
$EDITOR package.json

# Verify
perry native validate
# ✅ manifest matches the staticlib

# Publish
git tag v0.1.0 && git push --tags  # the scaffolded release.yml
                                    # builds prebuilts for all targets
                                    # and attaches them to the release
npm publish

A user can now bun add my-pdf and import { renderPdf } from "my-pdf" in their Perry program.

Versioning policy

  • perry-ffi semver: tracks Perry’s minor today (perry-ffi = "0.5" for Perry 0.5.x). Backwards-incompatible changes to anything documented in abi.md bump perry-ffi major — independent of perry-runtime. Wrappers depend on perry-ffi = "0.5" and stay buildable across Perry’s 0.5.x releases.
  • Manifest spec v1: locked at abiVersion: "0.5"; missing field is warning-only in v0.5.x, hard error from v0.6.0. Schema changes bump the spec version (v2) and ship alongside a new manifest schema file.
  • Wrappers: each ships independent semver; users bun update a binding without touching Perry.

Consumption today (v0.5.x)

Until the v0.6.0 type-source-of-truth refactor lands, perry-ffi is not yet on crates.io. External wrappers depend on it via git URL:

[dependencies]
perry-ffi = { git = "https://github.com/PerryTS/perry", branch = "main" }

PerryTS/tursodb-bindings and PerryTS/iroh-bindings use this shape and cargo build against live main. The git-URL approach is the supported consumption mechanism for the v0.5.x cycle; the v0.6.0 plan inverts type ownership so perry-ffi becomes the source of truth and can publish to crates.io as perry-ffi = "0.6".

Limits

  • Bindings are build-time linked. Perry doesn’t dlopen plugins at runtime — the staticlib joins the link line, the binary stands on its own.
  • Bindings can’t bring their own JS runtime — they extend Perry’s, not replace it. A binding that wants its own GC / event loop / threading is out of scope.
  • Cross-target prebuilds are the binding author’s responsibility. The scaffolded GitHub Actions workflow handles the common matrix (x86_64+aarch64 macOS/Linux + Windows); other targets need manual additions.

Next pages

  • abi.md — the perry-ffi surface, reference grade.
  • manifest-v1.md — the perry.nativeLibrary schema, every field documented.
  • API reference — auto-generated list of every stdlib symbol Perry implements.

Authoring a native binding

Step-by-step guide to writing and publishing a Rust binding that Perry programs can import like any npm package.

For the architectural picture this fits into, see Native bindings — overview.

Prerequisites

  • A Rust crate you want to expose to TypeScript (e.g. pdfium-render, image, your own internal library).
  • Rust toolchain installed.
  • perry on your PATH (the perry native subcommand ships with the install).
  • A GitHub account if you want the prebuild release-CI scaffold to Just Work.

1. Scaffold

perry native init my-bindings \
  --description "Native bindings for <upstream crate>" \
  --upstream-dep '<crate-name> = "<version>"' \
  --github-owner <your-handle>

cd my-bindings

This creates:

my-bindings/
├── Cargo.toml                           # perry-ffi dep + your upstream
├── src/
│   ├── lib.rs                           # one example #[no_mangle] fn
│   └── index.ts                         # TS surface user code imports
├── package.json                         # perry.nativeLibrary block
├── README.md
├── LICENSE                              # MIT, swap if needed
├── .gitignore
└── .github/workflows/release.yml        # multi-target prebuild on tag

2. Add bindings

Each TypeScript-visible function should forward to one extern "C" Rust export.

src/lib.rs

The example template starts with one js_<name>_hello function. Replace it with your bindings — one #[no_mangle] pub extern "C" fn per TypeScript-visible call, using only types from perry_ffi:

#![allow(unused)]
fn main() {
use perry_ffi::{alloc_string, StringHeader};

/// `pdf.parse(buf) -> string` — extract text from a PDF buffer.
///
/// # Safety
///
/// `buf_ptr` must be null or valid for `buf_len` bytes. Perry passes
/// this pair from a `buffer+len` manifest parameter.
#[no_mangle]
pub unsafe extern "C" fn js_pdf_parse(buf_ptr: *const u8, buf_len: usize) -> *mut StringHeader {
    let bytes = if buf_ptr.is_null() {
        &[]
    } else {
        std::slice::from_raw_parts(buf_ptr, buf_len)
    };
    match pdfium_render::Pdfium::default().load_pdf_from_byte_slice(bytes, None) {
        Ok(doc) => {
            let text = doc.pages().iter().map(|p| p.text().unwrap()).collect::<String>();
            alloc_string(&text).as_raw()
        }
        Err(_) => std::ptr::null_mut(),
    }
}
}

Key rules:

  • Don’t use perry_runtime::*. perry-runtime’s internals (NaN-box tags, struct layouts) change between Perry releases. perry-ffi is the stable contract.
  • Use unsafe extern "C" for any function that takes pointer args. *const StringHeader etc. require unsafe at the call site.
  • Document # Safety for unsafe fns — at minimum say “the pointer must be null or a Perry-runtime <Header>”.
  • Async returns *mut Promise. Pattern: JsPromise::new()spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async {...}); promise.resolve(...) }) → return promise.as_raw().

src/index.ts

Declare the FFI symbol from your manifest, then export the TypeScript surface user code imports. Perry’s FFI dispatch keys on the call-site identifier, so the wrapper body must explicitly call the js_* symbol listed in perry.nativeLibrary.functions[].

declare function js_pdf_parse(buf: Uint8Array): string;

/**
 * Extract text from a PDF buffer.
 */
export function parse(buf: Uint8Array): string {
  return js_pdf_parse(buf);
}

package.json

The perry.nativeLibrary block tells Perry’s compiler about every extern "C" export plus the build config. Schema details in manifest-v1.md.

{
  "name": "my-bindings",
  "version": "0.1.0",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "perry": {
    "nativeLibrary": {
      "abiVersion": "0.5",
      "functions": [
        {
          "name": "js_pdf_parse",
          "params": [{ "kind": "buffer+len" }],
          "returns": "string"
        }
      ],
      "targets": {
        "macos":   { "crate": "native", "lib": "perry_ext_my_bindings" },
        "linux":   { "crate": "native", "lib": "perry_ext_my_bindings" },
        "windows": { "crate": "native", "lib": "perry_ext_my_bindings" }
      }
    }
  }
}

Every entry in functions[] must:

  • have a name matching exactly the symbol the staticlib exports (Perry’s perry native validate verifies this for you)
  • declare params and returns so codegen knows the calling convention

Manifest descriptors are native ABI descriptors, not TypeScript surface types. Existing strings such as "string", "number", "i64", and "void" still work. Use the explicit vocabulary when you need native precision or metadata: "f32", "u32", "u64", "usize", "buffer_len", { "kind": "buffer+len" }, { "kind": "handle", "type": "MyThing" }, and { "kind": "promise", "result": "jsvalue" }.

Handles are opaque Perry native handle objects in JavaScript, not raw numbers. Legacy "handle" and "handle<T>" descriptors still parse as borrowed handles; structured descriptors can add ownership, nullability, thread affinity, debug names, and an owned-return finalizer:

{
  "kind": "handle",
  "type": "MyThing",
  "ownership": "owned",
  "thread": "creator",
  "finalizer": "my_thing_free",
  "debugName": "MyThing"
}

The finalizer symbol must use void(ptr, ptr) ABI and must only free native resources. It can run from GC finalization, so it must not call Perry JS APIs or allocate JS values. Use "ptr" when an API intentionally accepts a raw pointer payload instead of a managed handle.

3. Verify

perry native validate

This runs cargo build --release, locates the resulting .a, walks nm -gP over its symbols, and diffs against the manifest’s functions[]. The output flags two failure modes:

  • ❌ declared function has NO matching symbol — your manifest lists a function the staticlib doesn’t export. Either you typo’d the name, or you forgot #[no_mangle].
  • js_* symbol NOT in the manifest — your staticlib exports a function user code can’t reach. Either add it to functions[], rename it (drop the js_ prefix), or remove it.

A green run looks like:

perry native validate
======================
  package:    my-bindings
  abiVersion: 0.5
  staticlib:  ./target/release/libperry_ext_my_bindings.a
  declared functions:           1
  exported `js_*` symbols:      1
  ✅ manifest matches the staticlib.

4. Test in a Perry program

In a separate directory:

mkdir test-app && cd test-app
perry init
bun add file:../my-bindings   # or any path your tooling supports

Add to your TS:

import { parse } from "my-bindings";
const buf = await Bun.file("input.pdf").bytes();
console.log(parse(buf));

Then perry compile main.ts -o main && ./main.

5. Publish

Tag a release

git tag v0.1.0
git push --tags

The scaffolded .github/workflows/release.yml builds prebuilt staticlibs for x86_64 + aarch64 macOS/Linux + Windows on tag and attaches them to the GitHub release. Add or remove targets in the workflow’s matrix block as needed.

npm publish

npm publish

The scaffolded package.json includes the right files: [...] list to bundle src/ + Cargo.toml + the README. If you also vendor the prebuilt artifacts in the npm tarball, add them to the files block.

Two distribution models

There are two ways your users get the staticlib:

ModelWhat ships in the npm tarballTrade-off
Vendor prebuiltssrc/, Cargo.toml, AND prebuilt/<target>/lib<name>.a for every targetBigger npm tarball; install is fast (no compile); user doesn’t need a Rust toolchain
Source-onlysrc/, Cargo.toml, no prebuiltsTiny tarball; first perry compile runs cargo build --release (slow); user needs Rust

Vendoring is the friendlier default for npm consumers. Source-only makes sense if your matrix is too big for one tarball or if you’re publishing a private wrapper to a small audience.

The manifest’s targets.<target>.prebuilt field tells Perry where to find a prebuilt for the user’s compile target:

{
  "perry": {
    "nativeLibrary": {
      "targets": {
        "macos":   { "prebuilt": "./prebuilt/macos/libperry_ext_my_bindings.a" },
        "linux":   { "prebuilt": "./prebuilt/linux/libperry_ext_my_bindings.a" },
        "windows": { "prebuilt": "./prebuilt/windows/perry_ext_my_bindings.lib" }
      }
    }
  }
}

If a prebuilt field is present, Perry treats the archive as required for that target and fails with a diagnostic when it cannot be resolved or linked. Omit prebuilt and provide crate / lib when the target should build from source instead.

Backend-aware packaging

Graphics or compute wrappers can describe backend-owned artifacts without adding app-specific APIs to Perry. Put backend metadata under the target that can actually use it:

{
  "perry": {
    "nativeLibrary": {
      "targets": {
        "ios": {
          "prebuilt": "./prebuilt/ios/libdemo.a",
          "backends": {
            "metal": {
              "frameworks": ["Metal", "QuartzCore"],
              "shaderSources": ["shaders/default.metal"],
              "shaderOutputs": ["prebuilt/default.metallib"],
              "resources": ["resources/metal"]
            }
          }
        },
        "linux": {
          "prebuilt": "./prebuilt/linux/libdemo.a",
          "backends": {
            "vulkan": {
              "libs": ["vulkan"],
              "shaderOutputs": ["prebuilt/default.spv"]
            }
          }
        },
        "windows": {
          "prebuilt": "./prebuilt/windows/demo.lib",
          "backends": {
            "d3d12": {
              "libs": ["d3d12", "dxgi", "dxguid"],
              "shaderOutputs": ["prebuilt/default.dxil"]
            }
          }
        }
      }
    }
  }
}

Perry validates the backend/target pairing early: Metal is Apple-only, Vulkan is available on macOS/Linux/Windows/Android/HarmonyOS, and D3D12 is Windows-only. Precompiled shader outputs and resources are copied under NativeLibraries/<package>/<backend>/ in app bundles.

6. Update over time

  • A new perry-ffi feature lands: bump your Cargo.toml’s perry-ffi version, rebuild prebuilts, tag a new release. Users bun update to pick it up. Perry’s manifest spec stays at v1 unless the schema changes.
  • A new Perry minor: same — perry-ffi’s semver moves with Perry’s minor. The git-URL consumption (v0.5.x) means rebuilding against main picks it up automatically.
  • Breaking change to the js_* surface you exported: bump your package’s major version (1.0.02.0.0). Users who pin a major aren’t affected.

Common patterns

Async one-shot (HTTP request, DB query)

#![allow(unused)]
fn main() {
use perry_ffi::{alloc_string, spawn_blocking, JsPromise, JsValue, Promise};

#[no_mangle]
pub extern "C" fn js_my_fetch(url_ptr: *const StringHeader) -> *mut Promise {
    let promise = JsPromise::new();
    let raw = promise.as_raw();
    let url = unsafe { read_str(url_ptr) }.unwrap_or_default();

    spawn_blocking(move || {
        let outcome = tokio::runtime::Handle::current().block_on(async move {
            reqwest::get(&url).await.and_then(|r| Ok(r.text())).await
        });
        match outcome {
            Ok(body) => promise.resolve(JsValue::from_string_ptr(alloc_string(&body).as_raw())),
            Err(e)   => promise.reject_string(&format!("fetch: {}", e)),
        }
    });
    raw
}
}

Sync handle-based class

Use a handle descriptor for synchronous resource-style APIs. The native function receives or returns the raw resource pointer as i64, while TypeScript callers see only the opaque handle object.

#![allow(unused)]
fn main() {
use perry_ffi::{get_handle, register_handle, Handle};

pub struct MyThing { val: u64 }

#[no_mangle]
pub extern "C" fn js_my_thing_new() -> Handle {
    register_handle(MyThing { val: 0 })
}

#[no_mangle]
pub extern "C" fn js_my_thing_get(h: Handle) -> f64 {
    get_handle::<MyThing>(h).map(|t| t.val as f64).unwrap_or(0.0)
}
}

Event listeners (.on(event, cb))

#![allow(unused)]
fn main() {
use perry_ffi::{
    gc_register_root_scanner, get_handle_mut, iter_handles_of, register_handle,
    Handle, JsClosure, RawClosureHeader, StringHeader,
};

pub struct EventEmitter {
    listeners: Vec<i64>,  // closure pointers, kept alive by the GC scanner below
}

static SCANNER_REGISTERED: std::sync::Once = std::sync::Once::new();

fn ensure_scanner() {
    SCANNER_REGISTERED.call_once(|| {
        gc_register_root_scanner(|mark| {
            iter_handles_of::<EventEmitter, _>(|emitter| {
                for &cb in &emitter.listeners {
                    if cb != 0 {
                        let nan_boxed = f64::from_bits(0x7FFD_0000_0000_0000 | (cb as u64 & 0x0000_FFFF_FFFF_FFFF));
                        mark(nan_boxed);
                    }
                }
            });
        });
    });
}

#[no_mangle]
pub extern "C" fn js_emitter_on(h: Handle, cb: i64) -> Handle {
    ensure_scanner();
    if let Some(e) = get_handle_mut::<EventEmitter>(h) {
        e.listeners.push(cb);
    }
    h
}

#[no_mangle]
pub extern "C" fn js_emitter_emit(h: Handle, arg: f64) -> bool {
    if let Some(e) = get_handle_mut::<EventEmitter>(h) {
        for &cb in e.listeners.clone().iter() {
            let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) };
            let _ = unsafe { closure.call1(arg) };
        }
        true
    } else {
        false
    }
}
}

The GC scanner is load-bearing: without it, a malloc-triggered GC between .on(cb) and .emit() will sweep the closure and the next emit calls freed memory. Always register a scanner if your handles store closure pointers.

When to extend the perry-ffi surface

If your binding genuinely needs something perry-ffi doesn’t expose, file an issue against PerryTS/perry describing:

  • the binding you’re writing,
  • the perry-runtime function/type you’d otherwise reach into,
  • why a higher-level perry-ffi entry would generalize.

The bar for adding to perry-ffi is high — every helper is a forever commitment — but real wrappers driving real needs is exactly the right input. The recent additions (BigInt + Buffer in v0.5.556, JSON-stringify + event-pump in v0.5.567 followups) all came from specific wrappers needing them.

Don’t reach into perry_runtime::* directly to “unblock” your wrapper today — it’ll break the next time those internals change.

See also

perry-ffi — the stable ABI for native bindings

This page documents the contract between native bindings packages (perryts/mysql2-bindings, @perry/iroh, perry-ext-dotenv, …) and the Perry runtime they execute inside.

New here? Start with Native Bindings — Overview for the architectural picture and the Authoring Guide for the step-by-step. This page is reference-grade detail.

It is intentionally short. The whole point of the contract is minimum surface area — every helper added is a forever commitment, and Perry’s internals (string layout, NaN-boxing tags, GC) are free to change underneath as long as this surface holds.

Versioning

perry-ffi ships its own semver, currently tracking Perry’s minor: perry-ffi = "0.5" for Perry 0.5.x.

v0.5.x consumption (current): until the v0.6.0 type-source-of-truth refactor lands, perry-ffi re-exports a handful of types from perry-runtime (StringHeader, ArrayHeader, ObjectHeader, BigIntHeader, BufferHeader, ClosureHeader, Promise). That makes it un-publishable to crates.io as-is — perry-runtime is a private dep and not something we want on crates.io. Wrappers depend on perry-ffi via the git URL while we’re in the v0.5.x cycle:

[dependencies]
perry-ffi = { git = "https://github.com/PerryTS/perry", branch = "main" }

PerryTS/tursodb-bindings and PerryTS/iroh-bindings ship this shape and cargo build against the live main branch.

v0.6.0 plan (deferred): invert the type ownership so perry-ffi becomes the source of truth — it defines #[repr(C)] versions of the ABI types itself, exposes opaque pointers for Promise / ClosureHeader (which have private state), and perry-runtime imports the types from perry-ffi. At that point perry-ffi has zero perry-runtime deps and can publish to crates.io as perry-ffi = "0.6" — wrappers switch to cargo add perry-ffi. Tracked under #466 Phase 1 as a v0.6.0 followup; the v0.5.x git-URL approach is supported and tested end-to-end in the meantime.

A wrapper’s package.json declares the ABI it was built against:

{
  "perry": {
    "nativeLibrary": {
      "abiVersion": "0.5",
      "...": "..."
    }
  }
}

The Perry compiler refuses to load a wrapper whose declared abiVersion doesn’t satisfy the bundled perry-ffi’s semver range (strict enforcement lands under issue #466 Phase 2). Backwards- incompatible changes to anything in this document bump perry-ffi’s major version — independent of perry-runtime semver.

Surface (v0.5.x)

The current surface is deliberately minimal — just enough to port the simplest stdlib wrappers (dotenv, nanoid, uuid, slugify). It will grow as real wrappers demand it; we’d rather under-design and add than commit to a helper we later regret.

Strings

#![allow(unused)]
fn main() {
pub struct JsString(/* opaque */);

pub fn alloc_string(s: &str) -> JsString;
pub fn read_string(handle: JsString) -> Option<&'static str>;

impl JsString {
    pub unsafe fn from_raw(ptr: *mut StringHeader) -> Self;
    pub fn as_raw(self) -> *mut StringHeader;
    pub fn is_null(self) -> bool;
}

pub use perry_runtime::StringHeader; // for `*mut StringHeader` in extern "C" sigs
}

alloc_string allocates a fresh string in the runtime’s arena. The handle is owned by the runtime — Perry’s GC reclaims it once no live references remain, including references held by JS code your function returned the handle to.

read_string borrows the underlying UTF-8 bytes for the duration of the FFI call. Returns None on a null handle or invalid UTF-8.

StringHeader is re-exported as the canonical type for extern "C" return / parameter types — wrappers should write pub extern "C" fn js_my_module_thing() -> *mut perry_ffi::StringHeader, not import StringHeader from perry-runtime directly.

What’s NOT in v0.5

These will land as real wrappers force them, tracked under #466 Phase 1’s “Open questions”:

  • Array allocation / read (alloc_array, read_array).
  • Object field get / set.
  • Closure invocation helpers.
  • NaN-boxing constants (undefined / null / true / false).
  • Async runtime sharing (spawn_async, block_on).
  • BigInt allocation.

If your wrapper needs one of these today, add it to perry-ffi in the same PR that ports the wrapper. Treat this document as the review gate: any addition needs a one-line entry above and a unit test in crates/perry-ffi/src/lib.rs.

Reference example: perry-ext-dotenv

The smallest stdlib wrapper Perry ships is the acceptance test for the surface above. Its full FFI surface is two functions:

#![allow(unused)]
fn main() {
use perry_ffi::{alloc_string, read_string, JsString, StringHeader};

#[no_mangle]
pub unsafe extern "C" fn js_dotenv_config_path(
    path_ptr: *const StringHeader,
) -> f64 {
    let handle = JsString::from_raw(path_ptr as *mut _);
    let path = read_string(handle).unwrap_or(".env");
    // … read file, set env vars, return 1.0 / 0.0 …
}

#[no_mangle]
pub unsafe extern "C" fn js_dotenv_parse(
    content_ptr: *const StringHeader,
) -> *mut StringHeader {
    let handle = JsString::from_raw(content_ptr as *mut _);
    let Some(content) = read_string(handle) else {
        return std::ptr::null_mut();
    };
    let parsed = parse_dotenv_content(content);
    let json = serde_json::to_string(&parsed).unwrap_or_else(|_| "{}".into());
    alloc_string(&json).as_raw()
}
}

Source: crates/perry-ext-dotenv/src/lib.rs.

It depends only on perry-ffi and serde_json. Zero references to perry-runtime internals. That’s the bar for every wrapper that moves out of perry-stdlib over the course of #466 Phase 5.

Followup roadmap

  • #466 Phase 2 freezes the perry.nativeLibrary manifest spec and enforces abiVersion at resolve time.
  • #466 Phase 3 adds perry native init/validate/prebuild for scaffolding new wrapper packages.
  • #466 Phase 4 adds the well-known bindings table so import 'dotenv' resolves to perry-ext-dotenv automatically — until it lands, import 'dotenv' continues to bind to the perry-stdlib copy.
  • #466 Phase 5 ports the rest of the wrappers in size order (uuid, nanoid, slugify, bcrypt, argon2, then ws, then the database batch).

perry.nativeLibrary manifest — spec v1

New here? Start with Native Bindings — Overview for the architectural picture and the Authoring Guide for a step-by-step that uses this manifest. This page is reference-grade detail.

This page is the authoritative spec for the perry.nativeLibrary field a native-bindings package declares in its package.json. The Perry compiler reads this manifest at resolve time and uses it to:

  1. Decide whether the import is “native” (calls into a Rust staticlib) vs. plain TypeScript / JavaScript.
  2. Map TypeScript-side function calls onto the right extern "C" symbol with the right calling convention.
  3. Pull the right .a archive into the link line, with the right frameworks / system libs / pkg-config dependencies for the user’s compile target.

A companion JSON schema lives at docs/api/manifest.schema.json for editor validation.

Versioning

The schema is versioned via the abiVersion field. Every wrapper declares which perry-ffi ABI it was built against:

{
  "perry": {
    "nativeLibrary": {
      "abiVersion": "0.5",
      "...": "..."
    }
  }
}

The perry binary refuses to load a wrapper whose declared abiVersion doesn’t satisfy the bundled perry-ffi’s semver range.

Transitional rule for the v0.5.x cycle: missing abiVersion is allowed but emits a warning naming the package and pointing at this spec. From v0.6.0 onwards it becomes a hard error.

See docs/src/native-libraries/abi.md for what the v0.5 ABI surface actually contains.

Top-level shape

{
  "perry": {
    "nativeLibrary": {
      // Required from v0.6.0; warning-only in v0.5.x.
      "abiVersion": "0.5",

      // FFI function declarations — what TypeScript-side
      // call sites bind to. See "Functions" below.
      "functions": [
        { "name": "js_my_thing", "params": ["string"], "returns": "string" }
      ],

      // Per-target build configuration. Optional; if omitted, no
      // crate is built and the wrapper is purely a `.d.ts`-style
      // declaration of pre-built symbols (rare).
      "targets": {
        "macos":     { "...": "..." },
        "ios":       { "...": "..." },
        "linux":     { "...": "..." },
        "windows":   { "...": "..." },
        "android":   { "...": "..." },
        "web":       { "...": "..." },
        "harmonyos": { "...": "..." },
        "tvos":      { "...": "..." },
        "watchos":   { "...": "..." },
        "visionos":  { "...": "..." }
      }
    }
  }
}

abiVersion

Semver string (e.g. "0.5", "0.5.3", "^0.5").

The compiler interprets this as a range. The range must include the bundled perry-ffi’s exact version. A wrapper declaring "0.5" loads under any 0.5.x Perry; one declaring "0.5.3" loads only when the runtime is exactly 0.5.3.

When the runtime fails the range check, compilation aborts with:

error: native library `<package>` declares perry-ffi ABI "0.5"
         but this Perry build ships perry-ffi 0.6.1.
       Update the package or use an older Perry release.

functions

Array of function declarations. Each entry binds a TypeScript-visible name to an extern "C" symbol exported by the wrapper’s staticlib.

FieldTypeRequiredNotes
namestringyesSymbol name (Perry prepends an underscore on macOS).
paramsABI descriptor[]yesParameter ABI descriptors — see “Param types” below.
returnsABI descriptoryesReturn ABI descriptor — see “Return types” below.

ABI descriptors describe the native calling convention, not the TypeScript type system. Perry keeps three layers separate:

  • JS-visible values (number, string, opaque handles, promises)
  • native ABI descriptors in the manifest (f32, usize, buffer+len)
  • lowered LLVM/C ABI slots (double, i64, ptr, etc.)

Existing string spellings remain valid. The canonical descriptor vocabulary is:

jsvalue, string, bool, i32, i64, i64_str, u32, u64, usize,
f32, f64, number, ptr, buffer_len, buffer+len, handle<T>,
promise<T>, pod, void

number is a compatibility alias for f64; js_value and boolean are compatibility aliases for jsvalue and bool. Bare handle is the same as an untyped handle<T>. Bare promise is the same as promise<jsvalue>. Unlike handles and promises, pod has no string-only spelling; use object form so the field order and scalar ABI types are explicit.

Descriptors with metadata may also use object form:

{ "kind": "handle", "type": "MyThing" }
{
  "kind": "handle",
  "type": "MyThing",
  "ownership": "owned",
  "nullable": true,
  "thread": "creator",
  "finalizer": "my_thing_free",
  "debugName": "MyThing"
}
{ "kind": "promise", "result": "jsvalue" }
{ "kind": "buffer+len" }
{
  "kind": "pod",
  "name": "Packet",
  "fields": [
    { "name": "tag", "type": "u32" },
    { "name": "count", "type": "usize" },
    { "name": "weight", "abi": { "kind": "f32" } }
  ]
}

Structured handles are GC-managed Perry native handle objects on the JavaScript side. They are opaque and branded; user code cannot forge a valid handle by passing a number or ordinary object. Use "ptr" only when you intentionally want the raw pointer payload escape hatch.

Handle fields:

FieldValuesDefaultNotes
typestringuntypedBranded handle type. Legacy "handle<T>" maps here.
ownership"borrowed" / "owned""borrowed"Owned return handles may run a native finalizer. Params may not declare finalizers.
nullablebooleanfalseNullable handles may wrap a null resource pointer and unwrap to 0. Non-null descriptors reject null handles.
thread"any" / "main" / "creator""any"Runtime validation rejects use from the wrong thread.
finalizersymbol stringnoneValid only on owned return handles. The symbol must have void(ptr, ptr) ABI and must not call Perry JS APIs during GC.
debugNamestringtype or "handle"Stored inline for diagnostics.

POD descriptors are parameter-only. A POD parameter describes one closed JavaScript object shape that Perry can copy into verifier-backed C-layout storage and pass to native code as a pointer. The fields array is ordered, and field order is part of the ABI. Each field must have a non-empty name and exactly one of type or abi.

POD field types are restricted to numeric ABI scalars that have stable C layout:

i32, i64, u32, u64, usize, f32, f64, number, buffer_len

number aliases f64; buffer_len is a u32 byte-length scalar. Dynamic or pointerful descriptors such as jsvalue, string, bool, ptr, buffer+len, handle, promise, nested pod, and void are rejected in POD fields.

Param types

Manifest descriptorMaps to Rust signatureTypeScript callsite view
"jsvalue"f64raw Perry NaN-boxed value
"string"*const StringHeaderstring
"bool"i32 truthy flagboolean
"i32"i32number truncated to signed 32-bit
"i64"i64number converted to signed 64-bit
"u32"u32number converted to unsigned 32-bit
"u64"u64number converted to unsigned 64-bit
"usize"usizenumber converted to pointer-sized unsigned integer
"f32"f32number narrowed to 32-bit float
"f64" / "number"f64number
"ptr"i64 raw boxed pointer payloadraw pointer escape hatch
"buffer_len"u32 byte lengthnumber
"buffer+len"(*const u8, usize)one Buffer/Uint8Array-shaped argument
"handle" / "handle<T>"i64 unwrapped resource pointeropaque native handle
"promise" / "promise<T>"i64 promise handlePromise handle metadata
{ "kind": "pod", ... }pointer to C-layout record storageone object-shaped argument

Return types

Manifest descriptorRust signatureTypeScript view
"jsvalue"-> f64raw Perry NaN-boxed value
"string"-> *const u8 (see note)string
"ptr"-> *const u8 (see note)string legacy pointer return
"i64_str"-> i64string (the i64 is a *StringHeader)
"bool"-> i32boolean
"i32"-> i32number
"i64"-> i64number
"u32"-> u32number
"u64"-> u64number
"usize"-> usizenumber
"f32"-> f32number via explicit f32 -> f64 materialization
"f64" / "number"-> f64number
"buffer_len"-> u32number
"handle" / "handle<T>"-> i64 resource pointeropaque native handle object
"promise" / "promise<T>"-> i64JavaScript Promise
"void"-> ()undefined

Note on "string" vs. "i64_str": both produce a string on the TypeScript side, but they differ in how Rust returns the pointer. Use "string" / "ptr" when your extern "C" fn is declared -> *const u8 (or *const StringHeader); use "i64_str" when it’s -> i64 and the value happens to be a StringHeader address (closes #222).

"void" is valid only as a return descriptor. "buffer+len" and { "kind": "pod", ... } are valid only as parameter descriptors: "buffer+len" expands one JavaScript argument into two native ABI slots, while pod lowers one object-shaped argument to a pointer to verifier-backed C-layout storage.

Native-only numeric descriptors (f32, u32, u64, usize, buffer_len) render as TypeScript number. Handles remain opaque GC-managed values, even though native functions still receive and return raw i64 resource pointers at the ABI boundary. POD parameters remain ordinary JavaScript objects at the boundary; guarded hot paths may pass native record storage directly, and dynamic values fall back to validated object-field materialization. Promises remain JavaScript promises; the optional promise<T> result metadata is currently recorded in compiler proof artifacts rather than changing the runtime ABI.

targets.<target>

Per-target build configuration. The <target> key is one of: macos, ios, linux, windows, android, web, harmonyos, tvos, watchos, visionos. Simulator variants use the same key as their device counterpart (ios covers both ios-simulator and ios).

FieldTypeRequiredNotes
cratepath stringyes*Path (relative to package.json) to the Cargo crate that produces the staticlib. Required when prebuilt is absent.
libstringyes*Library name (without the lib prefix or .a extension). Required when prebuilt is absent.
frameworksarray of stringnoApple-only — system frameworks to pass to clang -framework (resolved from the SDK’s System/Library/Frameworks).
optionalFrameworksarray of stringnoApple-only — vendored third-party frameworks linked only when frameworksEnv resolves to a directory containing them. -framework <name> per entry. Static frameworks only (see below). Snake_case optional_frameworks also accepted.
frameworksEnvstringnoName of an env var that points at the directory holding optionalFrameworks. When set + the path is a directory, -F <dir> is added to the link line; when unset, the optional frameworks are skipped silently. Snake_case frameworks_env also accepted.
libsarray of stringnoSystem libraries to pass to the linker (-lcurl, etc.).
libDirsarray of pathsnoExtra linker search paths. Emitted before libs as -L<dir> (or /LIBPATH:<dir> on Windows MSVC). Relative entries resolve against package.json.
pkgConfigarray of stringnopkg-config package names. The compiler runs pkg-config --libs and forwards the output.
availablebooleannoSet false when the package intentionally does not ship this target. Perry skips it without requiring crate / lib / prebuilt.
unavailableReasonstringnoOptional diagnostic text shown when available: false. Snake_case unavailable_reason also accepted.
resourcesarray of pathsnoNative resource files/directories copied into NativeLibraries/<package>/ in the target bundle or output staging directory.
shaderOutputsarray of pathsnoPrecompiled shader/resource files copied into NativeLibraries/<package>/. Snake_case shader_outputs also accepted.
backendsobjectnoBackend-specific packaging blocks for metal, vulkan, and d3d12; see below.
swift_sourcesarray of pathsnoSwift sources to compile via swiftc and link in. Used by SwiftUI wrappers.
metal_sourcesarray of pathsnoMetal shader sources to compile via xcrun metal into <app>.app/default.metallib.
prebuiltpath stringnoPath (relative to package.json) to a pre-built .a archive. When present, Perry uses this instead of running cargo build.

When both prebuilt and crate/lib are absent for the user’s compile target, the wrapper is silently skipped on that target — useful for platform-specific bindings that only exist on macOS, etc.

Backend packaging (backends)

targets.<target>.backends describes backend-owned packaging without adding app-specific graphics APIs to Perry. The keys are:

BackendValid target keys
metalmacos, ios, tvos, watchos, visionos
vulkanmacos, linux, windows, android, harmonyos
d3d12windows

Unsupported combinations fail during manifest parsing or perry native validate, before any SDK-specific tool is invoked.

Each backend block accepts:

FieldTypeNotes
availablebooleanSet false to document an intentionally unavailable backend for that target.
unavailableReasonstringOptional skip reason. Snake_case alias accepted.
prebuiltpath stringBackend-specific archive linked in addition to the target-level archive.
frameworksarray of stringApple framework names for Metal packaging.
libsarray of stringSystem libraries such as vulkan, d3d12, dxgi, dxguid.
libDirsarray of pathsExtra backend library search paths.
pkgConfigarray of stringBackend pkg-config packages.
shaderSourcesarray of pathsSource shaders that require backend tools (xcrun metal, glslc, dxc) when Perry packages them. Snake_case alias accepted.
shaderOutputsarray of pathsPrecompiled shader outputs (.metallib, .spv, .dxil, .cso) copied into the target bundle or output staging directory. Snake_case alias accepted.
resourcesarray of pathsBackend-owned resource files/directories copied into NativeLibraries/<package>/<backend>/.
packageobjectOptional descriptive metadata: name, version, kind. Perry writes it to NativeLibraries/<package>/<backend>/perry-backend-package.json; native code owns interpretation.

Example:

"targets": {
  "macos": {
    "prebuilt": "./prebuilt/macos/libdemo.a",
    "backends": {
      "metal": {
        "frameworks": ["Metal", "QuartzCore"],
        "shaderSources": ["shaders/default.metal"],
        "shaderOutputs": ["prebuilt/default.metallib"],
        "resources": ["resources/metal"],
        "package": {
          "name": "demo-metal",
          "version": "1.0.0",
          "kind": "metallib"
        }
      },
      "vulkan": {
        "libs": ["vulkan"],
        "shaderOutputs": ["prebuilt/default.spv"]
      }
    }
  },
  "windows": {
    "prebuilt": "./prebuilt/windows/demo.lib",
    "backends": {
      "d3d12": {
        "libs": ["d3d12", "dxgi", "dxguid"],
        "shaderOutputs": ["prebuilt/default.dxil"]
      },
      "vulkan": {
        "libs": ["vulkan-1"],
        "shaderOutputs": ["prebuilt/default.spv"]
      }
    }
  }
}

For Apple app-bundle targets, Metal shader sources are compiled into default.metallib. Set PERRY_XCRUN=/path/to/fake-or-real-xcrun to override tool discovery in tests. Vulkan shader sources are compiled with glslc into NativeLibraries/<package>/vulkan/<source>.spv; set PERRY_GLSLC=/path/to/glslc to override discovery. D3D12 shader sources are compiled with dxc into NativeLibraries/<package>/d3d12/<source>.dxil; set PERRY_DXC=/path/to/dxc to override discovery. If your shader build needs custom profiles, entry points, or flags, ship prebuilt shaderOutputs from your package build instead.

Vendored frameworks (optionalFrameworks + frameworksEnv)

Some Apple SDKs can’t be redistributed through npm (licensing) or are too large to vendor — GoogleSignIn is the canonical example. For these, the wrapper declares the SDK’s framework name(s) in optionalFrameworks and the name of an environment variable in frameworksEnv. The app developer builds/downloads the framework locally, points the env var at the directory holding it, and Perry’s linker adds -F <dir> plus -framework <name> for each entry.

"targets": {
  "ios": {
    "crate": "crate-ios",
    "lib": "perry_google_auth",
    "optionalFrameworks": ["GoogleSignIn"],
    "frameworksEnv": "PERRY_GOOGLE_SIGN_IN_FRAMEWORK_DIR"
  }
}
PERRY_GOOGLE_SIGN_IN_FRAMEWORK_DIR=/path/to/Frameworks \
  perry compile app.ts --target ios

When the env var is unset (or points at a non-directory), the optional frameworks are skipped silently. This pairs with a Swift bridge guarded by #if canImport(GoogleSignIn): the no-SDK fallback compiles and the binary still links, returning a runtime “framework not linked” result instead of failing with undefined symbols. The same build.rs opt-in (-F $DIR to swiftc) must gate the bridge’s compile so both halves agree.

Project-relative framework_dir (survives perry publish). The env var works for local perry compile, but perry publish uploads the project to a remote build worker where the dev’s shell env doesn’t transfer and an absolute local path wouldn’t exist anyway. For the round-trip, declare the framework search dir relative to the project root in perry.toml:

[google_auth]
framework_dir = "vendor/google-sign-in/frameworks"   # relative to perry.toml

Perry resolves it to an absolute path and exports it as the package’s frameworksEnv before building the wrapper crate — on the local machine and on the worker — and perry publish forces the directory into the upload tarball (even though it holds the static archive binary, which the default binary-artifact exclusion would otherwise drop). Precedence is explicit env var > framework_dir, so existing local setups are unchanged. Issue #1303.

Contract — static frameworks only. -framework links the archive directly; Perry does not embed the .framework into <app>.app/Frameworks/ or add an @executable_path/Frameworks rpath. A dynamic framework would link but fail to load at runtime. Vendor a statically-linked .framework (or a .xcframework slice containing a static Mach-O). Embedding dynamic frameworks + resource bundles is tracked as future work (#1304).

Resolution

  1. The user writes import { foo } from "@perry/iroh".
  2. Perry resolves @perry/iroh against node_modules/. If a matching directory has a perry.nativeLibrary manifest in its package.json, this file’s spec applies and the wrapper is used.
  3. If node_modules/<name>/ exists without a manifest, the import falls through to V8 (existing behavior — TypeScript / JavaScript package).
  4. If no node_modules entry matches, Perry consults its built-in well-known bindings table (see #466 Phase 4) — the same spec applies to the bundled wrapper.
  5. None of the above match → resolution error.

A wrapper installed in node_modules always beats the well-known table — that’s how users override a bundled binding with a fork or a beta version.

Reference example

Minimal — three FFI functions, two targets. Matches the perry-ext-dotenv shape:

{
  "name": "@perry/dotenv",
  "version": "0.5.0",
  "perry": {
    "nativeLibrary": {
      "abiVersion": "0.5",
      "functions": [
        { "name": "js_dotenv_config",      "params": [],          "returns": "number" },
        { "name": "js_dotenv_config_path", "params": ["string"],  "returns": "number" },
        { "name": "js_dotenv_parse",       "params": ["string"],  "returns": "string" }
      ],
      "targets": {
        "macos":   { "crate": "native/macos",   "lib": "perry_ext_dotenv" },
        "linux":   { "crate": "native/linux",   "lib": "perry_ext_dotenv" }
      }
    }
  }
}

A larger reference is Bloom Engine’s manifest (~230 functions, 6 targets, frameworks + metal_sources) in the bloom repo.

Compatibility & migration

The manifest schema is itself versioned by abiVersion. The major version of perry-ffi is the major version of this manifest spec — they move in lockstep:

  • 0.5.x — current; abiVersion is recommended but optional.
  • 0.6.0abiVersion becomes required; missing field is a hard resolution error.
  • 1.0.0 — first stable release; backwards-compat guarantees begin.

Anything not documented on this page (custom keys, undocumented returns values) is unsupported and may break between releases. File a request under #466 and we’ll consider adding it to v1.

Multi-Threading

Perry gives you real OS threads with a one-line API. No worker setup, no message ports, no structured clone overhead. Just parallelMap, parallelFilter, and spawn.

async function overviewHeader(): Promise<void> {
    const data = [1, 2, 3, 4, 5, 6, 7, 8]
    const records = [
        { score: 50, id: 1 },
        { score: 90, id: 2 },
        { score: 85, id: 3 },
    ]
    const threshold = 80

    // Process a million items across all CPU cores
    const results = parallelMap(data, (item: number) => item * item)

    // Filter a large dataset in parallel
    const valid = parallelFilter(records, (r: { score: number; id: number }) => r.score > threshold)

    // Run expensive work in the background
    const answer = await spawn(() => {
        let acc = 0
        for (let i = 0; i < 100_000; i++) acc += i
        return acc
    })

    console.log(`overview-header results=${results.length} valid=${valid.length} answer=${answer}`)
}

This is something no JavaScript runtime can do. V8, Bun, and Deno are all locked to one thread per isolate. Perry compiles to native code — there are no isolates, no GIL, no structural limitations. Your code runs on real OS threads with the full power of every CPU core.

Why This Matters

JavaScript’s single-threaded model is its biggest performance bottleneck. Here’s how runtimes try to work around it:

Runtime“Multi-threading”Reality
Node.jsworker_threadsSeparate V8 isolates. Data copied via structured clone. ~2MB RAM per worker. Complex API.
DenoWorkerSame as Node — isolated heaps, message passing only.
BunWorkerSame architecture. Faster structured clone, still isolated.
PerryparallelMap / spawnReal OS threads. Lightweight (8MB stack). One-line API. Compile-time safety.

The fundamental problem: V8 uses a garbage-collected heap that cannot be shared between threads. Every “worker” is an entirely separate JavaScript engine instance with its own heap, its own GC, and its own copy of your data.

Perry doesn’t have this limitation. It compiles TypeScript to native machine code. Values are transferred between threads using zero-cost copies for numbers and efficient serialization for objects — no separate engine instances, no multi-megabyte overhead per thread.

Three Primitives

parallelMap — Data-Parallel Processing

Split an array across all CPU cores. Each element is processed independently. Results are collected in order.

async function overviewParallelMap(): Promise<void> {
    const prices = [100, 200, 300, 400, 500, 600, 700, 800]
    const adjusted = parallelMap(prices, (price: number) => {
        // Heavy computation runs on a worker thread
        let result = price
        for (let i = 0; i < 1000000; i++) {
            result = Math.sqrt(result * result + i)
        }
        return result
    })

    console.log(`overview-parallel-map len=${adjusted.length}`)
}

Perry automatically:

  1. Detects the number of CPU cores
  2. Splits the array into chunks (one per core)
  3. Spawns OS threads to process each chunk
  4. Collects results in the original order
  5. Returns a new array

For small arrays, Perry skips threading entirely and processes inline — no overhead for trivial cases.

parallelFilter — Data-Parallel Filtering

Filter a large array across all CPU cores. Like .filter() but parallel:

async function overviewParallelFilter(): Promise<void> {
    const cutoffDate = 1_700_000_000
    const users = [
        { lastLogin: 1_710_000_000, score: 150, name: "alice" },
        { lastLogin: 1_690_000_000, score: 50, name: "bob" },
        { lastLogin: 1_720_000_000, score: 120, name: "carol" },
    ]

    // Filter across all cores — order is preserved
    const active = parallelFilter(users, (user: { lastLogin: number; score: number; name: string }) => {
        return user.lastLogin > cutoffDate && user.score > 100
    })

    console.log(`overview-parallel-filter active=${active.length}`)
}

Same rules as parallelMap: closures cannot capture mutable variables (compile-time enforced), and values are deep-copied between threads.

spawn — Background Threads

Run any computation in the background and get a Promise back. The main thread continues immediately.

async function overviewSpawnBg(): Promise<void> {
    // Start heavy work in the background
    const handle = spawn(() => {
        let sum = 0
        for (let i = 0; i < 100_000; i++) {
            sum += Math.sin(i)
        }
        return sum
    })

    // Main thread keeps running — UI stays responsive
    console.log("Computing...")

    // Get the result when you need it
    const result = await handle
    console.log(`Done: len=${typeof result}`)
}

spawn returns a standard Promise. You can await it, pass it to Promise.all, or chain .then() — it works exactly like any other async operation.

Practical Examples

Parallel Image Processing

async function overviewImage(): Promise<void> {
    const pixels = [
        { r: 100, g: 120, b: 140 },
        { r: 50, g: 60, b: 70 },
        { r: 200, g: 210, b: 220 },
    ]

    // Each pixel processed on a separate core
    const processed = parallelMap(pixels, (pixel: { r: number; g: number; b: number }) => {
        const r = Math.min(255, pixel.r * 1.2)
        const g = Math.min(255, pixel.g * 0.8)
        const b = Math.min(255, pixel.b * 1.1)
        return { r, g, b }
    })

    console.log(`overview-image processed=${processed.length}`)
}

Parallel Cryptographic Hashing

async function overviewCrypto(): Promise<void> {
    // Hash thousands of items across all cores
    const passwords = ["pass1", "pass2", "pass3"]
    const hashed = parallelMap(passwords, (password: string) => {
        // Stand-in for a real hash: deterministic FNV-1a over the bytes.
        let h = 2166136261
        for (let i = 0; i < password.length; i++) {
            h ^= password.charCodeAt(i)
            h = (h * 16777619) >>> 0
        }
        return h
    })

    console.log(`overview-crypto hashed=${hashed.length}`)
}

Multiple Independent Computations

async function overviewMultiple(): Promise<void> {
    const dataA = [1, 2, 3]
    const dataB = [4, 5, 6]
    const dataC = [7, 8, 9]

    // Three independent tasks run simultaneously on three OS threads
    const task1 = spawn(() => {
        let acc = 0
        for (const v of dataA) acc += v * v
        return acc
    })
    const task2 = spawn(() => {
        let acc = 0
        for (const v of dataB) acc += v * v
        return acc
    })
    const task3 = spawn(() => {
        let acc = 0
        for (const v of dataC) acc += v * v
        return acc
    })

    // All three run concurrently
    const [result1, result2, result3] = await Promise.all([task1, task2, task3])
    console.log(`overview-multiple ${result1} ${result2} ${result3}`)
}

Keeping UI Responsive

const responsiveButton = Button("Start Analysis", async () => {
    status.set("Analyzing...")

    // Heavy computation runs on a background thread
    // UI stays responsive — user can still interact
    const value = await spawn(() => {
        let acc = 0
        for (let i = 0; i < 1_000_000; i++) acc += i
        return acc
    })

    status.set(`Done: ${value}`)
})

const responsiveText = Text(`Status: ${status.value}`)

Captured Variables

Closures can capture outer variables. Captured values are automatically deep-copied to each worker thread:

async function overviewCaptured(): Promise<void> {
    const prices = [100, 200, 300, 400]
    const taxRate = 0.08
    const discount = 0.15

    // taxRate and discount are captured and copied to each thread
    const finalPrices = parallelMap(prices, (price: number) => {
        const discounted = price * (1 - discount)
        return discounted * (1 + taxRate)
    })

    console.log(`overview-captured len=${finalPrices.length}`)
}

Numbers and booleans are zero-cost copies (just 64-bit values). Strings, arrays, and objects are deep-copied automatically.

Safety

Perry enforces thread safety at compile time. You don’t need to think about race conditions, mutexes, or data corruption.

No Shared Mutable State

Closures passed to parallelMap and spawn cannot capture mutable variables. The compiler rejects this:

// Reject example — Perry rejects this at compile time:

let counter = 0;

// COMPILE ERROR: Closures passed to parallelMap cannot
// capture mutable variable 'counter'
parallelMap(data, (item) => {
    counter++;  // Not allowed
    return item;
});

This eliminates data races by design. If you need to aggregate results, use the return values:

async function overviewReduceInstead(): Promise<void> {
    const data = [1, 2, 3, 4, 5, 6, 7, 8]

    // Instead of mutating a shared counter, return values and reduce
    const results = parallelMap(data, (item: number) => item * item)
    const total = results.reduce((sum: number, r: number) => sum + r, 0)

    console.log(`overview-reduce-instead total=${total}`)
}

Independent Thread Arenas

Each worker thread has its own memory arena. Objects created on one thread can never be accessed from another thread. Values cross thread boundaries only through deep-copy serialization, which Perry handles automatically and invisibly.

File-system descriptors are also thread-affine. Numeric fds from fs.openSync are just copied numbers in another thread, where the fd registry does not know them, so fd operations fail with EBADF. fs.promises.FileHandle objects cross thread boundaries as detached handles with fd === -1. Pass file paths to spawn/parallelMap and reopen files inside the worker when it needs file I/O.

How It Works

Perry’s threading model is built on three pillars:

1. Native Code, Not Interpreted

Perry compiles TypeScript to native machine code via LLVM. There’s no interpreter, no VM, no isolate. A function pointer is just a function pointer — it’s valid on any thread.

2. Thread-Local Memory

Each thread gets its own memory arena (bump allocator) and garbage collector. No synchronization overhead during computation. When a thread finishes, its arena is freed automatically.

3. Serialized Transfer

Values crossing thread boundaries are serialized to a thread-safe intermediate format and deserialized on the target thread. The cost depends on the value type:

Value TypeTransfer Cost
Numbers, booleans, null, undefinedZero-cost (64-bit copy)
StringsO(n) byte copy
ArraysO(n) deep copy of elements
ObjectsO(n) deep copy of fields
ClosuresPointer + captured values
fs numeric fds / FileHandleThread-affine; reopen by path

For numeric workloads — the most common parallelizable tasks — the threading overhead is negligible.

Next Steps

parallelMap

Signature: parallelMap<T, U>(data: T[], fn: (item: T) => U): U[] — imported from perry/thread.

Processes every element of an array in parallel across all available CPU cores. Returns a new array with the results in the same order as the input.

Basic Usage

function parallelMapBasic(): void {
    const numbers = [1, 2, 3, 4, 5, 6, 7, 8]
    const doubled = parallelMap(numbers, (x: number) => x * 2)
    // [2, 4, 6, 8, 10, 12, 14, 16]
    console.log(`parallel-map-basic len=${doubled.length}`)
}

How It Works

Input: [a, b, c, d, e, f, g, h]     (8 elements, 4 CPU cores)

  Core 1: [a, b] → map → [a', b']
  Core 2: [c, d] → map → [c', d']
  Core 3: [e, f] → map → [e', f']
  Core 4: [g, h] → map → [g', h']

Output: [a', b', c', d', e', f', g', h']   (same order as input)

Perry automatically detects the number of CPU cores and splits the array into equal chunks. Elements within each chunk are processed sequentially; chunks run concurrently across cores.

Capturing Variables

The mapping function can reference variables from the outer scope. Captured values are deep-copied to each worker thread automatically:

function parallelMapCapture(): void {
    const prices = [100, 200, 300]
    const exchangeRate = 1.12

    const converted = parallelMap(prices, (price: number) => {
        // exchangeRate is captured and copied to each thread
        return price * exchangeRate
    })

    console.log(`parallel-map-capture len=${converted.length}`)
}

What Can Be Captured

TypeSupportedTransfer
NumbersYesZero-cost (64-bit copy)
BooleansYesZero-cost
StringsYesByte copy
ArraysYesDeep copy
ObjectsYesDeep copy
const variablesYesCopied
let/var variablesOnly if not reassignedCopied

Numeric fds and fs.promises.FileHandle objects are thread-affine. A captured fd is not registered in worker threads, and a captured FileHandle is detached with fd === -1. For file-backed parallel work, capture path strings and open the file inside the mapper.

What Cannot Be Captured

Mutable variables — variables that are reassigned anywhere in the enclosing scope — are rejected at compile time:

// Reject example — Perry rejects this at compile time:

let total = 0;

// COMPILE ERROR: Cannot capture mutable variable 'total'
parallelMap(data, (item) => {
    total += item;   // Would be a data race
    return item;
});

Instead, return values and reduce:

function parallelMapReduce(): void {
    const data = [1, 2, 3, 4, 5, 6, 7, 8]
    const results = parallelMap(data, (item: number) => item * 2)
    const total = results.reduce((sum: number, x: number) => sum + x, 0)
    console.log(`parallel-map-reduce total=${total}`)
}

Performance

When to Use parallelMap

Use parallelMap when the computation per element is significantly heavier than the cost of copying the element across threads.

Good candidates (CPU-bound work per element):

function parallelMapGoodCandidates(): void {
    const data = [1.0, 2.0, 3.0, 4.0]
    const documents = ["alpha beta", "gamma delta", "epsilon"]
    const inputs = ["a", "bb", "ccc"]

    // Heavy math
    const out1 = parallelMap(data, (x: number) => {
        let acc = x
        for (let i = 0; i < 1_000; i++) acc = Math.sqrt(acc * acc + i)
        return acc
    })

    // String processing on large strings
    const out2 = parallelMap(documents, (doc: string) => {
        const words = doc.split(" ")
        return { count: words.length, first: words[0] }
    })

    // Cryptographic operations
    const out3 = parallelMap(inputs, (input: string) => {
        let h = 0
        for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) >>> 0
        return h
    })

    console.log(`parallel-map-good-candidates ${out1.length} ${out2.length} ${out3.length}`)
}

Poor candidates (trivial work per element):

function parallelMapPoorCandidate(): void {
    const numbers = [1, 2, 3, 4, 5]

    // Too simple — threading overhead outweighs the gain
    const a = parallelMap(numbers, (x: number) => x + 1)

    // For trivial operations, use regular map
    const result = numbers.map((x: number) => x + 1)

    console.log(`parallel-map-poor-candidate ${a.length} ${result.length}`)
}

Small Array Optimization

For arrays with fewer elements than CPU cores, Perry skips threading entirely and processes elements inline on the main thread. There’s zero overhead for small inputs.

Numeric Fast Path

When elements are pure numbers (no strings, objects, or arrays), Perry transfers them between threads at virtually zero cost — just 64-bit value copies with no serialization.

Examples

Matrix Row Processing

function parallelMapMatrix(): void {
    // Process each row of a matrix independently
    const rows = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    const rowSums = parallelMap(rows, (row: number[]) => {
        let sum = 0
        for (const val of row) sum += val
        return sum
    })
    // [6, 15, 24]
    console.log(`parallel-map-matrix sums=${rowSums[0]},${rowSums[1]},${rowSums[2]}`)
}

Batch Validation

function parallelMapValidation(): void {
    const users = [
        { name: "Alice", email: "alice@example.com" },
        { name: "Bob", email: "invalid" },
        { name: "Charlie", email: "charlie@example.com" },
    ]

    const validationResults = parallelMap(users, (user: { name: string; email: string }) => {
        const emailValid = user.email.includes("@") && user.email.includes(".")
        const nameValid = user.name.length > 0 && user.name.length < 100
        return { name: user.name, valid: emailValid && nameValid }
    })

    console.log(`parallel-map-validation len=${validationResults.length}`)
}

Financial Calculations

function parallelMapMonteCarlo(): void {
    const portfolios = [
        { id: 1, base: 100 },
        { id: 2, base: 200 },
        { id: 3, base: 150 },
    ] // thousands of portfolios

    // Monte Carlo simulation across all cores
    const riskScores = parallelMap(portfolios, (portfolio: { id: number; base: number }) => {
        let totalRisk = 0
        for (let sim = 0; sim < 1000; sim++) {
            // simulateReturns stand-in: deterministic pseudo-random walk.
            let s = portfolio.base + sim
            s = ((s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff
            totalRisk += s
        }
        return totalRisk / 1000
    })

    console.log(`parallel-map-monte-carlo len=${riskScores.length}`)
}

parallelFilter

Signature: parallelFilter<T>(data: T[], predicate: (item: T) => boolean): T[] — imported from perry/thread.

Filters an array in parallel across all available CPU cores. Returns a new array containing only the elements where the predicate returned a truthy value. Order is preserved.

Basic Usage

function parallelFilterBasic(): void {
    const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    const evens = parallelFilter(numbers, (x: number) => x % 2 === 0)
    // [2, 4, 6, 8, 10]
    console.log(`parallel-filter-basic len=${evens.length}`)
}

How It Works

Input: [a, b, c, d, e, f, g, h]     (8 elements, 4 CPU cores)

  Core 1: [a, b] → test → [a]       (b filtered out)
  Core 2: [c, d] → test → [c, d]    (both kept)
  Core 3: [e, f] → test → []        (both filtered out)
  Core 4: [g, h] → test → [h]       (g filtered out)

Output: [a, c, d, h]                 (concatenated in original order)

Each core independently tests its chunk of elements. Results are merged in the original element order after all threads complete.

Why Not Just Use .filter()?

Regular .filter() runs on a single thread. For large arrays with expensive predicates, parallelFilter distributes the work:

function parallelFilterVsFilter(): void {
    const data = [1, 2, 3, 4, 5, 6, 7, 8]

    // Single-threaded — one core does all the work
    const a = data.filter((item: number) => item > 3)

    // Parallel — all cores share the work
    const b = parallelFilter(data, (item: number) => item > 3)

    console.log(`parallel-filter-vs-filter ${a.length} ${b.length}`)
}

The tradeoff: parallelFilter has overhead from copying values between threads. Use it when the predicate is expensive enough to justify that cost.

Capturing Variables

Like parallelMap, the predicate can capture outer variables. Captures are deep-copied to each thread:

function parallelFilterCapture(): void {
    const candidates = [
        { name: "Alice", score: 90, age: 28 },
        { name: "Bob", score: 80, age: 35 },
        { name: "Carol", score: 95, age: 25 },
    ]
    const minScore = 85
    const maxAge = 30

    // minScore and maxAge are captured and copied to each thread
    const qualified = parallelFilter(candidates, (c: { name: string; score: number; age: number }) => {
        return c.score >= minScore && c.age <= maxAge
    })

    console.log(`parallel-filter-capture len=${qualified.length}`)
}

Mutable variables cannot be captured — the compiler rejects this at compile time.

Examples

Filtering Large Datasets

function parallelFilterLarge(): void {
    // Stand-in for "millions of records" — same shape, smaller list.
    const transactions = [
        { amount: 15000, country: "US", user: { homeCountry: "DE" }, timestamp: { hour: 4 } },
        { amount: 200, country: "DE", user: { homeCountry: "DE" }, timestamp: { hour: 12 } },
        { amount: 50000, country: "FR", user: { homeCountry: "DE" }, timestamp: { hour: 3 } },
    ]

    const suspicious = parallelFilter(transactions, (tx: {
        amount: number
        country: string
        user: { homeCountry: string }
        timestamp: { hour: number }
    }) => {
        return tx.amount > 10000
            && tx.country !== tx.user.homeCountry
            && tx.timestamp.hour < 6
    })

    console.log(`parallel-filter-large len=${suspicious.length}`)
}

Combined with parallelMap

function parallelFilterCombined(): void {
    const users = [
        { name: "Alice", isActive: true, age: 28, score: 90 },
        { name: "Bob", isActive: false, age: 35, score: 80 },
        { name: "Carol", isActive: true, age: 17, score: 95 },
        { name: "Dave", isActive: true, age: 40, score: 60 },
    ]

    // Step 1: Filter to relevant items (parallel)
    const active = parallelFilter(users, (u: { isActive: boolean; age: number }) => u.isActive && u.age >= 18)

    // Step 2: Transform the filtered results (parallel)
    const profiles = parallelMap(active, (u: { name: string; score: number }) => ({
        name: u.name,
        score: u.score * 2,
    }))

    console.log(`parallel-filter-combined active=${active.length} profiles=${profiles.length}`)
}

Predicate with Heavy Computation

function parallelFilterHeavy(): void {
    const certificates = [
        { id: 1, fingerprint: "aa", revoked: false },
        { id: 2, fingerprint: "bb", revoked: true },
        { id: 3, fingerprint: "cc", revoked: false },
    ]

    // Each predicate call does significant work — perfect for parallelization.
    const valid = parallelFilter(certificates, (cert: { id: number; fingerprint: string; revoked: boolean }) => {
        // Stand-in for a real chain verification: hash the fingerprint a bit
        // then sanity-check the revocation flag.
        let h = 0
        for (let i = 0; i < cert.fingerprint.length; i++) {
            h = (h * 31 + cert.fingerprint.charCodeAt(i)) >>> 0
        }
        return h !== 0 && !cert.revoked
    })

    console.log(`parallel-filter-heavy len=${valid.length}`)
}

Performance

Use parallelFilter when:

  • The array has many elements (hundreds or more)
  • The predicate function does meaningful work per element
  • You need to keep the UI responsive during filtering

For trivial predicates on small arrays, regular .filter() is faster (no threading overhead).

spawn

Signature: spawn<T>(fn: () => T): Promise<T> — imported from perry/thread.

Runs a closure on a new OS thread and returns a Promise that resolves when the thread completes. The main thread continues immediately — UI and other work are not blocked.

Basic Usage

async function spawnBasic(): Promise<void> {
    const result = await spawn(() => {
        // This runs on a separate OS thread.
        let sum = 0
        for (let i = 0; i < 100_000_000; i++) {
            sum += i
        }
        return sum
    })

    console.log(result) // 4999999950000000
}

Non-Blocking

spawn returns immediately. The main thread doesn’t wait:

async function spawnNonBlocking(): Promise<void> {
    console.log("1. Starting background work")

    const handle = spawn(() => {
        // Runs on a background thread — heavier work elided here.
        let n = 0
        for (let i = 0; i < 10_000_000; i++) n++
        return n
    })

    console.log("2. Main thread continues immediately")

    const result = await handle
    console.log(`3. Got result: ${result}`)
}

Output:

1. Starting background work
2. Main thread continues immediately
3. Got result: <computed value>

Multiple Concurrent Tasks

Spawn multiple tasks and they run truly concurrently — one OS thread per spawn call:

async function spawnMultiple(): Promise<void> {
    const t1 = spawn(() => analyseChunk(0, 1_000_000))
    const t2 = spawn(() => analyseChunk(1_000_000, 2_000_000))
    const t3 = spawn(() => analyseChunk(2_000_000, 3_000_000))

    // All three run simultaneously on separate OS threads.
    const results = await Promise.all([t1, t2, t3])

    console.log(`Region A: ${results[0]}`)
    console.log(`Region B: ${results[1]}`)
    console.log(`Region C: ${results[2]}`)
}

function analyseChunk(start: number, end: number): number {
    let acc = 0
    for (let i = start; i < end; i++) acc += i & 0xff
    return acc
}

Unlike Node.js worker_threads, each spawn is a lightweight OS thread (~8MB stack), not a full V8 isolate (~2MB heap + startup cost).

Capturing Variables

Like parallelMap, spawn closures can capture outer variables. They are deep-copied to the background thread:

async function spawnCapture(): Promise<void> {
    const config = { iterations: 1000, seed: 42 }
    const dataset = [1, 2, 3, 4, 5, 6, 7, 8]

    const result = await spawn(() => {
        // config and dataset are deep-copied to this thread.
        let acc = config.seed
        for (let i = 0; i < config.iterations; i++) {
            acc = (acc * 1103515245 + 12345) & 0x7fffffff
        }
        for (const v of dataset) acc ^= v
        return acc
    })

    console.log(`spawn-capture: ${result}`)
}

Mutable variables cannot be captured — this is enforced at compile time.

File System Handles

Do not capture numeric fds or fs.promises.FileHandle objects for file I/O in spawn. Perry’s fd registry is per thread: a numeric fd captured from the main thread is not open in the worker, and a captured FileHandle arrives detached with fd === -1. Capture a path string instead, then call fs.openSync or fs.promises.open inside the spawned function.

Returning Complex Values

spawn can return any value type. Complex values (objects, arrays, strings) are serialized back to the main thread automatically:

async function spawnComplexReturn(): Promise<void> {
    const stats = await spawn(() => {
        const values = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]
        let sum = 0
        let max = values[0]
        let min = values[0]
        for (const v of values) {
            sum += v
            if (v > max) max = v
            if (v < min) min = v
        }
        return {
            mean: sum / values.length,
            min,
            max,
            count: values.length,
        }
    })

    console.log(`mean=${stats.mean} min=${stats.min} max=${stats.max} count=${stats.count}`)
}

UI Integration

spawn is ideal for keeping native UIs responsive during heavy computation:

const analyzeButton = Button("Analyze", async () => {
    status.set("Processing...")

    // Background thread — UI stays responsive
    const data = await spawn(() => {
        let count = 0
        for (let i = 0; i < 1_000_000; i++) {
            if ((i & 0xff) === 0) count++
        }
        return { count }
    })

    result.set(`Found ${data.count} patterns`)
    status.set("Done")
})

Without spawn, the analysis would freeze the UI. With spawn, the user can still scroll, tap other buttons, or navigate while the computation runs.

Compared to Node.js worker_threads

// ── Node.js: ~15 lines, separate file needed ──────────
// worker.js
const { parentPort, workerData } = require("worker_threads");
const result = heavyComputation(workerData);
parentPort.postMessage(result);

// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js", {
    workerData: inputData,
});
worker.on("message", (result) => {
    console.log(result);
});
worker.on("error", (err) => { /* handle */ });


// ── Perry: 1 line ─────────────────────────────────────
// const result = await spawn(() => heavyComputation(inputData));

No separate files. No message ports. No event handlers. No structured clone. One line.

Examples

Background File Processing

async function spawnBgFile(): Promise<void> {
    // Read and process a "large" file without blocking. We inline a tiny CSV
    // so the snippet runs hermetically — the docs' real version would call
    // readFileSync from "fs".
    const content = "id,value\n1,10\n2,20\n3,30\n"
    const analysis = await spawn(() => {
        const lines = content.split("\n").filter((l: string) => l.length > 0).slice(1)
        let total = 0
        for (const line of lines) {
            const parts = line.split(",")
            total += parseInt(parts[1], 10)
        }
        return { rows: lines.length, total }
    })

    console.log(`spawn-bg-file rows=${analysis.rows} total=${analysis.total}`)
}

Parallel API Calls with Processing

async function spawnApiThenProcess(): Promise<void> {
    // The docs example fetches a remote API; for a hermetic test we
    // just hand-roll the same pipeline shape with synthetic data.
    const rawData = { items: [1, 2, 3, 4, 5] }

    // CPU-intensive processing happens off the main thread
    const processed = await spawn(() => {
        let total = 0
        for (const v of rawData.items) total += v * v
        return { total, count: rawData.items.length }
    })

    console.log(`spawn-api-then-process total=${processed.total} count=${processed.count}`)
}

Deferred Computation

async function spawnDeferred(): Promise<void> {
    const params = { size: 8 }

    // Start computation early, use result later
    const precomputed = spawn(() => {
        const table: number[] = []
        for (let i = 0; i < params.size; i++) table.push(i * i)
        return table
    })

    // ... do other setup work ...

    // Result is ready (or we wait for it)
    const table = await precomputed
    console.log(`spawn-deferred len=${table.length} last=${table[table.length - 1]}`)
}

UI Overview

Perry’s perry/ui module lets you build native desktop and mobile apps with declarative TypeScript. Your UI code compiles directly to platform-native widgets — AppKit on macOS, UIKit on iOS, GTK4 on Linux, Win32 on Windows, and DOM elements on the web.

Quick Start

// demonstrates: the smallest complete Perry UI app
// docs: docs/src/ui/overview.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, Text, VStack } from "perry/ui"

App({
    title: "My App",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text("Hello from Perry!"),
    ]),
})
perry app.ts -o app && ./app

Mental Model

Perry’s UI follows the same model as SwiftUI and Flutter: you compose native widgets using stack-based layout containers (VStack, HStack, ZStack), control alignment and distribution, and style widgets via free functions that take the widget handle as their first argument (textSetColor(label, r, g, b, a), widgetSetEdgeInsets(stack, ...), etc.). If you’re coming from web development, the key shift is:

  • Layout is controlled by stack alignment, distribution, and spacers — not CSS properties. See Layout.
  • Styling is applied directly to widgets — not through stylesheets. See Styling.
  • Absolute positioning uses overlays (widgetAddOverlay + widgetSetOverlayFrame) — not position: absolute/relative.
  • Design tokens come from the perry-styling package. See Theming.

App Lifecycle

Every Perry UI app starts with App():

function runAppShell(): void {
    App({
        title: "Window Title",
        width: 800,
        height: 600,
        body: VStack(16, [
            Text("Content here"),
        ]),
    })
}

App({}) accepts a config object with the following properties:

PropertyTypeDescription
titlestringWindow title
widthnumberInitial window width
heightnumberInitial window height
bodywidgetRoot widget
iconstringApp icon file path (optional)
framelessbooleanRemove title bar (optional)
levelstringWindow z-order: "floating", "statusBar", "modal" (optional)
transparentbooleanTransparent background (optional)
vibrancystringNative blur material, e.g. "sidebar" (optional)
activationPolicystring"regular", "accessory" (no dock icon), "background" (optional)

See Multi-Window for full documentation on window properties.

Lifecycle Hooks

onActivate(() => {
    console.log("App became active")
})

onTerminate(() => {
    console.log("App is closing")
})

Widget Tree

Perry UIs are built as a tree of widgets:

function runWidgetTree(): void {
    App({
        title: "Layout Demo",
        width: 400,
        height: 300,
        body: VStack(16, [
            Text("Header"),
            HStack(8, [
                Button("Left", () => console.log("left")),
                Button("Right", () => console.log("right")),
            ]),
            Text("Footer"),
        ]),
    })
}

Widgets are created by calling their constructor functions. Layout containers (VStack, HStack, ZStack) accept a spacing value (in points) followed by an array of child widgets.

Handle-Based Architecture

Under the hood, each widget is a handle — a small integer that references a native platform object. When you call Text("hello"), Perry creates a native NSTextField (macOS), UILabel (iOS), GtkLabel (Linux), or <span> (web) and returns a handle you can use to modify it.

const label = Text("Hello")
textSetFontSize(label, 18)              // Modifies the native widget
textSetColor(label, 1.0, 0.0, 0.0, 1.0) // RGBA floats in [0,1]

Imports

All UI functions are imported from perry/ui:

import {
    // App lifecycle
    App, onActivate, onTerminate,

    // Widgets
    Text, Button, TextField, SecureField, TextArea,
    Toggle, Slider, ProgressView, Picker, ImageFile, ImageSymbol,

    // Layout
    VStack, HStack, ZStack, ScrollView, Spacer, Divider,
    NavStack, TabBar, LazyVStack, Section,
    VStackWithInsets, HStackWithInsets, SplitView, splitViewAddChild,

    // Layout control
    stackSetAlignment, stackSetDistribution, stackSetDetachesHidden,
    widgetMatchParentWidth, widgetMatchParentHeight, widgetSetHugging,
    widgetAddOverlay, widgetSetOverlayFrame,

    // State
    State, ForEach,

    // Dialogs
    openFileDialog, openFolderDialog, saveFileDialog,
    alert, alertWithButtons,
    sheetCreate, sheetPresent, sheetDismiss,

    // Menus
    menuCreate, menuAddItem, menuAddSeparator, menuAddSubmenu,
    menuBarCreate, menuBarAddMenu, menuBarAttach,
    widgetSetContextMenu,

    // Window
    Window,
} from "perry/ui"

Canvas, CameraView, and the virtualized Table widget are wired through the LLVM codegen (closed via #190, #191, and #192). See each widget’s page for the platform-support matrix.

Platform Differences

The same code runs on all platforms, but the look and feel matches each platform’s native style:

FeaturemacOSiOSLinuxWindowsWeb
ButtonsNSButtonUIButtonGtkButtonHWND Button<button>
TextNSTextFieldUILabelGtkLabelStatic HWND<span>
LayoutNSStackViewUIStackViewGtkBoxManual layoutFlexbox
MenusNSMenuGMenuHMENUDOM

Platform-specific behavior is noted on each widget’s documentation page.

Next Steps

Widgets

Perry provides native widgets that map to each platform’s native controls. Every example on this page is a real runnable program verified by CI (scripts/run_doc_tests.sh) — the snippet you read is the same source that’s compiled and launched.

The widget API is free functions, not methods. A widget is a 64-bit opaque handle; you pass it into helpers like textSetFontSize(widget, 18) rather than calling widget.setFontSize(18). That’s the only shape perry/ui supports — no fluent chain, no prototype methods.

Text

Displays read-only text.

// demonstrates: Text widget styling with the real free-function API
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, textSetFontSize, textSetFontWeight, textSetColor, textSetFontFamily } from "perry/ui"

const label = Text("Hello, World!")
textSetFontSize(label, 18)
textSetColor(label, 0.2, 0.2, 0.2, 1.0) // RGBA in [0, 1]
textSetFontFamily(label, "Menlo")

const bold = Text("Bold")
textSetFontWeight(bold, 20, 1.0)

App({
    title: "Text",
    width: 400,
    height: 200,
    body: VStack(8, [label, bold]),
})

Color is RGBA with each channel in [0.0, 1.0] — divide a hex byte by 255 (0x33 / 255 ≈ 0.2).

Helpers: textSetString, textSetFontSize, textSetFontWeight, textSetFontFamily, textSetColor, textSetWraps, textSetSelectable.

Text widgets inside template literals with state.value update automatically — perry detects the state read and rewires the widget to re-render on change. See State Management.

Button

A clickable button.

// demonstrates: Button styling with buttonSet*/widgetSet* helpers
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import {
    App,
    VStack,
    Button,
    buttonSetBordered,
    widgetSetEnabled,
    setCornerRadius,
} from "perry/ui"

// Note: buttonSetContentTintColor is macOS/iOS-only (maps to NSButton /
// UIButton tint). GTK4/Win32 don't have an equivalent — set
// widgetSetBackgroundColor(btn, r, g, b, a) there instead.
const primary = Button("Click Me", () => console.log("Clicked!"))
buttonSetBordered(primary, 1)
setCornerRadius(primary, 8)

const disabled = Button("Can't click me", () => {})
widgetSetEnabled(disabled, 0)

App({
    title: "Button",
    width: 400,
    height: 200,
    body: VStack(12, [primary, disabled]),
})

Helpers: buttonSetTitle, buttonSetBordered, buttonSetImage (SF Symbol name on macOS/iOS), buttonSetImagePosition, buttonSetContentTintColor, buttonSetTextColor, widgetSetEnabled.

TextField

An editable single-line text input.

// demonstrates: TextField + two-way binding via stateBindTextfield
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, TextField, State, stateBindTextfield } from "perry/ui"

const text = State("")
const field = TextField("Placeholder...", (value: string) => text.set(value))
stateBindTextfield(text, field) // programmatic text.set() also updates the field

App({
    title: "TextField",
    width: 400,
    height: 200,
    body: VStack(12, [
        field,
        Text(`You typed: ${text.value}`),
    ]),
})

TextField(placeholder, onChange) fires onChange as the user types. Pair with stateBindTextfield(state, field) for two-way binding so programmatic state.set(…) also updates the visible text.

Helpers: textfieldSetString, textfieldSetFontSize, textfieldSetTextColor, textfieldSetBackgroundColor, textfieldSetBorderless, textfieldSetOnSubmit, textfieldSetOnFocus, textfieldSetNextKeyView.

SecureField

A password input — identical signature to TextField, but text is masked.

// demonstrates: SecureField for password input
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, SecureField, State } from "perry/ui"

const password = State("")

App({
    title: "SecureField",
    width: 400,
    height: 200,
    body: VStack(12, [
        SecureField("Enter password...", (value: string) => password.set(value)),
    ]),
})

Toggle

A boolean on/off switch.

// demonstrates: Toggle widget bound to State
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, Toggle, State } from "perry/ui"

const enabled = State(false)

App({
    title: "Toggle",
    width: 400,
    height: 200,
    body: VStack(12, [
        Toggle("Enable notifications", (on: boolean) => enabled.set(on)),
        Text(`Enabled: ${enabled.value}`),
    ]),
})

Slider

A numeric slider.

// demonstrates: Slider with a numeric range
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, Slider, State } from "perry/ui"

const value = State(50)

App({
    title: "Slider",
    width: 400,
    height: 200,
    body: VStack(12, [
        Slider(0, 100, (v: number) => value.set(v)),
        Text(`Value: ${value.value}`),
    ]),
})

Slider(min, max, onChange)onChange fires on every drag. Use stateBindSlider(state, slider) for two-way binding.

Picker

A dropdown selection control. Items are added with pickerAddItem.

// demonstrates: Picker with items added via pickerAddItem
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, Picker, State, pickerAddItem } from "perry/ui"

const selected = State(0)
const picker = Picker((index: number) => selected.set(index))
pickerAddItem(picker, "Option A")
pickerAddItem(picker, "Option B")
pickerAddItem(picker, "Option C")

App({
    title: "Picker",
    width: 400,
    height: 200,
    body: VStack(12, [
        picker,
        Text(`Selected index: ${selected.value}`),
    ]),
})

ImageFile / ImageSymbol

Two distinct constructors:

  • ImageFile(path) — image from a file path
  • ImageSymbol(name) — SF Symbol glyph name (macOS/iOS only)
// demonstrates: ImageSymbol for SF Symbol glyphs (macOS/iOS)
// docs: docs/src/ui/widgets.md
// platforms: macos
// targets: ios-simulator, visionos-simulator, tvos-simulator

import { App, HStack, ImageSymbol, widgetSetWidth, widgetSetHeight } from "perry/ui"

const star = ImageSymbol("star.fill")
widgetSetWidth(star, 32)
widgetSetHeight(star, 32)

const heart = ImageSymbol("heart.fill")
const bell = ImageSymbol("bell.fill")

App({
    title: "ImageSymbol",
    width: 400,
    height: 200,
    body: HStack(12, [star, heart, bell]),
})

Use widgetSetWidth(img, N) / widgetSetHeight(img, N) to size the image.

ProgressView

An indeterminate or determinate progress indicator.

// demonstrates: ProgressView as an indeterminate spinner
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, ProgressView } from "perry/ui"

App({
    title: "ProgressView",
    width: 400,
    height: 200,
    body: VStack(12, [
        Text("Loading..."),
        ProgressView(),
    ]),
})

TextArea

A multi-line text input. Same (placeholder, onChange) signature as TextField but renders as a multi-line box.

// demonstrates: TextArea for multi-line input
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, Text, TextArea, State } from "perry/ui"

const content = State("")

App({
    title: "TextArea",
    width: 500,
    height: 400,
    body: VStack(12, [
        TextArea("Enter multi-line text...", (value: string) => content.set(value)),
        Text(`Length: ${content.value.length}`),
    ]),
})

Helpers: textareaSetString.

Sections

Group controls into labelled sections. Perry has no Form() widget — use a VStack of Section(title)s and attach children via widgetAddChild.

// demonstrates: Section grouping with widgetAddChild (no Form widget in Perry)
// docs: docs/src/ui/widgets.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import {
    App,
    VStack,
    Section,
    TextField,
    Toggle,
    State,
    widgetAddChild,
} from "perry/ui"

const name = State("")
const notifications = State(true)

const personal = Section("Personal Info")
widgetAddChild(personal, TextField("Name", (value: string) => name.set(value)))

const settings = Section("Settings")
widgetAddChild(
    settings,
    Toggle("Notifications", (on: boolean) => notifications.set(on)),
)

App({
    title: "Sections",
    width: 500,
    height: 400,
    body: VStack(16, [personal, settings]),
})

Mobile widgets (issue #553)

BottomNavigation

5-tab bottom bar with icon + label + badge per tab. onSelect(index) fires when the user taps; bottomNavSetSelected is the programmatic counterpart and does NOT fire onSelect.

import {
  BottomNavigation,
  bottomNavAddItem,
  bottomNavSetBadge,
} from "perry/ui";

const bar = BottomNavigation((index) => {
  console.log("tab:", index);
});
bottomNavAddItem(bar, "house", "Home");
bottomNavAddItem(bar, "magnifyingglass", "Search");
bottomNavAddItem(bar, "bell", "Activity");
bottomNavSetBadge(bar, 2, "5");

Real on macOS (NSStackView + NSButton strip with SF Symbol icons), iOS (UITabBar), Android (custom LinearLayout strip with badge overlay), and GTK4 (GtkBox + Adwaita CSS). Stub on Windows, tvOS, visionOS, watchOS.

ImageGallery

Swipeable, paging carousel of images. Local file paths load synchronously; HTTP/HTTPS URLs are fetched on a background queue and applied on the main thread.

import { ImageGallery, imageGalleryAddImage } from "perry/ui";

const gallery = ImageGallery((idx) => console.log("page:", idx));
imageGalleryAddImage(gallery, "/photos/01.jpg", "Hero shot");
imageGalleryAddImage(gallery, "https://cdn.example/photo2.jpg", "Wide angle");

Real on macOS (NSScrollView paging), iOS (UIScrollView with scrollViewDidEndDecelerating), Android (HorizontalScrollView), GTK4 (GtkScrolledWindow + GtkPicture). Stub on Windows, tvOS, visionOS, watchOS.

Pull-to-refresh

Available on ScrollView and LazyVStack. The onPull callback fires once when the user pulls past the threshold; call scrollviewEndRefreshing (or lazyvstackEndRefreshing) when your async fetch settles to dismiss the spinner.

import {
  ScrollView,
  scrollviewSetRefreshControl,
  scrollviewEndRefreshing,
} from "perry/ui";

const scroll = ScrollView();
scrollviewSetRefreshControl(scroll, async () => {
  await refreshFeed();
  scrollviewEndRefreshing(scroll);
});

Real on iOS (UIRefreshControl). The macOS / Android / GTK4 / Windows desktops have no native pull-to-refresh idiom — they’re documented no-ops.

Infinite scroll (onScrollEnd)

Fires once when the user scrolls past thresholdPx (or thresholdItems for LazyVStack) from the bottom; re-arms after the user scrolls back up past the threshold so a single fetch is queued at a time.

import { ScrollView, scrollviewSetScrollEndCallback } from "perry/ui";

const scroll = ScrollView();
scrollviewSetScrollEndCallback(
  scroll,
  () => loadMore(),
  200, // threshold in pixels from the bottom
);

Real on every platform that has a scroll view: macOS (NSViewBoundsDidChangeNotification), iOS (UIScrollViewDelegate.scrollViewDidScroll), Android (View.OnScrollChangeListener), GTK4 (GtkAdjustment::value-changed), Windows (WM_VSCROLL / WM_MOUSEWHEEL).

Platform-specific widgets

These exist only on specific platforms and aren’t verified by the cross-platform doc-tests:

  • Table(rows, cols, renderer) — macOS only. Now supports tableSetSortColumn, tableSetFilterText, and multi-select since v0.5.636 (#473).
  • QRCode(data, size) — macOS only. Renders a QR code.
  • Canvas(width, height, draw) — all desktop platforms. A drawing surface; see Canvas.
  • CameraView() — iOS only (other platforms planned). See Camera.

Combobox (issue #475)

Editable text field with a filterable dropdown of suggestions. macOS uses NSComboBox with as-you-type completion; other platforms stub the FFI today (the field falls back to a plain editable field).

import { Combobox, comboboxAddItem, comboboxGetValue } from "perry/ui";

const combo = Combobox("", (v) => console.log("picked:", v));
comboboxAddItem(combo, "apple");
comboboxAddItem(combo, "apricot");
comboboxAddItem(combo, "avocado");

TreeView / Outline (issue #480)

Hierarchical disclosure list. Build the topology bottom-up via TreeNode

  • treeNodeAddChild, then mount it via TreeView. macOS uses NSOutlineView; other platforms stub.
import { TreeNode, treeNodeAddChild, TreeView } from "perry/ui";

const dox = TreeNode("docs", "Documents");
treeNodeAddChild(dox, TreeNode("doc-1", "Resume.pdf"));
treeNodeAddChild(dox, TreeNode("doc-2", "Cover Letter.pdf"));
const root = TreeNode("root", "Files");
treeNodeAddChild(root, dox);
const tree = TreeView(root, (id) => console.log("selected:", id));

Calendar (issue #481)

Month-grid date picker. macOS uses NSDatePicker in graphical style; other platforms stub. onChange receives the selected date as an ISO yyyy-MM-dd string.

import { Calendar, calendarGetSelectedDate } from "perry/ui";

const cal = Calendar(2026, 5, (iso) => console.log("date:", iso));

DatePicker (issue #4772)

Compact field-style date picker — the space-saving complement to the month-grid Calendar. Each platform uses its native compact date control: macOS NSDatePicker (text-field-and-stepper), iOS / visionOS UIDatePicker (.compact), Windows SysDateTimePick32, Android android.widget.DatePicker. GTK4 has no native compact date field, so it reuses GtkCalendar; tvOS / watchOS stub. onChange receives the selected date as an ISO yyyy-MM-dd string.

import { DatePicker, datePickerGetSelectedDate } from "perry/ui";

const picker = DatePicker(2026, 5, (iso) => console.log("date:", iso));

Chart (issue #474)

Line / bar / pie via CoreGraphics on macOS. kind is 0=line, 1=bar, 2=pie. Apple Charts framework / SwiftUI Charts integration on iOS 16+ is a follow-up.

import { Chart, chartAddDataPoint, chartSetTitle } from "perry/ui";

const chart = Chart(0, 600, 400);
chartSetTitle(chart, "Visits");
chartAddDataPoint(chart, "Mon", 12);
chartAddDataPoint(chart, "Tue", 18);
chartAddDataPoint(chart, "Wed", 9);

Command palette (issue #477)

⌘K-style fuzzy command launcher. macOS shows a floating NSPanel; other platforms stub. Bind commandPaletteShow() to ⌘K via addKeyboardShortcut to wire the default hotkey.

import {
  commandPaletteRegister,
  commandPaletteShow,
} from "perry/ui";

commandPaletteRegister("save", "Save", "⌘S", () => save());
commandPaletteRegister("export", "Export PDF", "", () => exportPdf());
// then:
commandPaletteShow();

MapView (issue #517)

Wraps MKMapView on macOS / iOS / visionOS / tvOS, libshumate on GTK4, Google Maps SDK on Android (requires API key in AndroidManifest.xml), and the SwiftUI Map view on watchOS. Windows remains a stub (WinUI MapControl needs XAML Islands integration).

import {
  MapView,
  mapViewSetRegion,
  mapViewAddPin,
  mapViewSetMapType,
} from "perry/ui";

const map = MapView(800, 600);
mapViewSetRegion(map, 37.7749, -122.4194, 0.05, 0.05);
mapViewAddPin(map, 37.7749, -122.4194, "San Francisco");
mapViewSetMapType(map, 1); // 0=standard, 1=satellite, 2=hybrid

PdfView (issue #516)

PDFView from PDFKit on macOS / iOS / visionOS. pdfViewLoadFile returns 1 on success, 0 on failure.

import {
  PdfView,
  pdfViewLoadFile,
  pdfViewGetPageCount,
} from "perry/ui";

const pdf = PdfView(800, 600);
if (pdfViewLoadFile(pdf, "/tmp/report.pdf")) {
  console.log("pages:", pdfViewGetPageCount(pdf));
}

RichTextEditor (issue #478)

NSTextView with NSAttributedString storage on macOS. Plain-text and HTML round-trip cover persistence; richTextToggleBold / ToggleItalic / ToggleUnderline cover inline formatting via NSResponder actions.

import {
  RichTextEditor,
  richTextSetHtml,
  richTextGetHtml,
  richTextToggleBold,
} from "perry/ui";

const editor = RichTextEditor(600, 400, (text) => console.log(text));
richTextSetHtml(editor, "<p>Hello <b>world</b></p>");
richTextToggleBold(editor);

Rich tooltip (issue #479)

widgetSetRichTooltip(widget, content, hoverDelayMs) — like widgetSetTooltip but the tooltip content is itself a Perry widget. macOS uses NSPanel + NSTrackingArea; other platforms stub. For plain-text tooltips with VoiceOver / a11y support, prefer the simpler widgetSetTooltip.

WebView (issue #658)

WebView({ url, allowedDomains?, onShouldNavigate?, ... }) embeds a real browser engine — WKWebView on Apple, WebView2 on Windows, WebKitGTK 6.0 on Linux, android.webkit.WebView on Android, sandboxed <iframe> on web. See WebView for the full OAuth / callback-interception pattern and the per-platform notes.

These are linked from their own pages where richer examples exist.

Common widget helpers

Every widget handle accepts these:

HelperDescription
widgetSetWidth(w, n) / widgetSetHeight(w, n)Explicit size in points
widgetSetBackgroundColor(w, r, g, b, a)RGBA in [0, 1]
setCornerRadius(w, r)Rounded corners in points
widgetSetOpacity(w, alpha)Opacity in [0, 1]
widgetSetEnabled(w, flag)0 disables, 1 enables
widgetSetHidden(w, flag)0 visible, 1 hidden
widgetSetTooltip(w, text)Tooltip on hover (desktop only)
widgetSetOnClick(w, cb)Click handler
widgetSetOnHover(w, cb)Hover enter/leave (desktop only)
widgetSetOnDoubleClick(w, cb)Double-click handler
widgetSetEdgeInsets(w, top, left, bottom, right)Padding around contents
widgetSetBorderColor(w, r, g, b, a) / widgetSetBorderWidth(w, n)Border
widgetAddChild(parent, child)Attach a child to a container
widgetSetContextMenu(w, menu)Right-click menu

See Styling and Events for deeper coverage.

Next Steps

Layout

Perry provides layout containers that arrange child widgets using the platform’s native layout system. Every snippet below is excerpted from docs/examples/ui/layout/snippets.ts — CI compiles and runs it on every PR.

Layout helpers are free functions: widgetAddChild(parent, child), stackSetAlignment(stack, value), widgetSetEdgeInsets(w, top, left, bottom, right), etc. Stack constructors take a numeric spacing followed by a child array; everything else (alignment, distribution, padding, sizing) is applied post-construction via the free functions on the widget handle.

VStack

Arranges children vertically (top to bottom).

const stack = VStack(16, [
    Text("First"),
    Text("Second"),
    Text("Third"),
])

VStack(spacing, children) — the first argument is the gap in points between children.

HStack

Arranges children horizontally (left to right).

const row = HStack(8, [
    Button("Cancel", noop),
    Spacer(),
    Button("OK", noop),
])

ZStack

Layers children on top of each other (back to front). ZStack() takes no constructor children — populate it with widgetAddChild:

const layered = ZStack()
widgetAddChild(layered, ImageFile("background.png"))
widgetAddChild(layered, Text("Overlay text"))

ScrollView

A scrollable container. Built empty, then filled via scrollviewSetChild:

// ScrollView() takes no args; populate it with `scrollviewSetChild`.
const sv = ScrollView()
const inner = VStack(8, [Text("a"), Text("b"), Text("c")])
scrollviewSetChild(sv, inner)

LazyVStack

A vertically scrolling list that lazily renders items. More efficient than ScrollView + VStack for thousands of rows — on macOS this is backed by NSTableView so only rows in the visible rect are realized.

// `render(index)` is invoked lazily — only rows in the visible rect are realized.
const lazy = LazyVStack(1000, (index: number) => Text(`Row ${index}`))

When the underlying data changes, call lazyvstackUpdate(handle, newCount) to refresh. Override the default 44pt row height with lazyvstackSetRowHeight.

A navigation container that supports push/pop navigation. Push a new view with navstackPush(stack, view, title); pop with navstackPop(stack):

const home = VStack(16, [
    Text("Home Screen"),
    Button("Go to Details", () => {
        navstackPush(nav, Text("Details!"), "Details")
    }),
])
const nav = NavStack()
widgetAddChild(nav, home)

Spacer

A flexible space that expands to fill available room.

const toolbar = HStack(8, [
    Text("Left"),
    Spacer(),
    Text("Right"),
])

Use Spacer() inside HStack or VStack to push widgets apart.

Divider

A visual separator line.

const sections = VStack(12, [
    Text("Section 1"),
    Divider(),
    Text("Section 2"),
])

Nesting Layouts

Layouts can be nested freely. This complete example is verified by CI:

// demonstrates: nested VStack/HStack + Spacer + Divider
// docs: docs/src/ui/layout.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import { App, VStack, HStack, Text, Button, Spacer, Divider } from "perry/ui"

App({
    title: "Layout Example",
    width: 800,
    height: 600,
    body: VStack(16, [
        // Header
        HStack(8, [
            Text("My App"),
            Spacer(),
            Button("Settings", () => {}),
        ]),
        Divider(),
        // Content
        VStack(12, [
            Text("Welcome!"),
            HStack(8, [
                Button("Action 1", () => {}),
                Button("Action 2", () => {}),
            ]),
        ]),
        Spacer(),
        // Footer
        Text("v1.0.0"),
    ]),
})

Child Management

Containers support dynamic child management via free functions:

const list = VStack(16, [])
widgetAddChild(list, Text("appended"))            // append
widgetAddChildAt(list, Text("prepended"), 0)      // insert at index
widgetReorderChild(list, 1, 0)                    // move from→to
const removeMe = Text("temporary")
widgetAddChild(list, removeMe)
widgetRemoveChild(list, removeMe)                 // remove
widgetClearChildren(list)                         // remove all
FunctionDescription
widgetAddChild(parent, child)Append a child widget
widgetAddChildAt(parent, child, index)Insert a child at a specific position
widgetRemoveChild(parent, child)Remove a specific child
widgetReorderChild(widget, fromIndex, toIndex)Move a child to a new position
widgetClearChildren(widget)Remove all children

Stack Alignment

Control how children are aligned within a stack using stackSetAlignment:

const centered = VStack(16, [
    Text("Centered"),
    Text("Content"),
])
stackSetAlignment(centered, 9) // CenterX

VStack alignment (cross-axis = horizontal):

ValueNameEffect
5LeadingChildren align to the leading (left) edge
9CenterXChildren centered horizontally
7WidthChildren stretch to fill the stack’s width

HStack alignment (cross-axis = vertical):

ValueNameEffect
3TopChildren align to the top
12CenterYChildren centered vertically
4BottomChildren align to the bottom

Stack Distribution

Control how children share space within a stack using stackSetDistribution:

const buttons = HStack(8, [
    Button("Cancel", noop),
    Button("OK", noop),
])
stackSetDistribution(buttons, 1) // FillEqually — both buttons get equal width
ValueNameBehavior
0FillDefault. First resizable child fills remaining space
1FillEquallyAll children get equal size
2FillProportionallyChildren sized proportionally to their intrinsic content
3EqualSpacingEqual gaps between children
4EqualCenteringEqual distance between child centers

Fill Parent

Pin a child’s edges to its parent container:

const banner = Text("Full width banner")
widgetMatchParentWidth(banner)
const banneredPage = VStack(16, [banner, Text("Normal width")])
  • widgetMatchParentWidth(widget) — stretch to fill parent’s width
  • widgetMatchParentHeight(widget) — stretch to fill parent’s height

Content Hugging

Control whether a widget resists being stretched beyond its intrinsic size:

const tight = Text("I stay small")
widgetSetHugging(tight, 750) // High priority — resist stretching

const stretchy = Text("I stretch")
widgetSetHugging(stretchy, 1) // Low priority — stretch to fill
  • High priority (250–750+): widget resists stretching, stays at its natural size
  • Low priority (1–249): widget stretches to fill available space

Overlay Positioning

For absolute positioning, add overlay children to any container:

// Overlay parent must be a ZStack — macOS NSView allows `addSubview` on
// any view, but GTK4 can only float children above siblings inside
// `gtk::Overlay` (which is what ZStack is backed by).
const container = ZStack()
widgetAddChild(container, VStack(16, [Text("Main content")])) // main child

const badge = Text("3")
setCornerRadius(badge, 10)
widgetSetBackgroundColor(badge, 1.0, 0.231, 0.188, 1.0) // RGBA red

widgetAddOverlay(container, badge)
widgetSetOverlayFrame(badge, 280, 10, 20, 20) // x, y, width, height

Overlay children are positioned absolutely relative to their parent — similar to CSS position: absolute.

Split Views

Create resizable split panes for sidebar layouts:

const split = SplitView()

const sidebar = VStack(8, [Text("Navigation"), Text("Item 1"), Text("Item 2")])
const content = VStack(16, [Text("Main Content")])

splitViewAddChild(split, sidebar)
splitViewAddChild(split, content)

The user can drag the divider to resize panes. On macOS this maps to NSSplitView.

Stacks with Built-in Padding

Create a stack with padding in a single call. The order is top, left, bottom, right (CSS-shorthand-style), not top/right/bottom/left:

// VStackWithInsets(spacing, top, left, bottom, right) — note: order is
// top/left/bottom/right (CSS-style), not top/right/bottom/left.
const card = VStackWithInsets(12, 16, 16, 16, 16)
widgetAddChild(card, Text("Padded content"))
widgetAddChild(card, Text("More content"))

HStackWithInsets(spacing, top, left, bottom, right) is the horizontal counterpart. Equivalent to creating a stack and then calling widgetSetEdgeInsets, but more concise. Children are added via widgetAddChild rather than the constructor array.

Detaching Hidden Views

By default, hidden children still occupy space in a stack. To collapse them:

const collapsible = VStack(8, [Text("Always visible"), Text("Sometimes hidden")])
stackSetDetachesHidden(collapsible, 1) // Hidden children leave no gap
// You can then toggle a child:
const sometimesHidden = Text("toggle me")
widgetSetHidden(sometimesHidden, 1) // 1 = hidden, 0 = visible

Common Layout Patterns

Centered content

const page = VStack(16, [Text("Title"), Text("Subtitle")])
stackSetAlignment(page, 9) // CenterX

Search row that fills the width

const searchInput = TextField("Search...", (v: string) => search.set(v))
widgetMatchParentWidth(searchInput)
const results = VStack(8, [])
const searchPage = VStack(12, [searchInput, results])

Floating badge / overlay

// Wrap the icon in a ZStack so the badge can float above it on every
const icon = ZStack()
widgetAddChild(icon, ImageSymbol("bell"))
const dotBadge = Text("3")
widgetAddOverlay(icon, dotBadge)
widgetSetOverlayFrame(dotBadge, 20, -5, 16, 16)

Toolbar with spacers

const titleBar = HStack(8, [
    Button("Back", noop),
    Spacer(),
    Text("Page Title"),
    Spacer(),
    Button("Settings", noop),
])

Next Steps

Styling

Perry widgets accept an inline style: { ... } object that maps to each platform’s native styling APIs. The same shape works on every Widget constructor — Button, Text, Toggle, Slider, VStack/HStack, and friends — so cross-platform styling code stays the same regardless of target.

Pass a StyleProps object as the trailing argument to any widget constructor. Codegen destructures the literal at HIR time into a sequence of native setter calls, so the runtime shape is the same as hand-writing the imperative pattern below — but the source is much shorter:

const card = Button("Save", () => {
    console.log("saved")
}, {
    backgroundColor: { r: 0.231, g: 0.510, b: 0.965, a: 1.0 },
    borderColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.1 },
    borderWidth: 1,
    borderRadius: 8,
    padding: 12,
    opacity: 0.95,
    shadow: {
        color: { r: 0.0, g: 0.0, b: 0.0, a: 0.25 },
        blur: 12,
        offsetX: 0,
        offsetY: 4,
    },
    tooltip: "Save the current document",
    enabled: true,
})

The style arg is optional; widgets without it look identical to calls before this API existed. See docs/examples/ui/styling/button_inline_style.ts for the full file.

What style accepts

PropTypeMaps to
backgroundColorstring | PerryColorwidgetSetBackgroundColor
colorstring | PerryColortextSetColor / buttonSetTextColor
borderColorstring | PerryColorwidgetSetBorderColor
borderWidthnumberwidgetSetBorderWidth
borderRadiusnumbersetCornerRadius
paddingnumber | { top, right, bottom, left }widgetSetEdgeInsets
opacitynumber (0..=1)widgetSetOpacity
shadow{ color, blur, offsetX, offsetY }widgetSetShadow
textDecoration"none" | "underline" | "strikethrough"textSetDecoration
gradient{ angle, stops: [c1, c2] }widgetSetBackgroundGradient
fontSize, fontWeight, fontFamilynumber / stringtextSetFont*
tooltipstringwidgetSetTooltip
hiddenbooleanwidgetSetHidden
enabledbooleanwidgetSetEnabled

Color values

Color props accept four interchangeable shapes:

backgroundColor: "#3B82F6"                                   // hex 6/8
backgroundColor: "#3B82F6FF"                                 // hex with alpha
backgroundColor: "blue"                                      // named color
backgroundColor: { r: 0.231, g: 0.510, b: 0.965, a: 1.0 }   // PerryColor object
backgroundColor: themeColor                                  // runtime variable

Named colors: white, black, red, green, blue, yellow, cyan, magenta, gray / grey, transparent. Hex forms supported: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.

Literals (the first four forms) compile-time-fold into 4 baked-in float arguments — zero runtime cost. Runtime variables resolve through js_color_parse_channel (a small CSS color parser in perry-runtime) so backgroundColor: someStringVar works the same as the literal form.

Padding shapes

A single number applies to all four sides; an object picks per-side:

padding: 12                                       // all four sides 12
padding: { top: 8, right: 16, bottom: 8, left: 16 }  // per-side

Missing sides default to 0.

Container styling

VStack and HStack accept style after the children array:

// VStack with explicit spacing AND inline style — children + style.
const card = VStack(8, [
    Text("Heading"),
    Text("Subtitle"),
    Button("Action", () => { console.log("clicked") }),
], {
    backgroundColor: { r: 0.96, g: 0.97, b: 0.99, a: 1.0 },
    borderRadius: 12,
    padding: 16,
    shadow: {
        color: { r: 0.0, g: 0.0, b: 0.0, a: 0.1 },
        blur: 8,
        offsetY: 2,
    },
})

// HStack with no explicit spacing (children-array first form) + style.
const toolbar = HStack([
    Text("Left"),
    Text("Right"),
], {
    backgroundColor: { r: 0.2, g: 0.2, b: 0.2, a: 1.0 },
    padding: { top: 8, right: 16, bottom: 8, left: 16 },
    borderRadius: 6,
})

Both shapes work — VStack(children, style?) and VStack(spacing, children, style?).

Coming from CSS

If you’re coming from web, the conceptual mapping is:

CSSPerry inline style
display: flex; flex-direction: columnVStack(spacing, [...])
display: flex; flex-direction: rowHStack(spacing, [...])
width: 100%widgetMatchParentWidth(widget)
padding: 10px 20pxpadding: { top: 10, right: 20, bottom: 10, left: 20 }
gap: 16pxVStack(16, [...]) — first argument is the gap
CSS variables / design tokensperry-styling package
opacity: 0.5opacity: 0.5
border-radius: 8pxborderRadius: 8
background: #3B82F6backgroundColor: "#3B82F6"
box-shadow: 0 4px 12px rgba(0,0,0,0.25)shadow: { color: "#0004", blur: 12, offsetY: 4 }
text-decoration: underlinetextDecoration: "underline"

See Layout for full details on alignment, distribution, overlays, and split views.

Imperative API (underlying)

The inline style object lowers to the same FFI calls as Perry’s imperative free-function setters: widgetSet*, textSet*, buttonSet*. They take the widget handle as the first argument and remain available for cases where you want fine-grained control or need to mutate styles after creation. Colors here are RGBA floats in [0.0, 1.0] (divide each hex byte by 255 — 0xFF3B30(1.0, 0.231, 0.188, 1.0)).

Every snippet below is excerpted from docs/examples/ui/styling/snippets.ts, which CI compiles and runs on every PR — so the API drawn here is always the API the compiler accepts.

import {
    App,
    VStack, VStackWithInsets, HStack, Spacer,
    Text, Button,
    textSetColor, textSetFontSize, textSetFontFamily, textSetFontWeight,
    setCornerRadius, setPadding,
    widgetAddChild,
    widgetSetBackgroundColor, widgetSetBackgroundGradient,
    widgetSetBorderColor, widgetSetBorderWidth,
    widgetSetEdgeInsets,
    widgetSetWidth, widgetSetHeight, widgetMatchParentWidth,
    widgetSetOpacity,
    widgetSetControlSize,
    widgetSetTooltip,
    widgetSetEnabled,
} from "perry/ui"

Colors

const colored = Text("Colored text")
textSetColor(colored, 1.0, 0.0, 0.0, 1.0)              // r, g, b, a in [0,1]
widgetSetBackgroundColor(colored, 0.94, 0.94, 0.94, 1.0)

Fonts

const font = Text("Styled text")
textSetFontSize(font, 24)                  // Font size in points
textSetFontFamily(font, "Menlo")           // Font family name
textSetFontWeight(font, 24, 700)           // Re-set size + weight together

Use "monospaced" for the system monospaced font.

Corner Radius

const rounded = Button("Rounded", () => {})
setCornerRadius(rounded, 12)

Borders

const bordered = VStack(0, [])
widgetSetBorderColor(bordered, 0.8, 0.8, 0.8, 1.0)
widgetSetBorderWidth(bordered, 1)

Padding and Insets

const padded = VStack(8, [Text("Padded content")])
// Both names accept (widget, top, left, bottom, right):
setPadding(padded, 16, 16, 16, 16)
widgetSetEdgeInsets(padded, 10, 20, 10, 20)

Sizing

const sized = VStack(0, [])
widgetSetWidth(sized, 300)
widgetSetHeight(sized, 200)
widgetMatchParentWidth(sized) // expand to fill parent's width

Opacity

const dim = Text("Semi-transparent")
widgetSetOpacity(dim, 0.5) // 0.0 to 1.0

Background Gradient

const grad = VStack(0, [])
// Two RGBA stops + angle (degrees, 0 = top-to-bottom).
widgetSetBackgroundGradient(grad,
    1.0, 0.0, 0.0, 1.0,   // start (red)
    0.0, 0.0, 1.0, 1.0,   // end   (blue)
    0,                    // angle
)

Control Size

const small = Button("Small", () => {})
widgetSetControlSize(small, 0) // 0=mini, 1=small, 2=regular, 3=large

macOS: Maps to NSControl.ControlSize. Other platforms may interpret differently.

Tooltips

const tip = Button("Hover me", () => {})
widgetSetTooltip(tip, "Click to perform action")

macOS/Windows/Linux: Native tooltips. iOS/Android: No tooltip support. Web: HTML title attribute.

Enabled/Disabled

const submit = Button("Submit", () => {})
widgetSetEnabled(submit, 0)  // 0 = disabled, 1 = enabled

Complete Imperative Example

// demonstrates: a styled counter card using the real free-function API
// docs: docs/src/ui/styling.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import {
    App,
    Text,
    Button,
    VStack,
    HStack,
    State,
    Spacer,
    textSetFontSize,
    textSetFontFamily,
    textSetColor,
    widgetSetBackgroundColor,
    widgetSetEdgeInsets,
    setCornerRadius,
} from "perry/ui"

// Note: widgetSetBorderColor / widgetSetBorderWidth are macOS/iOS/Windows
// only — perry-ui-gtk4 doesn't export them (GTK4 borders are CSS-driven).
// Omitted from this demo so it compiles everywhere.

const count = State(0)

const title = Text("Counter")
textSetFontSize(title, 28)
textSetColor(title, 0.1, 0.1, 0.1, 1.0)

const display = Text(`${count.value}`)
textSetFontSize(display, 48)
textSetFontFamily(display, "monospaced")
textSetColor(display, 0.0, 0.478, 1.0, 1.0)

const decBtn = Button("-", () => count.set(count.value - 1))
setCornerRadius(decBtn, 20)
widgetSetBackgroundColor(decBtn, 1.0, 0.231, 0.188, 1.0)

const incBtn = Button("+", () => count.set(count.value + 1))
setCornerRadius(incBtn, 20)
widgetSetBackgroundColor(incBtn, 0.204, 0.78, 0.349, 1.0)

const controls = HStack(8, [decBtn, Spacer(), incBtn])
widgetSetEdgeInsets(controls, 20, 20, 20, 20)

const container = VStack(16, [title, display, controls])
widgetSetEdgeInsets(container, 40, 40, 40, 40)
setCornerRadius(container, 16)
widgetSetBackgroundColor(container, 1.0, 1.0, 1.0, 1.0)

App({
    title: "Styled App",
    width: 400,
    height: 300,
    body: container,
})

Composing Styles (imperative helper functions)

Reduce repetition by creating helper functions:

function card(children: number[]): number {
  const c = VStackWithInsets(12, 16, 16, 16, 16)
  setCornerRadius(c, 12)
  widgetSetBackgroundColor(c, 1.0, 1.0, 1.0, 1.0)
  widgetSetBorderColor(c, 0.9, 0.9, 0.9, 1.0)
  widgetSetBorderWidth(c, 1)
  for (const child of children) widgetAddChild(c, child)
  return c
}

For larger apps, use the perry-styling package to define design tokens in JSON and generate a typed theme file. See Theming for the full workflow.

Platform support

Per-prop, per-platform support is tracked in the styling matrix — auto-generated from crates/perry-ui/src/styling_matrix.rs and CI-checked against each backend’s lib.rs exports on every PR.

Current state (issue #185):

PlatformWiredStubMissing
macOS / iOS / tvOS / visionOS / watchOS / Android / Web43/4300
GTK4 (Linux)39/4304
Windows38/4350
  • GTK4 has 4 styling props (widget.on_click, button.content_tint_color, button.image_position, stack.detaches_hidden) that need a Linux contributor — tracked in issue #202. Inline style: {...} calls referencing only the wired props compile and run cleanly today; the missing props silently no-op until that issue lands.
  • Windows has 5 props in a “deferred-paint family” (shadow, opacity, border_color, border_width, text.decoration) where the FFI symbol exists and stores the requested params, but a custom WM_PAINT rendering pass is needed to make them visible — tracked in issue #210. User code authoring inline styles compiles and links cleanly on Windows; the visual rendering catches up when that issue lands.

Next Steps

  • Widgets — All available widgets
  • Layout — Layout containers
  • Animation — Animate style changes
  • Theming — Design tokens via the perry-styling package

State Management

Perry uses reactive state to automatically update the UI when data changes. Every snippet below is excerpted from docs/examples/ui/state/snippets.ts — CI compiles and runs it on every PR.

Creating State

const counter = State(0)               // number state
const username = State("Perry")        // string state
const items = State<string[]>([])      // array state

State(initialValue) creates a reactive state container.

Reading and Writing

const value = counter.value     // Read current value
counter.set(42)                  // Set new value → triggers UI update

Every .set() call re-renders the widget tree with the new value.

Reactive Text

Template literals with state.value update automatically:

const showCount = State(0)
const countLabel = Text(`Count: ${showCount.value}`)
// The text updates whenever showCount changes.

This works because Perry detects state.value reads inside template literals and creates reactive bindings.

Binding Inputs to State

Input widgets expose an onChange callback. Forward that into a state’s .set(...) to keep the state in sync as the user types/toggles/drags:

const input = State("")
const field = TextField("Type here...", (v: string) => input.set(v))

// Optional: also let input.set("hello") update the field on screen.
stateBindTextfield(input, field)

Input control signatures:

  • TextField(placeholder, onChange) — text input, onChange: (value: string) => void
  • SecureField(placeholder, onChange) — password input, onChange: (value: string) => void
  • Toggle(label, onChange) — boolean toggle, onChange: (value: boolean) => void
  • Slider(min, max, onChange) — numeric slider, onChange: (value: number) => void
  • Picker(onChange) — dropdown, onChange: (index: number) => void; items via pickerAddItem

For programmatic-to-UI sync (state-drives-widget) use the dedicated binders: stateBindTextfield, stateBindSlider, stateBindToggle, stateBindTextNumeric, stateBindVisibility.

onChange Callbacks

Listen for state changes with the free-function stateOnChange:

const watched = State(0)
stateOnChange(watched, (newValue: number) => {
    console.log(`Count changed to ${newValue}`)
})

ForEach

Render a list from numeric state (the index count):

const fruits = State(["Apple", "Banana", "Cherry"])
const fruitCount = State(3)

const fruitList = VStack(16, [
    ForEach(fruitCount, (i: number) =>
        Text(`${i + 1}. ${fruits.value[i]}`),
    ),
])

Note: ForEach iterates by index over a numeric state. Keep a count state in sync with your array, then read the items via array.value[i] inside the closure.

ForEach re-renders the list when the count state changes:

// Add an item:
fruits.set([...fruits.value, "Date"])
fruitCount.set(fruitCount.value + 1)

// Remove an item:
fruits.set(fruits.value.filter((_, i) => i !== 1))
fruitCount.set(fruitCount.value - 1)

Conditional Rendering

Use state to conditionally show widgets:

const showDetails = State(false)
const detailsLabel: number = showDetails.value
    ? Text("Details are visible!")
    : Spacer()
const detailsPanel = VStack(16, [
    Button("Toggle", () => showDetails.set(!showDetails.value)),
    detailsLabel,
])

Multi-State Text

Text can depend on multiple state values:

const firstName = State("John")
const lastName = State("Doe")

const greeting = Text(`Hello, ${firstName.value} ${lastName.value}!`)
// Updates when either firstName or lastName changes.

State with Objects and Arrays

const user = State({ name: "Perry", age: 0 })

// Update by replacing the whole object:
user.set({ ...user.value, age: 1 })

const todos = State<{ text: string; done: boolean }[]>([])

// Add a todo:
todos.set([...todos.value, { text: "New task", done: false }])

// Toggle a todo (must produce a new array reference):
const next = todos.value.slice()
if (next.length > 0) {
    next[0] = { ...next[0], done: !next[0].done }
    todos.set(next)
}

Note: State uses identity comparison. You must create a new array/object reference for changes to be detected. Mutating in-place without calling .set() with a new reference won’t trigger updates.

Complete Example

// demonstrates: complete reactive todo app combining State, ForEach, and widget tree mutation
// docs: docs/src/ui/state.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm

import {
    App,
    Text,
    Button,
    TextField,
    VStack,
    HStack,
    State,
    ForEach,
    Spacer,
    Divider,
} from "perry/ui"

const todos = State<string[]>([])
const count = State(0)
const input = State("")

App({
    title: "Todo App",
    width: 480,
    height: 600,
    body: VStack(16, [
        Text("My Todos"),

        HStack(8, [
            TextField("What needs to be done?", (value: string) => input.set(value)),
            Button("Add", () => {
                const text = input.value
                if (text.length > 0) {
                    todos.set([...todos.value, text])
                    count.set(count.value + 1)
                    input.set("")
                }
            }),
        ]),

        Divider(),

        ForEach(count, (i: number) =>
            HStack(8, [
                Text(todos.value[i]),
                Spacer(),
                Button("Delete", () => {
                    todos.set(todos.value.filter((_, idx) => idx !== i))
                    count.set(count.value - 1)
                }),
            ]),
        ),

        Spacer(),
        Text(`${count.value} items`),
    ]),
})

This program is built and run by CI (scripts/run_doc_tests.sh), so the snippet above always matches the compiled artifact under docs/examples/ui/state/todo_app.ts.

Next Steps

  • Events — Click, hover, keyboard events
  • Widgets — All available widgets
  • Layout — Layout containers

Events

Perry widgets support native event handlers for user interaction. Every snippet below is excerpted from docs/examples/ui/events/snippets.ts — CI compiles and runs it on every PR, so the API drawn here is the API the runtime exposes.

Event handlers are registered as free functions that take the widget handle as the first argument. The widget handle itself is opaque (number at the type level); perry’s API is function-first throughout.

onClick

const greet = Button("Click me", () => {
    log.set("Button clicked")
})

// Or attach a click handler to a non-button widget after creation:
const label = Text("Clickable text")
widgetSetOnClick(label, () => {
    log.set("Text clicked")
})

onHover

Triggered when the cursor enters the widget.

const hoverBtn = Button("Hover me", () => {})
widgetSetOnHover(hoverBtn, () => {
    log.set("hovered")
})

Note: Hover events are available on macOS, Windows, Linux, and Web. iOS and Android use touch interactions instead. The callback fires once on enter; if you need a “left” event you’ll have to track it yourself.

onDoubleClick

const dbl = Text("Double-click me")
widgetSetOnDoubleClick(dbl, () => {
    log.set("double-clicked!")
})

Keyboard Shortcuts

Register in-app keyboard shortcuts (active when the app is focused):

// Cmd+N on macOS, Ctrl+N on other platforms (modifier 1 = Cmd/Ctrl).
addKeyboardShortcut("n", 1, () => {
    log.set("New document")
})

// Cmd+Shift+S — modifiers add: 1 (Cmd/Ctrl) + 2 (Shift) = 3.
addKeyboardShortcut("s", 3, () => {
    log.set("Save as...")
})

Modifier bits: 1 = Cmd (macOS) / Ctrl (Windows/Linux), 2 = Shift, 4 = Option (macOS) / Alt (others), 8 = Control (macOS only). Combine by adding — 3 = Cmd+Shift, 5 = Cmd+Option, etc.

Keyboard shortcuts are also available on menu items:

const fileMenu = menuCreate()
menuAddItem(fileMenu, "New", () => log.set("file/new"))
menuAddItem(fileMenu, "Save As", () => log.set("file/saveAs"))

Global Hotkeys

Register a hotkey that fires system-wide, even when the app is in the background:

// System-wide: fires even when the app is in the background.
// macOS: real Carbon RegisterEventHotKey. Other platforms: no-op.
registerGlobalHotkey("F5", 0, () => {
    log.set("Global F5 hotkey fired")
})

// Cmd+Shift+G (modifiers: 1=Cmd + 2=Shift = 3)
registerGlobalHotkey("g", 3, () => {
    log.set("Global Cmd+Shift+G fired")
})

Platform support: macOS uses Carbon RegisterEventHotKey (real implementation). Linux, Windows, iOS, tvOS, visionOS, watchOS, and Android log the registration and no-op — global hotkeys on those platforms require OS-level portal / hook APIs that vary per OS.

Clipboard

// Copy to clipboard
clipboardWrite("Hello, clipboard!")

// Read from clipboard
const text = clipboardRead()
log.set(`clipboard length: ${text.length}`)

Complete Example

// demonstrates: click + hover + double-click + keyboard shortcut all wired to
// a single State-backed status label
// docs: docs/src/ui/events.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import {
    App,
    Text,
    Button,
    VStack,
    State,
    Spacer,
    addKeyboardShortcut,
    widgetSetOnHover,
    widgetSetOnDoubleClick,
} from "perry/ui"

const lastEvent = State("No events yet")

// Cmd+R (modifiers: 1 = Cmd/Ctrl).
addKeyboardShortcut("r", 1, () => {
    lastEvent.set("Keyboard: Cmd+R")
})

const hoverBtn = Button("Hover me", () => {})
widgetSetOnHover(hoverBtn, () => {
    lastEvent.set("Hover fired")
})

const dblLabel = Text("Double-click me")
widgetSetOnDoubleClick(dblLabel, () => {
    lastEvent.set("Double-clicked!")
})

App({
    title: "Events Demo",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text(`Last event: ${lastEvent.value}`),
        Spacer(),
        Button("Click me", () => {
            lastEvent.set("Button clicked")
        }),
        hoverBtn,
        dblLabel,
    ]),
})

Next Steps

Canvas

The Canvas widget provides a 2D drawing surface for custom graphics.

Availability: Canvas is wired in the LLVM codegen path (macOS, iOS, Linux, Android) and the JS / web / wasm codegen path. Closed via #190. The snippets below are compile-link verified by the doc-tests harness against docs/examples/ui/canvas/snippets.ts; see that file for the full standalone program.

The drawing API is method-based on the canvas handle (matching the FFI shape — perry_ui_canvas_set_fill_color(handle, r, g, b, a) etc.). Colors are RGBA floats in [0.0, 1.0].

Creating a Canvas

const canvas = Canvas(400, 300)
canvas.setFillColor(1.0, 0.4, 0.0, 1.0)
canvas.fillRect(10, 10, 100, 80)

Canvas(width, height) creates a canvas widget; subsequent draw operations are method calls on the returned handle.

Drawing Shapes

Rectangles

canvas.setFillColor(1.0, 0.0, 0.0, 1.0)    // red
canvas.fillRect(10, 10, 100, 80)

canvas.setStrokeColor(0.0, 0.0, 1.0, 1.0)  // blue
canvas.setLineWidth(2)
canvas.strokeRect(150, 10, 100, 80)

Lines

canvas.setStrokeColor(0.0, 0.0, 0.0, 1.0)
canvas.setLineWidth(1)
canvas.beginPath()
canvas.moveTo(10, 10)
canvas.lineTo(200, 150)
canvas.stroke()

Circles and Arcs

canvas.setFillColor(0.0, 1.0, 0.0, 1.0)
canvas.beginPath()
canvas.arc(200, 150, 50, 0, Math.PI * 2)  // x, y, radius, startAngle, endAngle
canvas.fill()

Text

canvas.setFillColor(0.0, 0.0, 0.0, 1.0)
canvas.setFont("16px sans-serif")
canvas.fillText("Hello Canvas!", 50, 50)

Platform Notes

PlatformImplementationStatus
WebHTML5 CanvasWired
WASMHTML5 Canvas via JS bridgeWired
macOSCore Graphics (CGContext)Wired
iOSCore Graphics (CGContext)Wired
LinuxCairoWired
WindowsGDIPlanned
AndroidCanvas/BitmapWired

Next Steps

Menus

Perry supports native menu bars, context menus, and toolbars across all platforms. Every snippet below is excerpted from docs/examples/ui/menus/snippets.ts — CI compiles and runs it on every PR.

The menu API is handle-based and free-function: build menus with menuCreate(), fill them with menuAddItem / menuAddItemWithShortcut, and attach them with menuBarAddMenu(bar, title, menu). Submenus go through menuAddSubmenu(parent, title, submenu).

// Menus are created independently, then attached. Build child menus first,
// then hand them to `menuBarAddMenu(bar, title, menu)`.
const menuBar = menuBarCreate()

// File menu
const fileMenu = menuCreate()
menuAddItemWithShortcut(fileMenu, "New",         "n", () => status.set("file/new"))
menuAddItemWithShortcut(fileMenu, "Open…",       "o", () => status.set("file/open"))
menuAddSeparator(fileMenu)
menuAddItemWithShortcut(fileMenu, "Save",        "s", () => status.set("file/save"))
menuAddItemWithShortcut(fileMenu, "Save As…",    "S", () => status.set("file/saveAs"))
menuBarAddMenu(menuBar, "File", fileMenu)

// Edit menu
const editMenu = menuCreate()
menuAddItemWithShortcut(editMenu, "Undo", "z", () => status.set("edit/undo"))
menuAddItemWithShortcut(editMenu, "Redo", "Z", () => status.set("edit/redo"))
menuAddSeparator(editMenu)
menuAddItemWithShortcut(editMenu, "Cut",   "x", () => status.set("edit/cut"))
menuAddItemWithShortcut(editMenu, "Copy",  "c", () => status.set("edit/copy"))
menuAddItemWithShortcut(editMenu, "Paste", "v", () => status.set("edit/paste"))
menuBarAddMenu(menuBar, "Edit", editMenu)

// Submenu: View → Zoom
const viewMenu = menuCreate()
const zoomSubmenu = menuCreate()
menuAddItemWithShortcut(zoomSubmenu, "Zoom In",     "+", () => status.set("zoom/in"))
menuAddItemWithShortcut(zoomSubmenu, "Zoom Out",    "-", () => status.set("zoom/out"))
menuAddItemWithShortcut(zoomSubmenu, "Actual Size", "0", () => status.set("zoom/reset"))
menuAddSubmenu(viewMenu, "Zoom", zoomSubmenu)
menuBarAddMenu(menuBar, "View", viewMenu)

menuBarAttach(menuBar)
FunctionDescription
menuBarCreate()Create a new (empty) menu bar
menuCreate()Create a new menu — used as a child of the bar or as a submenu
menuBarAddMenu(bar, title, menu)Attach a top-level menu under title
menuAddItem(menu, label, callback)Append an item without a shortcut
menuAddItemWithShortcut(menu, label, shortcut, callback)Append an item with a keyboard shortcut
menuAddSeparator(menu)Append a horizontal separator line
menuAddSubmenu(parent, title, submenu)Nest a previously-created menu under a label
menuBarAttach(bar)Install the bar as the application’s main menu

Keyboard Shortcuts

The third argument to menuAddItemWithShortcut is the shortcut key:

ShortcutmacOSOther
"n"Cmd+NCtrl+N
"S"Cmd+Shift+SCtrl+Shift+S
"+"Cmd++Ctrl++

Uppercase letters imply Shift.

Context Menus

Right-click menus are attached to widgets via widgetSetContextMenu(widget, menu). Build the menu the same way as a menu-bar entry, then bind it:

const label = Text("Right-click me")
const ctx = menuCreate()
menuAddItem(ctx, "Copy",   () => status.set("ctx/copy"))
menuAddItem(ctx, "Paste",  () => status.set("ctx/paste"))
menuAddSeparator(ctx)
menuAddItem(ctx, "Delete", () => status.set("ctx/delete"))
widgetSetContextMenu(label, ctx)

Toolbar

Add a toolbar to a window. toolbarAddItem takes an identifier (used by AppKit to deduplicate items) and a label:

const toolbar = toolbarCreate()
toolbarAddItem(toolbar, "new",  "New",  () => status.set("tb/new"))
toolbarAddItem(toolbar, "save", "Save", () => status.set("tb/save"))
toolbarAddItem(toolbar, "run",  "Run",  () => status.set("tb/run"))

// `toolbarAttach(toolbar, window)` mounts onto a specific window.
const win = Window("Toolbar Demo", 800, 600)
toolbarAttach(toolbar, win as unknown as number)

Platform Notes

PlatformMenu BarContext MenuToolbar
macOSNSMenuNSMenuNSToolbar
iOS— (no menu bar)UIMenuUIToolbar
WindowsHMENU/SetMenuHorizontal layout
LinuxGMenu/set_menubarHeaderBar
WebDOMDOMDOM

iOS: Menu bars are not applicable. Use toolbar and navigation patterns instead.

Next Steps

  • Events — Keyboard shortcuts and interactions
  • Dialogs — File dialogs and alerts
  • Layout — Toolbar and navigation patterns

Tray Icon

Perry ships a cross-platform system tray API on perry/ui (issue #490). The same six functions work on every desktop target — macOS, Windows, Linux/GTK4 — and link as no-ops on the mobile / embedded backends.

The API is handle-based and free-function: build a tray with trayCreate(iconPath), attach a context menu built with the existing menuCreate / menuAddItem API via trayAttachMenu(tray, menu), and register a left-click callback with trayOnClick.

Basic Usage

// Build the tray BEFORE App() — the tray icon installs while the
// runtime is starting up, so it's already live when the main window
// appears.
const tray = trayCreate("")  // empty path → "●" placeholder
traySetTooltip(tray, "My App")

// Right-click (or left-click on macOS) opens the menu attached below.
const menu = menuCreate()
menuAddItem(menu, "Show", () => status.set("tray/show"))
menuAddSeparator(menu)
menuAddItem(menu, "Quit", () => status.set("tray/quit"))
trayAttachMenu(tray, menu)

// Optional: left-click handler. On macOS the menu pops on left-click,
// so this fires only when no menu is attached. On Windows / Linux,
// left-click and the menu are independent — typical usage is
// "left-click → show main window, right-click → menu".
trayOnClick(tray, () => {
    status.set("tray/click")
})

API

FunctionDescription
trayCreate(iconPath: string): WidgetCreate the tray icon. iconPath is a filesystem path to a PNG (or .icns on macOS, .ico on Windows). Pass "" to use a “●” placeholder.
traySetIcon(tray, iconPath)Hot-swap the icon image. Empty path is a no-op.
traySetTooltip(tray, tooltip)Set the tooltip text shown on hover.
trayAttachMenu(tray, menu)Attach a context menu (built with menuCreate / menuAddItem). Right-click — or left-click on macOS — opens the menu.
trayOnClick(tray, callback)Register a left-click handler. On macOS the menu pops on left-click, so this only fires when no menu is attached; on Windows / Linux, left-click and menu are independent.
trayDestroy(tray)Remove the icon. The handle stays valid (subsequent setters are no-ops) so existing closures don’t crash.

Updating the Icon

// Hot-swap the icon. The path can be a PNG (every platform), .icns
// (macOS), or .ico (Windows). Empty path is a no-op.
traySetIcon(tray, "./assets/tray.png")

Removal

// Remove the tray icon. After this, the handle is dead — set_icon /
// set_tooltip / attach_menu calls become no-ops.
trayDestroy(tray)

Platform Notes

PlatformBackendNotes
macOSNSStatusItem from NSStatusBar.systemIcon appears top-right of the menu bar. Click auto-pops the attached menu. Tooltip routes through the button’s toolTip. PNG and .icns paths supported. Icons are rendered as templates — single-color glyphs adapt to light/dark mode.
WindowsShell_NotifyIconW + TrackPopupMenuIcon appears in the notification area (bottom-right). Left-click → onClick callback. Right-click → menu. PNG and .ico paths supported (PNG via LoadImageW with LR_LOADFROMFILE). trayCreate must come after App({...}) since the tray reuses the main window’s WndProc.
Linux/GTK4StatusNotifierItem (KSNI) over D-BusWorks on KDE Plasma, GNOME-with-appindicator-extension, XFCE, Cinnamon, MATE, Budgie, LXQt out of the box. Vanilla GNOME without the extension keeps the service alive but the icon doesn’t render — a one-line warning logs at create time.
iOS / tvOS / visionOS / watchOSno-opThese platforms have no system-tray concept. Calls link cleanly and return 0 / no-op so cross-platform code compiles unchanged.
Androidno-opAndroid’s “tray” is the notifications shade, which is a different concept. The functions link as no-ops.
HarmonyOSno-opAuto-stubbed at compile time.
Webno-op (warns)Browser tabs have no tray equivalent.

Click vs. Menu

Different desktops have different click conventions; Perry exposes both hooks so a single TypeScript app can do the right thing everywhere:

PlatformLeft-clickRight-click
macOSPops the attached menuSame as left-click
WindowsFires onClickPops the attached menu
LinuxFires onClick (KSNI activate)Pops the attached menu

The typical pattern: use onClick to “show / focus the main window” and attachMenu for the user-facing actions. macOS users will see the menu pop on every click, which is the platform-native behavior.

Common Patterns

Background app (no Dock icon, tray-only)

On macOS, set the activation policy to "accessory" so the app has no Dock icon and lives only as a tray-resident process. (See the platform docs for activation-policy details.)

Building the menu after the tray

The menu lookup on every backend happens at click time, not at attach time. This means you can rebuild the menu (menuClear + fresh menuAddItem calls) between user clicks — the new menu wins on the next click without re-attaching.

Next Steps

  • Menus — Full menu / submenu / shortcut API used by trayAttachMenu
  • State Management — Make tray menu items react to app state
  • Multi-Window — Show / hide windows from tray actions

Dialogs

Perry provides native dialog functions for file selection, alerts, and sheets. Every snippet below is excerpted from docs/examples/ui/dialogs/snippets.ts — CI compiles and links the file on every PR, so the API drawn here is the API the runtime exposes.

All file dialogs are callback-based (the OS-modal panel is non-blocking on Apple platforms, so a synchronous return wouldn’t be possible without freezing the app’s run loop). The callback receives an empty string when the user cancels.

File Open Dialog

function pickFile(): void {
    openFileDialog((path: string) => {
        if (path.length > 0) {
            console.log(`Selected: ${path}`)
        } else {
            console.log("Open dialog cancelled")
        }
    })
}

Folder Selection Dialog

function pickFolder(): void {
    openFolderDialog((path: string) => {
        if (path.length > 0) {
            console.log(`Selected folder: ${path}`)
        }
    })
}

Save File Dialog

function pickSaveTarget(): void {
    saveFileDialog((path: string) => {
        if (path.length > 0) {
            console.log(`Will save to: ${path}`)
        }
    }, "untitled", "txt")
}

saveFileDialog(callback, defaultName, extension) pre-fills the name field with defaultName.<extension>.

Alert

Display a native alert dialog:

function showSimpleAlert(): void {
    alert("Operation Complete", "Your file has been saved successfully.")
}

alert(title, message) shows a modal alert with an OK button.

Alert with Buttons

function confirmDelete(): void {
    alertWithButtons(
        "Delete Item?",
        "This action cannot be undone.",
        ["Cancel", "Delete"],
        (index: number) => {
            if (index === 1) {
                console.log("user confirmed delete")
            }
        },
    )
}

alertWithButtons(title, message, buttons, callback) invokes the callback with the 0-based index of the button the user clicked. By convention put a destructive label last and check the index in the callback.

Sheets

Sheets are modal panels attached to a window. Build the body, hand it (with a size) to sheetCreate, then sheetPresent it. To dismiss programmatically, keep the handle around and call sheetDismiss(handle):

function showSheet(): void {
    let sheet = 0
    const body = VStack(16, [
        Text("Sheet Content"),
        Button("Close", () => sheetDismiss(sheet)),
    ])
    sheet = sheetCreate(body, 320, 200)
    sheetPresent(sheet)
}

Platform Notes

DialogmacOSiOSWindowsLinuxWeb
File OpenNSOpenPanelUIDocumentPickerIFileOpenDialogGtkFileChooserDialog<input type="file">
File SaveNSSavePanelIFileSaveDialogGtkFileChooserDialogDownload link
FolderNSOpenPanelIFileOpenDialogGtkFileChooserDialog
AlertNSAlertUIAlertControllerMessageBoxWMessageDialogalert()
SheetNSSheetModal VCModal DialogModal WindowModal div

Complete Example: minimal text editor

A real program that wires openFileDialog and saveFileDialog into a state-bound TextField:

// demonstrates: file-open / save dialogs wired to a tiny text editor
// docs: docs/src/ui/dialogs.md
// platforms: macos, linux, windows

import {
    App,
    VStack, HStack,
    Text, Button, TextField,
    State,
    openFileDialog, saveFileDialog, alert,
} from "perry/ui"
import { readFileSync, writeFileSync } from "fs"

const content = State("")
const filePath = State("")

App({
    title: "Text Editor",
    width: 800,
    height: 600,
    body: VStack(12, [
        HStack(8, [
            Button("Open", () => {
                openFileDialog((path: string) => {
                    if (path.length === 0) return
                    filePath.set(path)
                    content.set(readFileSync(path, "utf-8") as string)
                })
            }),
            Button("Save As", () => {
                saveFileDialog((path: string) => {
                    if (path.length === 0) return
                    writeFileSync(path, content.value)
                    filePath.set(path)
                    alert("Saved", `File saved to ${path}`)
                }, "untitled", "txt")
            }),
        ]),
        Text(filePath.value === "" ? "No file open" : `File: ${filePath.value}`),
        TextField("Start typing...", (value: string) => content.set(value)),
    ]),
})

Next Steps

Table

The Table widget displays tabular data with columns, headers, and row selection.

Platform support: real implementation lives on macOS (NSTableView + NSScrollView); the Web target uses an HTML <table>. iOS, Android, Linux/GTK4, Windows, tvOS, visionOS, and watchOS link no-op stubs so cross-platform code compiles everywhere — the table renders nothing and tableGetSelectedRow returns -1. For production lists on platforms without a real impl, use LazyVStack (see Layout).

Creating a Table

const basicTable = Table(10, 3, (row: number, col: number) => {
    return Text(`Row ${row}, Col ${col}`)
})

Table(rowCount, colCount, renderCell) creates a table. The render callback receives (row, col) and must return a Widget (typically Text(...)). The runtime resolves the returned handle as the cell view, which lets cells render images, stacks, or composites — not just plain strings.

Column Headers

const userTable = Table(users.length, 3, (row: number, col: number) => {
    const user = users[row]
    if (col === 0) return Text(user.name)
    if (col === 1) return Text(user.email)
    return Text(user.role)
})

tableSetColumnHeader(userTable, 0, "Name")
tableSetColumnHeader(userTable, 1, "Email")
tableSetColumnHeader(userTable, 2, "Role")

Column Widths

tableSetColumnWidth(userTable, 0, 150)  // Name column
tableSetColumnWidth(userTable, 1, 250)  // Email column
tableSetColumnWidth(userTable, 2, 100)  // Role column

Row Selection

const selectedRow = State(-1)

tableSetOnRowSelect(userTable, (row: number) => {
    selectedRow.set(row)
    console.log(`Selected row: ${row}`)
})

// Read the currently selected row at any time:
const current = tableGetSelectedRow(userTable)

Dynamic Row Count

Update the number of rows after creation:

tableUpdateRowCount(userTable, users.length)

Complete Example

const selectedName = State("None")

const table = Table(users.length, 3, (row: number, col: number) => {
    const user = users[row]
    if (col === 0) return Text(user.name)
    if (col === 1) return Text(user.email)
    return Text(user.role)
})

tableSetColumnHeader(table, 0, "Name")
tableSetColumnHeader(table, 1, "Email")
tableSetColumnHeader(table, 2, "Role")
tableSetColumnWidth(table, 0, 150)
tableSetColumnWidth(table, 1, 250)
tableSetColumnWidth(table, 2, 100)

tableSetOnRowSelect(table, (row: number) => {
    selectedName.set(users[row].name)
})

App({
    title: "Table Demo",
    width: 600,
    height: 400,
    body: VStack(12, [
        table,
        Text(`Selected: ${selectedName.value}`),
    ]),
})

Sort, filter, multi-select (issue #473)

Since v0.5.636 the macOS Table exposes a column-sort callback, multi-row selection, and a passive filter-text slot the user wires to their own row-hiding logic.

import {
  Table,
  tableSetOnSortChange,
  tableSetAllowsMultipleSelection,
  tableGetSelectedRowsCount,
  tableGetSelectedRowAt,
  tableSetFilterText,
  tableGetFilterText,
} from "perry/ui";

const table = Table(rows.length, cols.length, renderCell);

tableSetAllowsMultipleSelection(table, 1);

tableSetOnSortChange(table, (col, ascending) => {
  // Re-sort your data array, then call tableReload(table)
  rows.sort((a, b) =>
    ascending ? a[col].localeCompare(b[col]) : b[col].localeCompare(a[col]),
  );
});

// Multi-select read-back
const n = tableGetSelectedRowsCount(table);
for (let i = 0; i < n; i++) {
  console.log("selected:", tableGetSelectedRowAt(table, i));
}

// Passive filter slot — your TS code reads it back and adjusts
// `tableUpdateRowCount(table, filteredRows.length)`.
tableSetFilterText(table, "alice");
console.log(tableGetFilterText(table));

These are real impls on macOS via NSTableView.sortDescriptors and selectedRowIndexes; other platforms link safe-default stubs.

Next Steps

Animation

Perry supports animating widget properties for smooth transitions. Every snippet below is excerpted from docs/examples/ui/animation/snippets.ts — CI compiles and runs it on every PR.

animateOpacity and animatePosition are special: they’re documented as methods on the widget handle (the only methods perry/ui exposes), and the HIR lowers them to widgetAnimateOpacity / widgetAnimatePosition calls under the hood.

Opacity Animation

const fading = Text("Fading text")
// Animate from the widget's current opacity to `target` over `durationSecs`.
fading.animateOpacity(1.0, 0.3) // target, durationSeconds

Position Animation

const moving = Button("Moving", () => {})
// Animate by a delta (dx, dy) relative to the widget's current position.
moving.animatePosition(100, 200, 0.5) // dx, dy, durationSeconds

Example: Fade-In Effect

When the first argument reads from a State.value, Perry auto-subscribes the call to the state — toggling visible re-runs the animation.

// demonstrates: auto-reactive animateOpacity driven by a State toggle
// docs: docs/src/ui/animation.md
// platforms: macos, linux, windows
// targets: ios-simulator, tvos-simulator, watchos-simulator, web, wasm

import { App, Text, Button, VStack, State } from "perry/ui"

const visible = State(false)

const label = Text("Hello!")
label.animateOpacity(visible.value ? 1.0 : 0.0, 0.3)

App({
    title: "Animation Demo",
    width: 400,
    height: 300,
    body: VStack(16, [
        Button("Toggle", () => {
            visible.set(!visible.value)
        }),
        label,
    ]),
})

Platform Notes

PlatformImplementation
macOSNSAnimationContext / ViewPropertyAnimator
iOSUIView.animate
AndroidViewPropertyAnimator
WindowsWM_TIMER-based animation
LinuxCSS transitions (GTK4)
WebCSS transitions

Next Steps

  • Styling — Widget styling properties
  • Widgets — All available widgets
  • Events — User interaction

Frame Callbacks (onFrame)

onFrame subscribes a callback to the next display-link “tick”. Use it for time-based rendering — animations driven from code, simulations, games, real-time data visualizations, or custom Canvas transitions — where you need a frame-aligned tick with an accurate timestamp instead of setInterval(cb, 16).

import { onFrame, cancelFrame } from "perry/ui";

function loop(timestampMs: number, deltaMs: number) {
  // advance simulation, redraw...
  onFrame(loop); // schedule the next frame
}

const id = onFrame(loop);
// later, to stop:
cancelFrame(id);

Semantics

  • One-shot. The callback fires once. To keep a loop running, call onFrame again from inside the callback (this mirrors the web’s idiomatic requestAnimationFrame shape and avoids the “how do I stop a recurring callback” footgun).
  • timestampMs is monotonic time since app start, in milliseconds, double precision.
  • deltaMs is the time since the previous fire of this callback (0 on the first call). Tracking is keyed off the callback identity so the idiomatic onFrame(loop) pattern gets accurate deltas without the app bookkeeping anything.
  • Order. Subscribers fire in registration order each frame.
  • Pause when invisible. The web backend uses requestAnimationFrame, which is paused automatically when the tab is hidden. The native backends drive frames from their main-loop pump; treat that as a soft guarantee for now and a real per-platform display-link driver is a follow-up.

Platform mapping

PlatformDriver
Web (WASM)requestAnimationFrame
macOSMain-thread pump (CADisplayLink wiring TBD)
iOS / tvOS / visionOSMain-thread pump (CADisplayLink wiring TBD)
AndroidMain-thread pump (Choreographer wiring TBD)
GTK4 (Linux)Main-loop pump (gtk_widget_add_tick_callback TBD)
WindowsWM_TIMER pump (DwmFlush vsync wiring TBD)

Multi-Window & Window Management

Perry supports creating multiple native windows and controlling their appearance and behavior. Every snippet below is excerpted from docs/examples/ui/multi_window/snippets.ts — CI compiles and runs it on every PR.

Creating Windows

Window(title, width, height) returns a window handle. Call .setBody() to set its content and .show() to display it:

const settings = Window("Settings", 500, 400)
settings.setBody(VStack(16, [
    Text("Settings panel"),
]))
settings.show()

Window Instance Methods

const win = Window("My Window", 600, 400)

win.setBody(Text("Hello"))   // Set the root widget
win.show()                    // Show the window
win.hide()                    // Hide without destroying
win.setSize(800, 600)         // Resize dynamically
win.onFocusLost(() => {       // Callback when the window loses focus
    win.hide()
})
win.close()                   // Close and destroy
MethodDescription
setBody(widget)Set the root widget of the window
show()Show the window
hide()Hide without destroying — call show() again to reveal
setSize(w, h)Resize dynamically
onFocusLost(cb)Register a callback that fires when focus leaves the window
close()Close and destroy

App Window Properties

The main App({}) config object accepts the same window properties for building launcher-style, overlay, or utility apps:

App({
    title: "QuickLaunch",
    width: 600,
    height: 80,
    body: VStack(8, [
        Text("Search..."),
        Button("Open Settings", () => settings.show()),
    ]),
})

App additionally accepts the optional fields frameless, level, transparent, vibrancy, activationPolicy, and icon. They map to the following native primitives:

frameless: true

Removes the window title bar and frame, creating a borderless window.

PlatformImplementation
macOSNSWindowStyleMask::Borderless + movable by background
WindowsWS_POPUP window style
Linuxset_decorated(false)

level: "floating" | "statusBar" | "modal" | "normal"

Controls the window’s z-order level relative to other windows.

LevelDescription
"normal"Default window level
"floating"Stays above normal windows
"statusBar"Stays above floating windows
"modal"Modal panel level
PlatformImplementation
macOSNSWindow.level (NSFloatingWindowLevel, etc.)
WindowsSetWindowPos with HWND_TOPMOST
Linuxset_modal(true) (best-effort)

transparent: true

Makes the window background transparent, allowing the desktop to show through non-opaque regions of your UI.

PlatformImplementation
macOSisOpaque = false, backgroundColor = .clear
WindowsWS_EX_LAYERED with SetLayeredWindowAttributes
LinuxCSS background-color: transparent

vibrancy: string

Applies a native translucent material to the window background. On macOS this uses the system vibrancy effect; on Windows it uses Mica/Acrylic.

macOS materials: "sidebar", "titlebar", "selection", "menu", "popover", "headerView", "sheet", "windowBackground", "hudWindow", "fullScreenUI", "tooltip", "contentBackground", "underWindowBackground", "underPageBackground"

PlatformImplementation
macOSNSVisualEffectView with the specified material
WindowsDwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE) — Mica, Acrylic, or Mica Alt depending on material (Windows 11 22H2+)
LinuxCSS alpha(@window_bg_color, 0.85) (best-effort)

activationPolicy: "regular" | "accessory" | "background"

Controls whether the app appears in the dock/taskbar.

PolicyDescription
"regular"Normal app with dock icon and menu bar (default)
"accessory"No dock icon, no menu bar activation — ideal for launchers and utilities
"background"Fully hidden from dock and app switcher
PlatformImplementation
macOSNSApp.setActivationPolicy()
WindowsWS_EX_TOOLWINDOW (removes from taskbar)
Linuxset_deletable(false) (best-effort)

Platform Notes

PlatformImplementation
macOSNSWindow
WindowsCreateWindowEx (HWND)
LinuxGtkWindow
WebFloating <div>
iOS/AndroidModal view controller / Dialog

On mobile platforms, “windows” are presented as modal views or dialogs since mobile apps typically use a single-window model.

Next Steps

Theming

The perry-styling package provides a design system bridge for Perry UI — design token codegen and ergonomic styling helpers with compile-time platform detection.

Installation

npm install perry-styling

Design Token Codegen

Generate typed theme files from a JSON token definition:

perry-styling generate --tokens tokens.json --out src/theme.ts

Token Format

{
  "colors": {
    "primary": "#007AFF",
    "primary-dark": "#0A84FF",
    "background": "#FFFFFF",
    "background-dark": "#1C1C1E",
    "text": "#000000",
    "text-dark": "#FFFFFF"
  },
  "spacing": {
    "sm": 4,
    "md": 8,
    "lg": 16,
    "xl": 24
  },
  "radius": {
    "sm": 4,
    "md": 8,
    "lg": 16
  },
  "fontSize": {
    "body": 14,
    "heading": 20,
    "caption": 12
  },
  "borderWidth": {
    "thin": 1,
    "medium": 2
  }
}

Colors with a -dark suffix are used as the dark mode variant. If no dark variant is provided, the light value is used for both modes. Supported color formats: hex (#RGB, #RRGGBB, #RRGGBBAA), rgb()/rgba(), hsl()/hsla(), and CSS named colors.

Generated Types

The codegen produces typed interfaces:

interface PerryColor {
  r: number; g: number; b: number; a: number; // floats in [0, 1]
}

interface PerryTheme {
  light: { [key: string]: PerryColor };
  dark: { [key: string]: PerryColor };
  spacing: { [key: string]: number };
  radius: { [key: string]: number };
  fontSize: { [key: string]: number };
  borderWidth: { [key: string]: number };
}

interface ResolvedTheme {
  colors: { [key: string]: PerryColor };
  spacing: { [key: string]: number };
  radius: { [key: string]: number };
  fontSize: { [key: string]: number };
  borderWidth: { [key: string]: number };
}

Theme Resolution

Resolve a theme at runtime based on the system’s dark mode setting:

import { getTheme } from "perry-styling";
import { theme } from "./theme"; // generated file

const resolved = getTheme(theme);
// resolved.colors.primary → the correct light/dark variant

getTheme() calls isDarkMode() from perry/system and returns the appropriate palette.

Styling Helpers

Ergonomic functions for applying styles to widget handles. Perry’s compiler doesn’t yet support passing PerryColor objects as parameters into user functions, so the helpers take flat primitives: extract the channels at the call site:

import {
  applyBg, applyRadius, applyTextColor, applyFontSize, applyGradient,
} from "perry-styling";

const t = resolved;                    // your ResolvedTheme
const c = t.colors.text;               // a PerryColor
const bg = t.colors.background;
const start = t.colors.primary;
const end = t.colors["primary-dark"];

const label = Text("Hello");
applyTextColor(label, c.r, c.g, c.b, c.a);
applyFontSize(label, t.fontSize.heading);

const card = VStack(16, []);
applyBg(card, bg.r, bg.g, bg.b, bg.a);
applyRadius(card, t.radius.md);
applyGradient(card,
  start.r, start.g, start.b, start.a,
  end.r,   end.g,   end.b,   end.a,
  0,                                   // 0 = vertical, 1 = horizontal
);

Available Helpers

FunctionSignature
applyBg(handle, r, g, b, a)Background color
applyRadius(handle, radius)Corner radius
applyTextColor(handle, r, g, b, a)Text color
applyFontSize(handle, size)Font size (regular weight)
applyFontBold(handle, size)Font size with bold weight
applyFontFamily(handle, family)Font family
applyWidth(handle, width)Fixed width
applyTooltip(handle, text)Tooltip (no-op on iOS/Android)
applyBorderColor(handle, r, g, b, a)Border color
applyBorderWidth(handle, width)Border width
applyEdgeInsets(handle, top, left, bottom, right)Edge insets (padding)
applyOpacity(handle, alpha)Opacity
applyGradient(handle, r1, g1, b1, a1, r2, g2, b2, a2, direction)Background gradient
applyButtonBg(btn, r, g, b, a)Button background
applyButtonTextColor(btn, r, g, b, a)Button text color
applyButtonBordered(btn, bordered)Bordered button style (true/false)

Platform Constants

perry-styling exports compile-time platform constants based on the __platform__ built-in:

import { isMac, isIOS, isAndroid, isWindows, isLinux, isDesktop, isMobile } from "perry-styling";

if (isMobile) {
  applyFontSize(label, 16);
} else {
  applyFontSize(label, 14);
}

These are constant-folded by LLVM at compile time — dead branches are eliminated with zero runtime cost.

Next Steps

Camera

The perry/ui module provides a live camera preview widget with color sampling capabilities.

import {
    CameraView,
    cameraStart, cameraStop,
    cameraFreeze, cameraUnfreeze,
    cameraSampleColor, cameraSetOnTap,
} from "perry/ui"

Platform support: real capture is implemented on iOS (AVCaptureSession) and Android (Camera2). On macOS, Linux (GTK4), Windows, and the Web target the runtime exports no-op stubs so cross-platform code compiles and links cleanly — CameraView() returns handle 0 and cameraSampleColor returns -1. Wiring real capture on those platforms (AVFoundation on macOS, GStreamer/V4L2 on Linux, Media Foundation on Windows, getUserMedia on Web) is tracked as a follow-up.

Quick Example

const colorHex = State("#000000")

const cam = CameraView()
cameraStart(cam)

cameraSetOnTap(cam, (x: number, y: number) => {
    const rgb = cameraSampleColor(x, y)
    if (rgb >= 0) {
        const r = Math.floor(rgb / 65536)
        const g = Math.floor((rgb % 65536) / 256)
        const b = Math.floor(rgb % 256)
        colorHex.set(`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`)
    }
})

App({
    title: "Color Picker",
    width: 400,
    height: 600,
    body: VStack(16, [
        cam,
        Text(`Color: ${colorHex.value}`),
    ]),
})

API Reference

CameraView()

Create a live camera preview widget.

const preview = CameraView()

Returns a widget handle. The camera does not start automatically — call cameraStart() to begin capture.

cameraStart(handle)

Start the live camera feed.

cameraStart(preview)

On iOS, the camera permission dialog is shown automatically on first use.

cameraStop(handle)

Stop the camera feed and release the capture session.

cameraStop(preview)

cameraFreeze(handle)

Pause the live preview (freeze the current frame).

cameraFreeze(preview)

The camera session remains active but the preview stops updating. Useful for “capture” moments where you want to inspect the frozen frame.

cameraUnfreeze(handle)

Resume the live preview after a freeze.

cameraUnfreeze(preview)

cameraSampleColor(x, y)

Sample the pixel color at normalized coordinates.

const rgb = cameraSampleColor(0.5, 0.5) // center of frame
  • x, y are normalized coordinates (0.0–1.0)
  • Returns packed RGB as a number: r * 65536 + g * 256 + b
  • Returns -1 if no frame is available

To extract individual channels:

const r = Math.floor(rgb / 65536)
const g = Math.floor((rgb % 65536) / 256)
const b = Math.floor(rgb % 256)

The color is averaged over a 5x5 pixel region around the sample point for noise reduction.

cameraSetOnTap(handle, callback)

Register a tap handler on the camera view.

cameraSetOnTap(preview, (tx: number, ty: number) => {
    // tx, ty are normalized coordinates (0.0-1.0)
    const tappedRgb = cameraSampleColor(tx, ty)
    console.log(`tapped color: ${tappedRgb}`)
})

The callback receives normalized coordinates of the tap location, which can be passed directly to cameraSampleColor().

Implementation

On iOS, the camera uses AVCaptureSession with AVCaptureVideoPreviewLayer for GPU-accelerated live preview, and AVCaptureVideoDataOutput for frame capture. Color sampling reads pixel data from CVPixelBuffer.

On Android, the camera uses Camera2 with a TextureView preview surface. Color sampling reads from the most recent ImageReader frame.

Next Steps

WebView

WebView embeds a real browser engine inside the native widget tree — WKWebView on Apple platforms, WebView2 on Windows, WebKitGTK 6.0 on Linux, android.webkit.WebView on Android, and a sandboxed <iframe> on the web target. Use it for OAuth / payment flows, embedded admin pages, help / docs viewers, or any “show this URL as part of my app” surface. (Tracked under issue #658.)

import {
    WebView,
    webviewLoadUrl,
    webviewReload,
    webviewGoBack,
    webviewGoForward,
    webviewCanGoBack,
    webviewEvaluateJs,
    webviewClearCookies,
} from "perry/ui"

Scope. This is a “browser tab embedded in your native widget tree” primitive — explicit non-goals: a Tauri / Electron-style native↔JS RPC bridge, custom protocol / scheme handlers, DevTools, file downloads, WebGL / camera / mic / clipboard permission negotiation, service workers, WebRTC. If you need any of those, reach for Tauri or Electron; the rest of perry/ui still applies.

Basic Usage

const wv = WebView({
    url: "https://example.com",
    width: 800,
    height: 600,
})

App({
    title: "WebView Demo",
    width: 820,
    height: 640,
    body: wv,
})

WebView({...}) returns a Widget you can drop into any layout container. The widget tree’s layout engine controls final size — width and height are hints for the initial bounds.

OAuth / Callback Interception

The load-bearing use case. onShouldNavigate is a synchronous intercept invoked before each navigation; return false to cancel the load. Every backend’s should-load hook is itself sync on the main thread (decidePolicyForNavigationAction, NavigationStarting, shouldOverrideUrlLoading, decide-policy), so the contract is the same everywhere.

const authCode = State("")

const auth = WebView({
    url: "https://accounts.google.com/o/oauth2/auth?client_id=...&redirect_uri=https://myapp.com/oauth/callback&response_type=code&scope=email",
    // Hard host-level allowlist — blocked at the native delegate
    // without round-tripping into TS. Exact match or subdomain match
    // (so "google.com" allows "accounts.google.com").
    allowedDomains: ["accounts.google.com", "google.com", "myapp.com"],
    onShouldNavigate: (url) => {
        if (url.startsWith("https://myapp.com/oauth/callback?")) {
            const code = new URL(url).searchParams.get("code") ?? ""
            authCode.set(code)
            return false  // cancel — we already have what we need
        }
        return true
    },
    onLoaded: (url) => {
        // Fires after every successful page load.
    },
    onError: (code, message) => {
        // DNS / TLS / HTTP / cancellation all flow here.
    },
})

The allowedDomains allowlist is enforced at the native delegate layer — disallowed hosts never reach your onShouldNavigate. Treat it as defense-in-depth against a hijacked OAuth page redirecting the embedded session somewhere unexpected.

Imperative Navigation

Drive the WebView from outside (toolbar buttons, deep links, app-state changes):

// Navigate the WebView from outside (e.g. from a toolbar button).
webviewLoadUrl(wv, "https://perryts.com")
webviewReload(wv)
webviewGoBack(wv)
webviewGoForward(wv)
const hasHistory = webviewCanGoBack(wv)  // 1 or 0

Reading Page State

webviewEvaluateJs(handle, js, callback) runs a one-shot JS expression in the WebView’s content process and delivers the stringified result. Use this for “after the redirect lands, read document.cookie / localStorage.getItem(...)” — not as a general native↔JS RPC channel.

// Read state out of the loaded page after `onLoaded` fires. The
// callback gets the stringified return value (empty string on null /
// undefined / error). Plain string returns are JSON-unwrapped for
// ergonomic `document.cookie` reads.
const reader = WebView({
    url: "https://example.com/auth/callback",
    onLoaded: (_url) => {
        webviewEvaluateJs(reader, "document.cookie", (cookies) => {
            // parseCookies(cookies)
        })
    },
})

The callback receives an empty string on null / undefined / error. Plain string returns are JSON-unwrapped (so document.cookie reads clean, without surrounding quotes).

ephemeral: true is the default — auth flows that silently reuse a user’s logged-in browser session are usually a footgun. Each backend maps this to its native equivalent at construction time:

PlatformEphemeralPersistent
macOS / iOS / visionOSWKWebsiteDataStore.nonPersistent()WKWebsiteDataStore.defaultDataStore()
Windowsper-handle temp userDataFolder under %TEMP%\PerryWebView\<pid>-<tag>%LOCALAPPDATA%\PerryWebView\persistent
Linux / GTK4WebKitNetworkSession::new_ephemeral()~/.local/share/perry-webview + ~/.cache/perry-webview (XDG-aware)
Androidbest-effort CookieManager.removeAllCookies(null) + WebStorage.deleteAllData() at createshared process-wide storage
Webiframe shares parent storage (no true isolation)same

To opt out:

// Opt out of ephemeral cookies so the user's session survives app
// restarts (like a regular browser profile).
const browser = WebView({
    url: "https://news.ycombinator.com",
    ephemeral: false,
    userAgent: "MyApp/1.0",
})

webviewClearCookies(handle) wipes the data store on demand — useful at logout, or between accounts:

// Wipe the WebView's cookies / localStorage / IndexedDB. Useful at
// logout, or between user accounts in a multi-tenant flow. No-op when
// `ephemeral: true` (the default), since there's nothing persisted to
// clear.
webviewClearCookies(wv)

API

FunctionDescription
WebView({ url, allowedDomains?, userAgent?, ephemeral?, onShouldNavigate?, onLoaded?, onError?, width?, height? })Construct the widget. Returns a Widget handle.
webviewLoadUrl(handle, url)Replace the current URL and re-paint.
webviewReload(handle)Reload the current page.
webviewGoBack(handle)Navigate back through session history.
webviewGoForward(handle)Navigate forward through session history.
webviewCanGoBack(handle)Returns 1 if there’s back history, 0 otherwise.
webviewEvaluateJs(handle, js, callback)Run JS in the content process; callback receives the stringified result.
webviewClearCookies(handle)Wipe cookies / localStorage / IndexedDB for this WebView’s data store.

Options

FieldTypeDefaultNotes
urlstringInitial URL. Required.
allowedDomainsstring[][]Hard host allowlist (exact OR subdomain). Empty / omitted = no host restriction.
userAgentstringplatform WebKit UACustom UA header.
ephemeralbooleantrueCookie / storage isolation. See the table above.
onShouldNavigate(url) => boolean | voidSync intercept. Return false to cancel.
onLoaded(url) => voidFires when a page finishes loading.
onError(code, message) => voidDNS / TLS / HTTP / cancellation.
width, heightnumberlayout-engine controlledInitial pixel bounds; layout engine still has final say.

Platform Notes

PlatformBackendNotes
macOSWKWebView (AppKit)Full callback parity. PerryWebViewDelegate (NSObject conforming to WKNavigationDelegate) carries the user closures + allowed-domains list.
iOS / visionOSWKWebView (UIKit)Same delegate pattern as macOS.
WindowsWebView2 via webview2-com (pinned to windows = "0.58")A STATIC host HWND becomes the widget handle; ICoreWebView2Controller binds to it. WebView2’s two-stage async init is wrapped synchronously by pumping the message queue with a 10s timeout — WebView({...}) blocks until the widget is live, so the first navigation isn’t racing init. WM_SIZE is subclassed on the host HWND and forwards bounds to SetBounds so the surface tracks layout-engine resizes. Requires the WebView2 runtime, which ships preinstalled on Windows 10+ and Windows Server 2019+.
Linux / GTK4WebKitGTK 6.0 via webkit6 = "=0.4"Real implementation. decide-policy::navigation-action is the sync intercept. Build dep: libwebkitgtk-6.0-dev (Ubuntu 22.10+ / Debian 12+).
Androidandroid.webkit.WebView via JNIPerryWebViewClient.kt (deployed alongside the runtime APK) bridges shouldOverrideUrlLoading / onPageFinished / onReceivedError back to native Rust. Full callback parity with the Apple / Windows / GTK4 backends. Ephemeral isolation is best-effort — Android WebView shares storage process-wide; CookieManager.removeAllCookies(null) + WebStorage.deleteAllData() runs at create when requested.
Websandboxed <iframe>sandbox="allow-scripts allow-same-origin allow-forms allow-popups". onShouldNavigate is best-effort (cross-origin URLs the iframe navigates to are unreachable from JS for security reasons); onLoaded fires from the iframe’s load event; onError from error (same-origin only). webviewEvaluateJs only works on same-origin frames. UA is browser-controlled. See “Cross-origin messaging” below.
tvOS / watchOSstubAll 14 FFIs link as no-ops returning 0. The widget is invisible; cross-platform code compiles unchanged.

Cross-Origin Messaging (Web Target)

On the web target, the embedded iframe can window.parent.postMessage out, and the host can window.addEventListener("message", ...) to receive. This is a browser-only pattern — native targets don’t expose postMessage (that’s the Tauri / Electron path Perry’s WebView deliberately avoids).

The portable contract that works on every target:

  • Push state in with webviewEvaluateJs(wv, "window.someHook(...)").
  • Pull state out by intercepting a known callback URL in onShouldNavigate.

Common Pitfalls

  • Don’t reuse one WebView for unrelated sessions. Cookie isolation is per-WebView, not per-call. If you need to log a different user in, call webviewClearCookies(handle) first or destroy and recreate the widget.
  • onShouldNavigate runs on the main thread. Keep it cheap — it blocks the navigation until you return. Heavy work belongs in onLoaded or off-thread via spawn.
  • WebView2 runtime requirement on older Windows. WebView2 is preinstalled on Windows 10 1803+ and Windows Server 2019+. On older builds the runtime needs to be installed separately (Microsoft ships an evergreen bootstrapper).
  • No bidirectional RPC. If you find yourself round-tripping structured data through webviewEvaluateJs callbacks, you’re past the design scope — pick Tauri / Electron instead, or move the logic out of the embedded page.

Next Steps

  • Widgets — All available widgets
  • State Management — React to onLoaded / onError from the rest of the UI
  • Multi-Window — Pop a fresh window with a WebView for isolated sessions

Terminal UI Overview

perry/tui is a native terminal-UI engine built into the Perry runtime. It targets the same use cases as ink (interactive CLIs, dashboards, REPLs, log viewers) but compiles to native code — no Node, no React reconciler, no fiber tree. Your code runs as a single static binary that does a double-buffered ANSI diff each frame.

When to use perry/tui

You want…Use
An interactive CLI tool (prompts, menus, live progress)perry/tui
A long-running terminal dashboard / log viewerperry/tui
A native desktop / mobile appperry/ui
A one-shot script that just prints to stdoutPlain console.log

perry/tui enters the terminal’s alternate screen buffer (so your scrollback isn’t polluted), captures raw-mode keypresses, and re-renders only the cells that changed between frames. The cell grid is a packed Vec<Cell> so an 80×24 terminal fits in ~15 KB — well within L2.

Quick Start

The smallest interactive perry/tui program — a counter that increments on +, decrements on -, and quits on q:

import { Box, Text, useState, useInput, run, exit } from "perry/tui";

run(() => {
    const [n, setN] = useState(0);

    useInput((s: string) => {
        if (s === "+") setN(n + 1);
        if (s === "-") setN(n - 1);
        if (s === "q") exit();
    });

    return Box([Text("count: " + n)]);
});

Compile and run:

perry compile app.ts -o app && ./app

The component closure is called every render. Hooks (useState/useInput/etc.) bind to a per-frame call-site index so the second render’s useState(0) at the same position reads back what the first render wrote — same model as React. The run loop re-renders when any state setter is called and idles between renders.

Mental Model

Perry’s TUI uses the same authoring model as ink:

  • Components are functions that return a widget tree. The function is called every render; the tree it returns is diffed against the previous frame’s tree and only changed terminal cells get rewritten.
  • State lives in hooks (useState, useRef, useMemo). A change triggers a re-render automatically.
  • Layout uses flexbox (powered by Taffy) — flexDirection: "row" | "column", gap, padding, justifyContent, alignItems, flexGrow, etc.

If you’ve used ink, the only real difference at the surface is the factory call formBox({…opts}, [children]) instead of <Box>…</Box> JSX. JSX works for user-defined component functions today (<App /> calls App(props)), but the <Box> / <Text> intrinsics still need the function-call form until a compile-time JSX→intrinsic rewriter lands.

Architecture in one paragraph

run(component) enters the alt screen, enables raw mode on stdin, spawns a reader thread, and loops: reset hook index → call the component closure → diff the returned widget tree against the front buffer → emit minimal ANSI to reconcile → drain pending keypresses (dispatching to useInput handlers and the focus ring) → if any state changed, immediately re-render; else idle 16 ms. Exit happens when exit() (or useApp().exit()) flips a flag the loop checks at the top of every iteration. On exit, raw mode is restored and the alt screen is left so your terminal returns to exactly the state it was in before the program ran.

What’s next

  • WidgetsBox, Text, Input, List, Select, Spinner, ProgressBar, Table, Tabs, and the per-widget style props.
  • HooksuseState, useEffect, useMemo, useRef, useApp, useStdout, useFocus, useInput.
  • Examples — counter, chat REPL, file picker, log viewer.

Widgets

perry/tui ships ~10 widgets that cover the typical interactive-CLI surface. All of them are factory functions returning a widget handle — pass them to Box as children, or to render(widget) / run(() => widget) as the root.

Box(opts?, children?)

A flexbox container. Holds any number of children laid out by direction, gap, padding, and alignment rules.

import { Box, Text } from "perry/tui";

// Bare children — vertical column by default.
Box([Text("first"), Text("second")]);

// With style.
Box({ flexDirection: "row", gap: 2, padding: 1 }, [
    Text("left"),
    Text("right"),
]);

Style props

PropTypeNotes
flexDirection"row" | "column"Default "column".
justifyContent"start" | "center" | "end" | "space-between" | "space-around"Main-axis distribution.
alignItems"start" | "center" | "end" | "stretch"Cross-axis alignment.
gapnumberCells of space between children.
paddingnumber | { top, right, bottom, left }Uniform or per-side.
widthnumber | stringCells, or "50%" of parent.
heightnumber | stringCells, or percent.
flexGrownumber1 = fill remaining space.
flexShrinknumber1 = shrink when overflowing. 0 = never shrink.
flexBasisnumber | stringBase size before grow/shrink.

Children can be a literal array ([Text("a"), Text("b")]) or any runtime expression that evaluates to an array — messages.map(m => Text(m)) works the same.

Text(content, style?)

A text node. Single-line; multi-line strings render with \n preserved.

Text("plain");
Text("bold!", { bold: true });
Text("error", { color: "red", bold: true });
Text("subtle", { dimColor: true, italic: true });
Text("removed", { strikethrough: true });
Text("selected", { inverse: true });
Text("custom", { color: "#ff8800", backgroundColor: "#222" });

Style props

PropTypeSGRNotes
color (alias fg)named color or #rrggbb30-37 / 38;2Foreground.
backgroundColor (alias bg)named color or #rrggbb40-47 / 48;2Background.
boldboolean1
dimColor (alias dim)boolean2
italicboolean3
underlineboolean4
inverse (alias reverse)boolean7Swaps fg/bg.
strikethroughboolean9

Named colors: black, red, green, yellow, blue, magenta, cyan, white, plus their bright* variants. Truecolor (#rrggbb) works on every modern terminal.

Spacer()

A zero-content widget with flexGrow: 1 baked in. Push siblings to the edges of a flex container without spelling out the grow factor:

Box({ flexDirection: "row" }, [
    Text("left"),
    Spacer(),
    Text("right"),
]);

Input(value, cursor?)

A single-line text-input widget. Render a string with an optional inline cursor position (0-indexed); pair with useState for the buffer and useInput to drive it.

const [buf, setBuf] = useState("");
const [cur, setCur] = useState(0);
useInput((s) => { /* … update buf + cur on keypress … */ });
return Input(buf, cur);

perry/tui doesn’t ship a full line editor — it gives you the rendering primitive and you wire the keys yourself. See the chat REPL in Examples for a typical input loop.

TextArea(value)

A multi-line text widget. Same shape as Input but accepts newlines.

List(items, selected?)

A vertically-laid list of strings, with optional highlighted-row index.

List(["Apple", "Banana", "Cherry"], 1);  // "Banana" highlighted

Select(items, selected?)

Like List but with selection indicators ( next to the focused row).

const [idx, setIdx] = useState(0);
useInput((s) => {
    if (s === "\x1b[A" /* up */ ) setIdx(Math.max(0, idx - 1));
    if (s === "\x1b[B" /* down */) setIdx(Math.min(items.length - 1, idx + 1));
});
return Select(items, idx);

Spinner(frame)

A static spinner character — - \ | / cycling through frames 0–3. Caller bumps frame from a state counter to animate.

const [tick, setTick] = useState(0);
// On every Enter (or however you want to advance):
setTick(tick + 1);
return Box([Spinner(tick), Text(" working…")]);

Spinner(0) is a static - — useful as a stable bullet if you don’t want animation.

For true wall-clock animation, see AnimatedSpinner({ interval, frames }) which runs its own internal tick (it advances when the render loop polls between frames).

ProgressBar(filled, total, width?)

A simple horizontal bar.

ProgressBar(7, 10);          // ████████░░ at default width
ProgressBar(50, 100, 40);    // 40-cell wide bar

Table({ headers, rows, selected? })

A bordered table. headers is a string array; rows is an array of string arrays.

Table({
    headers: ["Name", "Status", "Latency"],
    rows: [
        ["api-east", "OK", "12ms"],
        ["api-west", "DEGRADED", "412ms"],
    ],
    selected: 1,
});

Tabs({ tabs, active, body })

A horizontal tab bar over a body widget. body is an array parallel to tabs — only the active tab’s body is rendered.

const [active, setActive] = useState(0);
Tabs({
    tabs: ["Files", "Search", "Settings"],
    active,
    body: [filesView, searchView, settingsView],
});

For state + event hooks (the React-shape useState/useInput/useApp/etc.), see Hooks. For complete worked examples, see Examples.

Hooks

perry/tui implements the React-shape hook API on top of a call-site-indexed slot pool. Each useXxx call gets the slot at its position in the component body; the run loop resets the index at the top of every frame, so the second render’s useState at the same position reads back what the first wrote.

This is the same rule-of-hooks model ink/React use: call hooks in the same order on every render. Don’t call them inside if/loops — the slot index would skew and you’d read the wrong slot. Slot kinds are tagged (State / Effect / Memo / Ref / Focus); calling useState at a position previously used by useMemo re-tags the slot rather than corrupting it, but the value resets.

useState(initial)

Per-frame state cell. Returns [value, setter].

const [count, setCount] = useState(0);
// Later, from an input handler:
setCount(count + 1);

The setter writes through to the slot’s bits and flips a global STATE_DIRTY flag — the run loop sees it after useInput drains and immediately re-renders without sleeping.

Setting the same value twice (bit-identical) is a no-op — STATE_DIRTY stays clear and the loop idles. This avoids the “render storm” pattern where unconditional setX(prev) calls would loop forever.

Stale-closure gotcha

The setter captured by a useInput handler reads value from that frame’s closure, not from the slot. If many bytes arrive in one frame (paste, typing fast), the handler fires N times with the same value:

const [n, setN] = useState(0);
useInput((s) => { if (s === "+") setN(n + 1); });
// User pastes "+++" — handler fires 3× with n=0, all three set the slot to 1.

If you need a functional setter for this case, use useRef as a mirror:

const buf = useRef("");
const [, redraw] = useState(0);
useInput((s) => {
    if (s.length === 1 && s >= " " && s <= "~") {
        buf.set(buf.get() + s);     // canonical buffer (no stale capture)
        redraw(buf.get().length);   // trigger re-render
    }
});

useEffect(fn, deps?)

Run a side effect after first render, and again whenever a dep changes.

useEffect(() => {
    // Run-once on mount.
    fetchInitialData();
}, []);

useEffect(() => {
    // Re-run whenever `query` changes.
    runSearch(query);
}, [query]);

useEffect(() => {
    // No deps array → run every render. Rarely what you want.
});

Deps are compared by bit-identity using an FNV-1a hash of the deps’ NaN-boxed values. An empty array [] hashes to a stable non-zero value, giving the React “run once” behaviour; passing no array runs the effect every render.

The effect closure runs synchronously inside the component call. Cleanup-on-dep-change (returning a cleanup function) is not wired yet — the return value is ignored.

useMemo(fn, deps)

Cache the result of fn() keyed by deps. Same hash convention as useEffect.

const sorted = useMemo(
    () => items.slice().sort((a, b) => a.priority - b.priority),
    [items],
);

Recomputes on first call or when deps change. Otherwise returns the cached value.

useRef(initial)

A stable mutable cell that doesn’t trigger re-renders. Use for values you want to mutate but don’t want to drive the UI.

const renderCount = useRef(0);
renderCount.set(renderCount.get() + 1);   // does NOT flip STATE_DIRTY

.get() reads, .set(v) writes. Identity is stable across renders — calling useRef(0) at the same position returns the same handle every time, so closures captured in useEffect / useInput always see the latest value.

Common pattern: use useRef as the canonical buffer for input that gets typed at terminal speed, and pair with a throwaway useState to trigger redraws (see the stale-closure gotcha above).

useApp()

Returns a handle for imperative control of the run loop.

const app = useApp();
// Later:
app.exit();                    // tells run() to break at the top of the next iteration
await app.waitUntilExit();     // blocks until EXIT_FLAG is set (rare; usually `run` itself blocks)

The handle is stable — calling useApp() on every render returns the same singleton. Wrap it in useRef if you want to stash it for a callback that outlives the render.

useStdout()

Terminal dimensions and a raw-write escape hatch.

const stdout = useStdout();
const cols = stdout.columns();    // terminal width in cells (falls back to 80 if not a TTY)
const rows = stdout.rows();       // height in cells (fallback 24)
stdout.write("raw bytes\n");      // bypass the cell-grid diff

Use columns/rows to size dividers, truncate content to fit, or pick a layout direction. write is rarely needed — almost everything should go through widgets so the cell-grid diff can render it efficiently.

useFocus(autoFocus, isActive)

Register the calling widget as a focus candidate. Returns 1.0 when this widget is the currently focused one, else 0.0 (treat as truthy/falsy).

const isFocused = useFocus(1 /* autoFocus */, 1 /* isActive */);
return Box({ flexDirection: "row" }, [
    Text("> ", isFocused ? { color: "cyan", bold: true } : { dimColor: true }),
    Text("name input"),
]);
  • autoFocus: pass 1 for one widget to take focus on first render. Subsequent useFocus calls with autoFocus=1 are ignored once focus has been claimed.
  • isActive: pass 0 to remove this widget from the Tab cycle (e.g. a disabled field).

Tab and Shift-Tab cycle focus automatically — no boilerplate. The run loop’s input drain handles the \x09 / \x1b[Z byte sequences before forwarding them to your useInput handler.

For imperative focus control, pair with useFocusManager():

const focus = useFocusManager();
// Later:
focus.focusNext();
focus.focusPrevious();
focus.focus(id);   // by focus id (1-based, in registration order)

useInput(handler)

Register a keypress handler. Called once per byte chunk arriving on stdin, in raw mode.

useInput((s: string) => {
    if (s === "\x03") app.exit();               // Ctrl+C
    if (s === "\r" || s === "\n") onSubmit();   // Enter
    if (s === "\x7f" || s === "\b") onErase();  // Backspace
    if (s === "\x1b[A") onUpArrow();            // ANSI up
    if (s.length === 1 && s >= " " && s <= "~") onPrintable(s);
});

The s argument is the raw byte chunk as a string. ANSI escape sequences like arrow keys arrive as a single chunk (\x1b[A, \x1b[B, \x1b[C, \x1b[D); printable characters as one byte; control codes (Ctrl+C, Tab, Enter, Backspace) as their literal byte.

Tab handling: Tab (\x09) and Shift-Tab (\x1b[Z) cycle the focus ring before the handler is called. The handler still sees the byte, so you can branch on it if you want custom Tab behaviour — but for the typical “Tab moves focus” case the framework already did it.

Only one handler is registered at a time (last useInput call wins). For multiple focusable widgets, dispatch from one handler by checking useFocus’s return.

Equivalence with ink

inkperry/tuiNotes
useState(0)useState(0)Identical.
useEffect(fn, [])useEffect(fn, [])Cleanup return not yet wired.
useMemo(fn, [])useMemo(fn, [])Identical.
useRef(0)useRef(0).get()/.set(v) instead of .current.
useApp().exit()useApp().exit()Identical.
useStdout().columns (prop)useStdout().columns() (method)Function call, not property.
useFocus({ autoFocus })useFocus(1, 1)Positional args.
useInput(handler)useInput(handler)Same signature; raw byte chunks.
<App />run(() => App())JSX user components work (<App /> lowers to App(props)); built-in <Box> JSX is still deferred.

Examples

End-to-end perry/tui programs covering the typical interactive-CLI shapes. Each example also lives in test-files/test_perry_tui_inkcompat_*.ts in the repo and is exercised by CI on every PR.

Counter

The smallest meaningful program: + / - increment/decrement, q quits, the count renders to one row.

import { Box, Text, useState, useInput, run, exit } from "perry/tui";

// Captured so we can print the final count after run() returns.
let finalValue = 0;

run(() => {
    const [n, setN] = useState(0);
    finalValue = n;

    useInput((s: string) => {
        if (s === "+") setN(n + 1);
        if (s === "-") setN(n - 1);
        if (s === "q") exit();
    });

    return Box([Text("count: " + n)]);
});

console.log("FINAL=" + finalValue);

Pipe +++-q and the program prints FINAL=2. The useEffect-less useState(0) initialises the slot on first frame; the handler captures n from the frame it was registered in, so each setter call computes from the value that frame saw.

Chat REPL with stable input buffer

A Claude-Code-shaped chat UI: header row, message history, prompt with cursor, help footer. Demonstrates useState for the message list, useRef as a stale-closure-resistant input buffer, useInput for keypresses, useApp().exit() for Ctrl+C handling.

import {
    Box, Text, Spinner,
    useState, useEffect, useInput, useApp, useStdout, useRef,
    run,
} from "perry/tui";

const CANNED = [
    "Sure, I can help with that.",
    "Read the file, check for null, write a test.",
    "Got it. Anything else?",
];

run(() => {
    const app = useApp();
    const stdout = useStdout();
    const [messages, setMessages] = useState([] as string[]);
    const inputRef = useRef("");
    const [, redraw] = useState(0);
    const [tick, setTick] = useState(0);

    useEffect(() => {
        setMessages([
            "[bot] Hi! Type a message and press Enter. Ctrl+C quits.",
        ]);
    }, []);

    useInput((s: string) => {
        if (s === "\x03") { app.exit(); return; }
        if (s === "\r" || s === "\n") {
            const buf = inputRef.get();
            if (buf.length === 0) return;
            const reply = CANNED[messages.length % CANNED.length];
            setMessages(messages.concat(["[you] " + buf, "[bot] " + reply]));
            inputRef.set("");
            setTick(tick + 1);
            return;
        }
        if (s === "\x7f" || s === "\b") {
            const buf = inputRef.get();
            if (buf.length > 0) {
                inputRef.set(buf.substring(0, buf.length - 1));
                redraw(buf.length - 1);
            }
            return;
        }
        if (s.length === 1) {
            const c = s.charCodeAt(0);
            if (c >= 0x20 && c <= 0x7e) {
                inputRef.set(inputRef.get() + s);
                redraw(c);
            }
        }
    });

    const cols = stdout.columns();
    const rows = messages.map((m: string) => {
        const isUser = m.indexOf("[you]") === 0;
        return Text(m, { color: isUser ? "yellow" : "green" });
    });
    const history = Box({ flexDirection: "column", flexGrow: 1 }, rows);

    let bar = "";
    for (let i = 0; i < cols - 2; i = i + 1) bar = bar + "─";
    const divider = Text(bar, { dimColor: true });

    const promptRow = Box({ flexDirection: "row" }, [
        Spinner(tick),
        Text(" › " + inputRef.get(), { bold: true }),
        Text("█", { color: "cyan" }),
    ]);

    return Box({ flexDirection: "column", padding: 1 }, [
        Text("Perry-Code (demo)", { bold: true, color: "cyan" }),
        history,
        divider,
        promptRow,
        Text("Enter=send · Backspace=erase · Ctrl+C=quit", { dimColor: true }),
    ]);
});

The key insight is inputRef as the canonical buffer: when the user types fast (or pastes), many bytes arrive in one frame; the handler fires N times with the same stale input if it lived in useState. useRef.set() mutates the cell directly, so each byte builds on the previous; the throwaway redraw useState just flips STATE_DIRTY so the loop repaints.

Multi-step prompt with useFocus

A two-field form (name, email) with Tab/Shift-Tab navigation. Demonstrates useFocus with autoFocus + the automatic Tab cycling.

import { Box, Text, useState, useFocus, useInput, run, exit } from "perry/tui";

run(() => {
    const nameFocused = useFocus(1, 1);  // auto-focus first
    const emailFocused = useFocus(0, 1);

    const [name, setName] = useState("");
    const [email, setEmail] = useState("");

    useInput((s: string) => {
        if (s === "\x03") exit();
        // Tab/Shift-Tab handled by the runtime — we don't see them here
        // unless we want to (they DO get dispatched after focus cycles).
        if (s.length === 1 && s >= " " && s <= "~") {
            if (nameFocused) setName(name + s);
            else if (emailFocused) setEmail(email + s);
        }
    });

    return Box({ flexDirection: "column", padding: 1, gap: 1 }, [
        Box({ flexDirection: "row" }, [
            Text(nameFocused ? "▸ " : "  ", { color: "cyan" }),
            Text("Name:  " + name),
        ]),
        Box({ flexDirection: "row" }, [
            Text(emailFocused ? "▸ " : "  ", { color: "cyan" }),
            Text("Email: " + email),
        ]),
        Text("Tab to switch · Ctrl+C to quit", { dimColor: true }),
    ]);
});

Log viewer with useStdout

Sizes content to the terminal width using useStdout().columns(). Truncates each log line to fit; uses useEffect with [] to seed log data on mount.

import { Box, Text, useState, useEffect, useStdout, useInput, run, exit } from "perry/tui";

run(() => {
    const stdout = useStdout();
    const cols = stdout.columns();
    const [lines, setLines] = useState([] as string[]);

    useEffect(() => {
        setLines([
            "2026-05-11 09:01:23 INFO  api-east started",
            "2026-05-11 09:01:24 INFO  api-west started",
            "2026-05-11 09:01:25 WARN  api-east latency degraded (412ms)",
            "2026-05-11 09:01:26 ERROR api-east connection refused",
        ]);
    }, []);

    useInput((s: string) => {
        if (s === "q" || s === "\x03") exit();
    });

    const rows = lines.map((line: string) => {
        const truncated = line.length > cols - 2 ? line.substring(0, cols - 5) + "..." : line;
        const color = line.indexOf("ERROR") >= 0 ? "red"
                    : line.indexOf("WARN") >= 0 ? "yellow"
                    : "white";
        return Text(truncated, { color });
    });

    return Box({ flexDirection: "column", padding: 1 }, rows);
});

Notes on running these locally

perry compile myapp.ts -o myapp
./myapp

The binary enters the alt screen, takes over the terminal until you press Ctrl+C (or whatever exit key the program defines), and restores everything cleanly on exit. Your scrollback is untouched.

For piped (non-interactive) testing — useful for CI assertions — send your test input on stdin and grep stdout for the values your program prints after run() returns:

echo "+++-q" | ./myapp | grep "FINAL="

run() won’t process inputs faster than the loop’s 16 ms idle tick, so very fast piped input can deliver multiple bytes per frame. If your handler captures state via closure, design for that — either compute fresh state on every byte (with useRef) or accept that paste-style input behaves as a single bulk action.

Platform Overview

Perry compiles TypeScript to native executables for 9 platform families from the same source code.

Supported Platforms

PlatformTarget FlagUI ToolkitStatus
macOS(default)AppKitFull support (127/127 FFI functions)
iOS--target ios / --target ios-simulatorUIKitFull support (127/127)
visionOS--target visionos / --target visionos-simulatorUIKit (2D windows)Core support (2D only)
tvOS--target tvos / --target tvos-simulatorUIKitFull support (focus engine + game controllers)
watchOS--target watchos / --target watchos-simulatorSwiftUI (data-driven)Core support (15 widgets)
Android--target androidJNI/Android SDKFull support (112/112)
Windows--target windowsWin32Full support (112/112)
Linux--target linuxGTK4Full support (112/112)
Web / WebAssembly--target web (alias --target wasm)DOM/CSS via WASM bridgeFull support (168 widgets)

Cross-Compilation

# Default: compile for current platform
perry app.ts -o app

# Compile for a specific target
perry app.ts -o app --target ios-simulator
perry app.ts -o app --target visionos-simulator
perry app.ts -o app --target tvos-simulator
perry app.ts -o app --target watchos-simulator
perry app.ts -o app --target web   # alias: --target wasm
perry app.ts -o app --target windows
perry app.ts -o app --target linux
perry app.ts -o app --target android

Platform Detection

Use the __platform__ compile-time constant to branch by platform:

declare const __platform__: number

// Platform constants:
// 0 = macOS
// 1 = iOS
// 2 = Android
// 3 = Windows
// 4 = Linux
// 5 = Web (browser, --target web / --target wasm)
// 6 = tvOS
// 7 = watchOS
// 8 = visionOS

if (__platform__ === 0) {
    console.log("Running on macOS")
} else if (__platform__ === 1) {
    console.log("Running on iOS")
} else if (__platform__ === 3) {
    console.log("Running on Windows")
}

__platform__ is resolved at compile time. The compiler constant-folds comparisons and eliminates dead branches, so platform-specific code has zero runtime cost.

Platform Feature Matrix

FeaturemacOSiOSvisionOStvOSwatchOSAndroidWindowsLinuxWeb (WASM)
CLI programsYesYesYes
Native UI (DOM on web)YesYesYesYesYesYesYesYesYes
Game enginesYesYesYesYesYesYesVia FFI
File systemYesSandboxedSandboxedSandboxedSandboxedYesYesFile System Access API
NetworkingYesYesYesYesYesYesYesYesfetch / WebSocket
System APIsYesPartialPartialPartialMinimalPartialYesYesPartial
Widgets (WidgetKit)YesYes
ThreadingNativeNativeNativeNativeNativeNativeNativeNativeWeb Workers

Next Steps

macOS

macOS is Perry’s primary development platform. It uses AppKit for native UI.

Requirements

  • macOS 13+ (Ventura or later)
  • Xcode Command Line Tools: xcode-select --install

Building

# macOS is the default target
perry app.ts -o app
./app

No additional flags needed — macOS is the default compilation target.

UI Toolkit

Perry maps UI widgets to AppKit controls:

Perry WidgetAppKit Class
TextNSTextField (label mode)
ButtonNSButton
TextFieldNSTextField
SecureFieldNSSecureTextField
ToggleNSSwitch
SliderNSSlider
PickerNSPopUpButton
ImageNSImageView
VStack/HStackNSStackView
ScrollViewNSScrollView
TableNSTableView
CanvasNSView + Core Graphics

Code Signing

For distribution, apps need to be signed. Perry supports automatic signing:

perry publish

This auto-detects your signing identity from the macOS Keychain, exports it to a temporary .p12 file, and signs the binary.

For manual signing:

codesign --sign "Developer ID Application: Your Name" ./app

App Store Distribution

perry app.ts -o MyApp
# Sign with App Store certificate
codesign --sign "3rd Party Mac Developer Application: Your Name" MyApp
# Package
productbuild --sign "3rd Party Mac Developer Installer: Your Name" --component MyApp /Applications MyApp.pkg

macOS-Specific Features

  • Menu bar: Full NSMenu support with keyboard shortcuts
  • Toolbar: NSToolbar integration
  • Dock icon: Automatic for GUI apps
  • Dark mode: isDarkMode() detects system appearance
  • Keychain: Secure storage via Security.framework
  • Notifications: Local notifications via UNUserNotificationCenter
  • File dialogs: NSOpenPanel/NSSavePanel

System APIs

import { openURL, isDarkMode, preferencesSet, preferencesGet } from "perry/system"

openURL("https://example.com")          // Opens in default browser
const dark = isDarkMode()               // Check appearance
preferencesSet("key", "value")          // NSUserDefaults
const val = preferencesGet("key")       // NSUserDefaults

Next Steps

iOS

Perry can cross-compile TypeScript apps for iOS devices and the iOS Simulator.

Requirements

  • macOS host (cross-compilation from Linux/Windows is not supported)
  • Xcode (full install, not just Command Line Tools) for iOS SDK and Simulator
  • Rust iOS targets:
    rustup target add aarch64-apple-ios aarch64-apple-ios-sim
    

Building for Simulator

perry app.ts -o app --target ios-simulator

This uses LLVM cross-compilation with the iOS Simulator SDK. The binary can be run in the Xcode Simulator.

Building for Device

perry app.ts -o app --target ios

This produces an ARM64 binary for physical iOS devices. You’ll need to code sign and package it in an .app bundle for deployment.

Running with perry run

The easiest way to build and run on iOS is perry run:

perry run ios              # Auto-detect device/simulator
perry run ios --console    # Stream live stdout/stderr
perry run ios --remote     # Use Perry Hub build server

Perry auto-discovers available simulators (via simctl) and physical devices (via devicectl). When multiple targets are found, an interactive prompt lets you choose.

For physical devices, Perry handles code signing automatically — it reads your signing identity and team ID from ~/.perry/config.toml (set up via perry setup ios), embeds the provisioning profile, and signs the .app before installing.

If you don’t have the iOS cross-compilation toolchain installed locally, perry run ios automatically falls back to Perry Hub’s remote build server.

UI Toolkit

Perry maps UI widgets to UIKit controls:

Perry WidgetUIKit Class
TextUILabel
ButtonUIButton (TouchUpInside)
TextFieldUITextField
SecureFieldUITextField (secureTextEntry)
ToggleUISwitch
SliderUISlider (Float32, cast at boundary)
PickerUIPickerView
ImageUIImageView
VStack/HStackUIStackView
ScrollViewUIScrollView

App Lifecycle

iOS apps use UIApplicationMain with a deferred creation pattern:

import { App, Text, VStack } from "perry/ui"

App({
    title: "My iOS App",
    width: 400,
    height: 800,
    body: VStack(16, [
        Text("Hello, iPhone!"),
    ]),
})

The App() call triggers UIApplicationMain, and your render function is called via PerryAppDelegate once the app is ready.

iOS Widgets (WidgetKit)

Perry can compile TypeScript widget declarations to native SwiftUI WidgetKit extensions:

perry widget.ts --target ios-widget

See Widgets (WidgetKit) for details.

Splash Screen

Perry auto-generates a native LaunchScreen.storyboard from the perry.splash config in package.json. The splash screen appears instantly during cold start.

{
  "perry": {
    "splash": {
      "image": "logo/icon-256.png",
      "background": "#FFF5EE"
    }
  }
}

The image is centered at 128x128pt with scaleAspectFit. You can provide a custom storyboard for full control:

{
  "perry": {
    "splash": {
      "ios": { "storyboard": "splash/LaunchScreen.storyboard" }
    }
  }
}

See Project Configuration for the full config reference.

Resource Bundling

Perry automatically bundles logo/ and assets/ directories from your project root into the .app bundle. These resources are available at runtime via standard file APIs relative to the app bundle path.

Keyboard Avoidance

Perry apps automatically handle keyboard avoidance on iOS. When the keyboard appears, the root view adjusts its bottom constraint with an animated layout transition, and focused TextFields are auto-scrolled into view above the keyboard.

Differences from macOS

  • No menu bar: iOS doesn’t support menu bars. Use toolbar or navigation patterns.
  • Touch events: onHover is not available. Use onClick (mapped to touch).
  • Slider precision: iOS UISlider uses Float32 internally (automatically converted).
  • File dialogs: Limited to UIDocumentPicker.
  • Keyboard shortcuts: Not applicable on iOS.

Next Steps

visionOS

Perry can compile TypeScript apps for Apple Vision Pro devices and the visionOS Simulator.

This first pass targets 2D windowed apps only. Perry uses the same UIKit-style perry/ui model as iOS, packaged for visionOS app bundles and scene lifecycle.

Prerequisites

  • macOS with Xcode installed
  • Rust visionOS targets:
rustup target add aarch64-apple-visionos aarch64-apple-visionos-sim

Compile

perry compile app.ts -o app --target visionos-simulator
perry compile app.ts -o app --target visionos

This produces a .app bundle with visionOS-specific Info.plist metadata and a UIWindowScene configuration.

Run

perry run visionos
perry run visionos --simulator <UDID>
perry run visionos --device <UDID>

Perry auto-detects booted Apple Vision Pro simulators via simctl. Physical device installs use devicectl, like other modern Apple platforms.

Configuration

Configure visionOS-specific settings in perry.toml:

[visionos]
bundle_id = "com.example.myvisionapp"
deployment_target = "1.0"
entry = "src/main_visionos.ts"
encryption_exempt = true

Custom Info.plist keys can be merged through [visionos.info_plist].

Platform Detection

Use __platform__ === 8 to detect visionOS at compile time:

function reportVisionos(): void {
    if (__platform__ === 8) {
        console.log("Running on visionOS")
    }
}

Current Scope

  • Supported: 2D windowed apps, simulator/device app bundles, perry run, perry setup, perry publish
  • Not supported yet: immersive spaces, volumes, RealityKit scene generation, Geisterhand

tvOS

Perry can compile TypeScript apps for Apple TV devices and the tvOS Simulator.

tvOS uses UIKit (the same framework as iOS), so Perry’s tvOS support shares the same UIKit-based widget system. The primary difference is input: Apple TV apps are controlled via the Siri Remote and game controllers rather than touch, and all apps run full-screen.

Requirements

  • macOS host (cross-compilation from Linux/Windows is not supported)
  • Xcode (full install) for tvOS SDK and Simulator
  • Rust tvOS targets:
    rustup target add aarch64-apple-tvos aarch64-apple-tvos-sim
    

Building for Simulator

perry compile app.ts -o app --target tvos-simulator

This produces an ARM64 binary linked with clang against the tvOS Simulator SDK, wrapped in a .app bundle.

Building for Device

perry compile app.ts -o app --target tvos

This produces an ARM64 binary for physical Apple TV hardware.

Running with perry run

perry run tvos                        # Auto-detect booted Apple TV simulator
perry run tvos --simulator <UDID>     # Target a specific simulator

Perry auto-discovers booted Apple TV simulators. To install and launch manually:

xcrun simctl install booted app.app
xcrun simctl launch booted com.perry.app

UI Toolkit

Perry maps UI widgets to UIKit controls on tvOS, identical to iOS:

Perry WidgetUIKit ClassNotes
TextUILabel
ButtonUIButtonFocus-based navigation
TextFieldUITextFieldOn-screen keyboard via Siri Remote
ToggleUISwitch
SliderUISlider
PickerUIPickerView
ImageUIImageView
VStack/HStackUIStackView
ScrollViewUIScrollViewFocus-based scrolling

Focus Engine

tvOS uses a focus-based navigation model instead of direct touch. The Siri Remote’s touchpad and directional buttons move focus between focusable views. Perry widgets that support interaction (buttons, text fields, toggles, etc.) are automatically focusable.

Game Engine Support

tvOS is particularly well-suited for game engines. When using a native library like Bloom, the game engine handles its own windowing, rendering, and input.

Status: illustrative only — Bloom is an external native library (see bloomengine.dev). The snippet below is left as ,no-test because it depends on Bloom’s .a/.so being available at link time; the doc-tests harness compiles every other snippet on this page.

import { initWindow, windowShouldClose, beginDrawing, endDrawing,
         clearBackground, isGamepadButtonDown, Colors } from "bloom";

initWindow(1920, 1080, "My Apple TV Game");

while (!windowShouldClose()) {
  beginDrawing();
  clearBackground(Colors.BLACK);

  if (isGamepadButtonDown(0)) {
    // A button (Siri Remote select) pressed
  }

  endDrawing();
}

Input on tvOS

The Siri Remote acts as a game controller:

InputMapping
Touchpad swipeGamepad axes 0/1 (left stick)
Touchpad click (Select)Gamepad button 0 (A) + mouse button 0
Menu buttonGamepad button 1 (B)
Play/Pause buttonGamepad button 9 (Start)
Arrow presses (up/down/left/right)Gamepad D-pad buttons (12-15)

Extended game controllers (MFi, PlayStation, Xbox) are fully supported with all axes, buttons, triggers, and D-pad mapped through the standard gamepad API.

App Lifecycle

tvOS apps use UIApplicationMain with the same lifecycle as iOS. When using perry/ui:

import { App, Text, VStack } from "perry/ui"

App({
    title: "My TV App",
    width: 1920,
    height: 1080,
    body: VStack(16, [
        Text("Hello, Apple TV!"),
    ]),
})

When using a game engine with --features ios-game-loop, the runtime starts UIApplicationMain on the main thread and runs your game code on a dedicated game thread.

Configuration

Configure tvOS settings in perry.toml:

[tvos]
bundle_id = "com.example.mytvapp"
deployment_target = "17.0"

Platform Detection

Use __platform__ === 6 to detect tvOS at compile time:

function reportTvos(): void {
    if (__platform__ === 6) {
        console.log("Running on tvOS")
    }
}

App Bundle

Perry generates a .app bundle with an Info.plist containing:

KeyValueNotes
UIDeviceFamily[3]Apple TV
MinimumOSVersion17.0tvOS 17+
UIRequiresFullScreentrueAll tvOS apps are full-screen
UILaunchStoryboardNameLaunchScreenRequired by tvOS

Limitations

tvOS has inherent platform constraints compared to other Perry targets:

  • No camera: Apple TV has no camera hardware
  • No clipboard: UIPasteboard is not available on tvOS
  • No file dialogs: No document picker
  • No QR code: No camera for scanning
  • No multi-window: Single full-screen window only
  • No direct touch: Input is via Siri Remote focus engine and game controllers
  • Resolution: Design for 1920x1080 (1080p) or 3840x2160 (4K) displays

Differences from iOS

AspecttvOSiOS
InputSiri Remote + game controllers (focus engine)Direct touch
DisplayFull-screen only (1080p/4K)Variable screen sizes
Device family[3] (Apple TV)[1, 2] (iPhone/iPad)
CameraNot availableAvailable
ClipboardNot availableAvailable
Deployment target17.017.0
UI frameworkUIKit (same as iOS)UIKit

Next Steps

watchOS

Perry can compile TypeScript apps for Apple Watch devices and the watchOS Simulator.

Since watchOS does not support UIKit views, Perry uses a data-driven SwiftUI renderer: your TypeScript code builds a UI tree via the standard perry/ui API, and a fixed SwiftUI runtime (shipped with Perry) queries the tree and renders it reactively. No code generation or transpilation is involved — the binary is fully native.

Requirements

  • macOS host (cross-compilation from Linux/Windows is not supported)
  • Xcode (full install) for watchOS SDK and Simulator
  • Rust watchOS targets:
    rustup target add arm64_32-apple-watchos aarch64-apple-watchos-sim
    

Building for Simulator

perry compile app.ts -o app --target watchos-simulator

This produces an ARM64 binary linked with swiftc against the watchOS Simulator SDK, wrapped in a .app bundle.

Building for Device

perry compile app.ts -o app --target watchos

This produces an arm64_32 (ILP32) binary for physical Apple Watch hardware. Apple Watch uses 32-bit pointers on 64-bit ARM.

Running with perry run

perry run watchos                # Auto-detect booted watch simulator
perry run watchos --simulator <UDID>  # Target a specific simulator

Perry auto-discovers booted Apple Watch simulators. To install and launch manually:

xcrun simctl install booted app_watchos/app.app
xcrun simctl launch booted com.perry.app

UI Toolkit

Perry maps UI widgets to SwiftUI views via a data-driven bridge:

Perry WidgetSwiftUI ViewNotes
TextTextFont size, weight, color, wrapping
ButtonButtonTap action via native closure callback
VStackVStackWith spacing
HStackHStackWith spacing
ZStackZStackLayered views
SpacerSpacer
DividerDivider
ToggleToggleTwo-way state binding
SliderSliderMin/max/value, state binding
ImageImage(systemName:)SF Symbols
ScrollViewScrollView
ProgressViewProgressViewLinear
PickerPickerSelection list
FormListMaps to List on watchOS
NavigationStackNavigationStackPush navigation

Modifiers

All widgets support these styling modifiers:

  • foregroundColor / backgroundColor
  • font (size, weight, family)
  • frame (width, height)
  • padding (uniform or per-edge)
  • cornerRadius
  • opacity
  • hidden / disabled

App Lifecycle

watchOS apps use SwiftUI’s @main App pattern. Perry’s PerryWatchApp.swift runtime handles the app lifecycle automatically:

import { App, Text, VStack, Button } from "perry/ui"

App({
    title: "My Watch App",
    width: 200,
    height: 200,
    body: VStack(8, [
        Text("Hello, Apple Watch!"),
        Button("Tap me", () => {
            console.log("Button tapped!")
        }),
    ]),
})

Under the hood:

  1. perry_main_init() runs your compiled TypeScript, which builds the UI tree in memory
  2. The SwiftUI @main struct observes the tree version and renders it
  3. User interactions (button taps, toggle changes) call back into native closures

State Management

Reactive state works the same as other platforms:

import { App, Text, VStack, Button, State } from "perry/ui"

const count = State(0)

App({
    title: "Counter",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text(`Count: ${count.value}`),
        Button("Increment", () => count.set(count.value + 1)),
    ]),
})

When state.set() is called, the tree version increments and SwiftUI re-renders the affected views automatically.

How It Works

Unlike iOS (UIKit) and macOS (AppKit), where Perry calls native view APIs directly via FFI, watchOS uses a data-driven architecture:

TypeScript code
  |
  v
perry_ui_*() FFI calls  →  Node tree stored in memory (Rust)
                                      |
                                      v
                        PerryWatchApp.swift queries tree via FFI
                                      |
                                      v
                        SwiftUI renders views reactively
                                      |
                                      v
                        User interaction → FFI callback → native closure

The PerryWatchApp.swift file is a fixed runtime (~280 lines) that ships with Perry. It never changes per-app — it’s the watchOS equivalent of libperry_ui_ios.a.

Configuration

Configure watchOS settings in perry.toml:

[watchos]
bundle_id = "com.example.mywatch"
deployment_target = "10.0"

[watchos.info_plist]
NSLocationWhenInUseUsageDescription = "Used for location features"

Set up signing credentials with:

perry setup watchos

This shares App Store Connect credentials with iOS/macOS (same team, API key, issuer).

Platform Detection

Use __platform__ === 7 to detect watchOS at compile time:

function reportWatchos(): void {
    if (__platform__ === 7) {
        console.log("Running on watchOS")
    }
}

watchOS Widgets (WidgetKit)

Perry also supports watchOS WidgetKit complications (separate from full apps):

perry compile widget.ts --target watchos-widget --app-bundle-id com.example.app

See watchOS Complications for widget-specific documentation.

Limitations

watchOS apps have inherent platform constraints compared to other Perry targets:

  • No Canvas: CoreGraphics drawing is not available
  • No Camera: watchOS does not support camera APIs
  • No TextField: Text input is extremely limited on Apple Watch
  • No File Dialogs: No document picker
  • No Menu Bar / Toolbar: Not applicable on watch
  • No Multi-Window: Single window only
  • No QR Code: Screen too small for practical QR display
  • Memory: watchOS devices have ~50-75MB available RAM — keep apps lightweight
  • Screen size: Design for 40-49mm watch faces

Differences from iOS

  • SwiftUI vs UIKit: watchOS uses SwiftUI rendering; iOS uses UIKit directly
  • No splash screen: watchOS apps don’t use launch storyboards
  • Standalone: watchOS apps are standalone (no iPhone companion required, WKWatchOnly = true)
  • Device family: UIDeviceFamily = [4] (watch) vs [1, 2] (iPhone/iPad)

Next Steps

Android

Perry compiles TypeScript apps for Android using JNI (Java Native Interface).

Requirements

  • Android NDK
  • Android SDK
  • Rust Android targets:
    rustup target add aarch64-linux-android armv7-linux-androideabi
    

Building

perry app.ts -o app --target android

UI Toolkit

Perry maps UI widgets to Android views via JNI:

Perry WidgetAndroid Class
TextTextView
ButtonButton
TextFieldEditText
SecureFieldEditText (ES_PASSWORD)
ToggleSwitch
SliderSeekBar
PickerSpinner + ArrayAdapter
ImageImageView
VStackLinearLayout (vertical)
HStackLinearLayout (horizontal)
ZStackFrameLayout
ScrollViewScrollView
CanvasCanvas + Bitmap
NavigationStackFrameLayout

Android-Specific APIs

  • Dark mode: Configuration.uiMode detection
  • Preferences: SharedPreferences
  • Keychain: Android Keystore
  • Notifications: NotificationManager
  • Open URL: Intent.ACTION_VIEW
  • Alerts: PerryBridge.showAlert
  • Sheets: Dialog (modal)

Splash Screen

Perry’s Android template includes a splash theme (Theme.Perry.Splash) that displays a windowBackground drawable during cold start. Configure it via perry.splash in package.json:

{
  "perry": {
    "splash": {
      "image": "logo/icon-256.png",
      "background": "#FFF5EE"
    }
  }
}

The image is centered via a layer-list drawable with a solid background color. The activity switches to the normal theme in onCreate before inflating the layout, so the splash disappears as soon as the app is ready.

For full control, provide custom drawable and theme XML files:

{
  "perry": {
    "splash": {
      "android": {
        "layout": "splash/splash_background.xml",
        "theme": "splash/themes.xml"
      }
    }
  }
}

See Project Configuration for the full config reference.

Differences from Desktop

  • Touch-only: No hover events, no right-click context menus
  • Single window: Multi-window maps to Dialog views
  • Toolbar: Horizontal LinearLayout
  • Font: Typeface-based font family support

Next Steps

HarmonyOS NEXT

Perry compiles TypeScript apps for HarmonyOS NEXT (Huawei’s mobile OS) by emitting declarative ArkUI alongside a logic-only .so library. The same TypeScript source that targets macOS, iOS, Android, Linux, and Windows also runs natively on HarmonyOS — no platform-specific adapters needed in user code.

Architecture

HarmonyOS NEXT runs apps via the ArkTS runtime, which owns the UI tree. Perry can’t lower perry/ui calls to the imperative AppKit/UIKit/etc shape used on every other platform — it has to play by ArkTS’s declarative rules. So the harmonyos target is structured differently:

TypeScript (.ts)
   ↓
HIR (perry-hir)
   ↓
perry-codegen-arkts (harvest pass)
   ├── walks App({body: ...}) call
   ├── extracts widget tree → emits pages/Index.ets (real ArkUI source)
   ├── captures closure args → registers slot ids
   ├── strips the App call from the HIR
   └── injects perry_arkts_register_callback() per closure
   ↓
perry-codegen (LLVM)
   ↓
libentry.so (no UI calls — just logic + NAPI bridge)

The user splices three artifacts into a DevEco Studio project — libentry.so, pages/Index.ets, cpp/types/libentry/Index.d.ts — and DevEco signs + runs as usual. Tap interactions, text input, etc. fire NAPI calls into the .so, which dispatch the registered Perry closure bodies.

What’s supported

Widgets (introduced in v0.5.401, expanded in v0.5.418, v0.5.429):

WidgetArkUI emission
Text(content) / Text(content, "id")Text(...).fontSize(20) (reactive when id is given)
VStack(children) / VStack(spacing, children)Column({ space })
HStack(children) / HStack(spacing, children)Row({ space })
Button(label, onPress)Button(...).onClick(...)
TextField(placeholder, onChange)TextInput(...).onChange(...)
Toggle(label, onChange)Toggle({type: ToggleType.Switch}).onChange(...)
Slider(min, max, onChange)Slider({...}).onChange(...)
Spacer()Blank()
Divider()Divider()
Image(src) / ImageFile(path)Image(...)
ScrollView(children)Scroll() { Column() { ... } }
LazyVStack(children)Column({...}) (eager — see v10 follow-up)
Picker(options, onChange)TextPicker({...}).onChange(...)
ProgressView(value, total)Progress({type: ProgressType.Linear})
Section(title, children)Column({ space: 4 }) { Text(title) ... }

Event handling (v0.5.417 + v0.5.421):

  • Button.onPressinvokeCallback(idx) via NAPI
  • Toggle.onChange((isOn: boolean) => ...)invokeCallback1(idx, isOn)
  • TextField.onChange((value: string) => ...)invokeCallback1(idx, value)
  • Slider.onChange((value: number) => ...)invokeCallback1(idx, value)
  • Picker.onChange((idx: number) => ...)invokeCallback1(idx, index)

Reactivity (v0.5.419 + v0.5.421):

  • Text("0", "counter") registers a reactive slot bound to a generated @State text_counter: string field.
  • setText("counter", "5") from inside any closure updates the Text on-screen.

Toast banners (v0.5.419):

  • showToast("Saved!") from inside any closure shows an ArkUI promptAction.showToast({ message }) banner.

Inline styling (v0.5.429):

  • Text("hi", { fontSize: 16, color: "red" }) maps to .fontSize(16).fontColor('red').
  • Supported props: backgroundColor, color, fontSize, fontWeight, fontFamily, borderRadius, padding (number or per-side object), opacity, hidden, borderColor + borderWidth (combined as .border({...})).
  • PerryColor objects ({r,g,b,a}) auto-convert to rgba(...) strings.

Dynamic lists (v0.5.429):

  • VStack(items.map(item => Text(item))) lowers to ArkUI ForEach(items, (__item) => { Text(__item) }, (__item) => __item).
  • Single-arg map closures only; complex array sources require Phase 2 v6 state binding.

Setup

  1. Install DevEco Studio + the OpenHarmony SDK from Huawei. Verified working with DevEco Studio 6.0.2 + OpenHarmony 5.0+.

  2. Run the setup wizard once (introduced in v0.5.380):

    perry setup harmonyos
    

    The wizard auto-discovers your DevEco-generated debug certificates from ~/.ohos/config/, prompts for the keystore password, and persists the configuration to ~/.perry/config.toml. Subsequent perry compile --target harmonyos invocations sign HAPs automatically.

  3. Optional: install hdc (HarmonyOS Device Connector) for emulator interaction. It ships inside DevEco at Contents/sdk/default/openharmony/toolchains/hdc.

Compile + run workflow

Write a TypeScript program with App({body: ...}):

// hi.ts
import { App, VStack, Text, Button, showToast } from "perry/ui";

let count = 0;

App({
  title: "Perry on HarmonyOS",
  body: VStack([
    Text("Count: 0", "counter"),
    Button("+", () => {
      count++;
      setText("counter", `Count: ${count}`);
    }),
    Button("Notify", () => {
      showToast(`Counter is ${count}`);
    }),
  ]),
});

Compile for HarmonyOS:

perry compile hi.ts --target harmonyos -o /tmp/libentry.so

This produces three artifacts in /tmp/:

  • libentry.so — the compiled .so (8-9 MB typically)
  • ets/pages/Index.ets — the auto-emitted ArkUI page
  • cpp/types/libentry/Index.d.ts — the NAPI declaration file

Splice into a DevEco Studio project:

cp /tmp/libentry.so       ~/DevEcoStudioProjects/MyApp/entry/libs/arm64-v8a/libentry.so
cp /tmp/ets/pages/Index.ets   ~/DevEcoStudioProjects/MyApp/entry/src/main/ets/pages/Index.ets
cp /tmp/cpp/types/libentry/Index.d.ts   ~/DevEcoStudioProjects/MyApp/entry/src/main/cpp/types/libentry/Index.d.ts

Click ▶ Run in DevEco — DevEco’s hvigor signs + bundles the HAP and installs onto the emulator (or attached device). The app launches, taps fire your TS closures, and the screen updates reactively.

Architecture deep dive

The harvest model

perry-codegen-arkts::emit_index_ets walks module.init looking for the first App({body: <expr>}) call from perry/ui. It extracts the body field, recursively emits ArkUI source for each widget in the tree, and destructively replaces the App call with Stmt::Expr(Expr::Number(0.0)) so the LLVM backend never sees perry_ui_* FFI calls (which would be unresolved on the OHOS target — there’s no perry-ui-harmonyos crate by design).

The emitted Index.ets is a real ArkUI @Entry @Component struct Index { build() { ... } } page with @State declarations for any reactive Text widgets, import promptAction from '@ohos.promptAction' for toast routing, and per-Button onClick handlers that invoke NAPI callbacks then drain queued toasts and text updates.

Closures across the NAPI boundary

Each Button/Toggle/etc onClick closure registers via perry_arkts_register_callback(idx, closure_handle) during main() startup. The closure_handle is a NaN-boxed pointer to a real Perry *ClosureHeader. A GC root scanner registered in gc_init keeps registered closures alive across collections.

When ArkUI fires an onClick, the auto-emitted .onClick(() => perryEntry.invokeCallback(0)) calls back into the .so via NAPI. The invoke_callback NAPI handler in crates/perry-runtime/src/ohos_napi.rs reads the int32 idx, looks up the slot, and dispatches via js_closure_call0. Multi-arg variants (Toggle/TextField/Slider) use invokeCallback1(idx, value) with napi_typeof dispatch to NaN-box the value (boolean / string / number) before calling js_closure_call1.

The drain queue pattern

showToast and setText calls inside a closure body push entries onto thread-local queues:

  • PENDING_TOASTS: Mutex<VecDeque<String>>
  • PENDING_TEXT_UPDATES: Mutex<VecDeque<(String, String)>>

After every onClick/onChange invocation, the auto-emitted handler in Index.ets drains both queues:

.onClick(() => {
    perryEntry.invokeCallback(0);
    let __t = perryEntry.drainToast();
    while (__t !== undefined) {
        promptAction.showToast({ message: __t });
        __t = perryEntry.drainToast();
    }
    let __u = perryEntry.drainTextUpdate();
    while (__u !== undefined) {
        this.applyTextUpdate(__u.id, __u.value);
        __u = perryEntry.drainTextUpdate();
    }
})

applyTextUpdate(id, value) is a switch over registered Text ids that assigns to the matching @State text_<id>: string field — ArkUI’s reactivity then rerenders the Text widget.

Why NAPI?

HarmonyOS NEXT uses the OpenHarmony NAPI binding (modeled on Node’s NAPI) to load native .so libraries from ArkTS. Perry’s crates/perry-runtime/src/ohos_napi.rs registers a module via napi_module_register in an .init_array constructor (Rust’s equivalent of __attribute__((constructor))), with the modname auto-derived from the .so filename via dladdr. The exported NAPI surface is just run / invokeCallback / invokeCallback1 / drainToast / drainTextUpdate — every other Perry runtime call happens within the .so itself.

Known limitations

  • LazyVStack is currently rendered eagerly as a plain Column. Real lazy rendering for big lists needs ArkUI’s LazyForEach + a custom IDataSource impl — tracked as Phase 2 v10.
  • State binding is one-waysetText("id", value) from a closure updates the Text on-screen, but a generic state<T> reactive container (const count = state(0); count.set(...)) is Phase 2 v6 follow-up work.
  • Multi-page navigation (NavStack / Router across multiple .ets files) is Phase 2 v11.
  • AppGallery production signing uses a different cert chain than DevEco’s debug certs and isn’t yet plumbed into perry compile. The current splice workflow handles debug-emulator deploy.
  • Real device validation is pending — every milestone has been verified on the Pura 90 Pro Max emulator. AppGallery upload + real-hardware install will follow.

Validated on emulator

End-to-end on Pura 90 Pro Max with a 5-widget interactive page (counter + reset, TextField echoing input live as You typed: <text>, Toggle flipping Notifications: on/off with toast feedback, Slider tracking Volume: N continuously, reactive Texts everywhere). Each interaction routes:

ArkUI event → invokeCallback{,1} → typeof-dispatch in NAPI → NaN-box marshal
            → js_closure_call{0,1} → user TS body runs with the typed arg
            → closure calls setText / showToast → drain queues → ArkUI rerenders

This is the first time Perry-compiled TypeScript state mutation has reactively driven a HarmonyOS NEXT screen.

Version history

  • v0.5.401 — Phase 2 v1.5: full widget set rendering (Text/VStack/HStack/Button/TextField/Toggle/Slider/Spacer/Divider).
  • v0.5.417 — Phase 2 v2 + v3 + v2.5: Button onClick callback bridge, showToast, reactive Text via setText, multi-arg Toggle/TextField/Slider value forwarding.
  • v0.5.418 — Phase 2 v4: Image / ScrollView / LazyVStack / Picker / ProgressView / Section.
  • v0.5.420 / .421 — Cross-platform showToast + setText on iOS / tvOS / visionOS / Android.
  • v0.5.422 / .423 — Cross-platform showToast + setText on Windows / GTK4.
  • v0.5.429 — Phase 2 v5: inline style: { ... } + ForEach via array.map.

For the full per-version detail see CHANGELOG.md.

Windows

Perry compiles TypeScript apps for Windows using the Win32 API.

Requirements

  • Windows 10 or later by default (Windows 7 SP1 / Windows 8 supported via --min-windows-version=7|8 — see Windows 7 Compatibility for the trade-offs)
  • A linker toolchain — either of these two options:

Uses LLVM’s clang + lld-link plus an xwin’d copy of the Microsoft CRT + Windows SDK libraries. No admin rights, no Visual Studio install.

winget install LLVM.LLVM
perry setup windows

perry setup windows downloads ~700 MB (unpacks to ~1.5 GB) at %LOCALAPPDATA%\perry\windows-sdk after prompting you to accept the Microsoft redistributable license. Pass --accept-license to skip the prompt in CI. Partial downloads resume safely on re-run.

Option B — Visual Studio (~8 GB)

If you already have Visual Studio installed, add the C++ workload via the Visual Studio Installer → Modify → check Desktop development with C++. Or install standalone Build Tools:

winget install Microsoft.VisualStudio.2022.BuildTools --override `
  "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"

Both options produce identical binaries — Perry picks Option A when the xwin’d sysroot is present, Option B otherwise. Run perry doctor to see which is active.

Building

perry compile app.ts -o app.exe --target windows

UI Toolkit

Perry maps UI widgets to Win32 controls:

Perry WidgetWin32 Class
TextStatic HWND
ButtonHWND Button
TextFieldEdit HWND
SecureFieldEdit (ES_PASSWORD)
ToggleCheckbox
SliderTrackbar (TRACKBAR_CLASSW)
PickerComboBox
ProgressViewPROGRESS_CLASSW
ImageGDI
VStack/HStackManual layout
ScrollViewWS_VSCROLL
CanvasGDI drawing
Form/SectionGroupBox

Windows-Specific APIs

  • Menu bar: HMENU / SetMenu
  • Dark mode: Windows Registry detection
  • Preferences: Windows Registry
  • Keychain: CredWrite/CredRead/CredDelete (Windows Credential Manager)
  • Notifications: Toast notifications
  • File dialogs: IFileOpenDialog / IFileSaveDialog (COM)
  • Alerts: MessageBoxW
  • Open URL: ShellExecuteW

Next Steps

Windows 7 Compatibility

Perry supports compiling executables that run on Windows 7 SP1 (and Windows 8 / 8.1) — opt-in via the --min-windows-version flag. The default target stays Windows 10+ to preserve full DPI fidelity and modern OS integration; legacy support is one flag away when you need it.

This page covers what works, what degrades, what’s outright impossible, and how to validate your build before shipping.

TL;DR

perry compile app.ts -o app.exe --target windows --min-windows-version=7

Produces a PE marked Win7-compatible. Perry’s UI runtime resolves the Win10-only DPI APIs lazily at startup and falls back through Win8.1 → Vista primitives, so the binary starts on Win7 SP1. Most UI widgets work. Some cosmetic effects (rounded corners, dark titlebar) silently no-op. No JavaScript-module imports allowed on Win7 — the V8 runtime is Win10+ unconditional.

Why this is opt-in

Two things make a Win7-compatible PE different from a default Perry build:

  1. The PE subsystem version field. Default Perry builds let the linker pick (currently /SUBSYSTEM:WINDOWS with no version, which marks the binary as needing Win8+). Win7 needs /SUBSYSTEM:WINDOWS,5.1 or /SUBSYSTEM:CONSOLE,5.1. The ,5.1 suffix is the PE subsystem ABI declaration of “I claim to run on Windows NT 5.1 or higher” — the OS loader reads this field before deciding whether to load the binary.

  2. Win10-only API calls become runtime-resolved. Perry’s UI library calls SetProcessDpiAwarenessContext (Win10 1607) and GetDpiForSystem (Win10 1607) for per-monitor v2 DPI awareness. Hard-importing them via extern "system" would emit IAT entries that the OS resolves before main() runs — on Win7, the loader fails the process with “entry point not found in user32.dll” before any Rust code can run. With --min-windows-version=7 (and on default builds too — the retrofit is unconditional), Perry resolves these symbols lazily via LoadLibraryW + GetProcAddress and falls back through:

    TierAPIMin Windows
    1SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)Windows 10 1607
    2SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE)Windows 8.1
    3SetProcessDPIAware()Windows Vista

    System DPI lookup uses the same lazy pattern: GetDpiForSystem (Win10) → GetDC + GetDeviceCaps(LOGPIXELSY) (Win2000+).

The --min-windows-version flag controls only (1) — the PE marker. The lazy DPI resolution from (2) is always active because it costs essentially nothing and makes default builds more robust against being run on stripped-down Windows installs.

Accepted values

--min-windows-versionSubsystem suffixTargetsDefault?
10(none — linker default)Windows 10+yes
8,6.02Windows 8 / 8.1+no
7,5.1Windows 7 SP1+no

Anything else is a hard error at compile time — typos like --min-windows-version=11 fail loudly instead of silently behaving like the default.

What works on Win7

The same audit that produced this feature found 12 KLOC of Win32 UI code in perry-ui-windows and 5 calls that touch Win10+ APIs. The 5 break down as 2 hard blockers (now lazy-resolved) and 3 cosmetic-effect calls that already failed soft and silently no-op on Win7. So the bulk of the UI surface — every standard widget — works on Win7 SP1:

  • All layout containers (VStack, HStack, ZStack, ScrollView, Spacer, Divider)
  • All input widgets (Button, TextField, SecureField, Toggle, Slider, Picker, ProgressView)
  • Text, Canvas, Image (file + symbol)
  • Form and LazyVStack
  • File / folder open / save dialogs
  • Clipboard access
  • Audio (WASAPI is Vista+)
  • Keyboard shortcuts, menus, toolbars
  • Multi-window
  • The full perry-runtime and perry-stdlib surface — fs, http, crypto, child_process, Date, Buffer, etc.

What degrades silently

These behaviors target Win10 / Win11 features. On Win7 the API call returns an error code that Perry already swallows; the binary runs, but the visual effect is missing:

  • DPI quality. Win7 has system-wide DPI only — moving a window between monitors with different DPI doesn’t trigger re-scaling. Per-monitor v2 (font hinting, dialog scaling) is Win10 1607+.
  • Dark titlebar. DWMWA_USE_IMMERSIVE_DARK_MODE is Win10 1809+. Titlebar follows the system theme on Win7 (light only on stock Win7).
  • Rounded window corners. DWMWA_WINDOW_CORNER_PREFERENCE is Win11+. Frameless windows have square corners on Win7 / Win10.
  • Mica / Acrylic backdrop. DWMWA_SYSTEMBACKDROP_TYPE is Win11+. Backdrop falls through to the standard window background on Win7 / Win10.

What is impossible

perry/jsruntime (V8 / deno_core) is Win10+ unconditional. Anything in your project that imports a .js module from node_modules triggers --enable-jsruntime, which links against deno_core, which embeds V8, which won’t load on Win7. There’s no fallback for this — Win7 builds must avoid JS-module imports entirely. If your project compiles cleanly without --enable-jsruntime (i.e. only TypeScript imports, only Perry-native packages), you’re good.

Universal Windows Platform (UWP / WinRT) APIs. Perry doesn’t currently use these, but if a future feature does (e.g. modern toast notifications), it’ll be Win10+ only. The runtime + stdlib audit was clean as of v0.5.395.

How the lazy DPI resolution works

The retrofit lives in crates/perry-ui-windows/src/dpi_compat.rs. It exposes two functions, both safe to call on any Windows version from Vista onward:

#![allow(unused)]
fn main() {
pub fn set_process_dpi_awareness_compat();
pub fn get_system_dpi_compat() -> u32;
}

Internally each function:

  1. Calls LoadLibraryA("user32.dll") (and shcore.dll for the Win8.1 tier) — both are loaded into every Win32 process by the kernel before main, so the call is a cheap handle lookup.
  2. Calls GetProcAddress to find the desired symbol. Caches the result (success or failure) in an AtomicPtr + AtomicU8 pair, so the lookup runs at most once per process.
  3. Falls through to the next tier on miss. set_process_dpi_awareness_compat ends with SetProcessDPIAware() (Vista+, hard-imported because every supported Windows has it). get_system_dpi_compat ends with GetDeviceCaps(LOGPIXELSY) (Win2000+, dead reliable).

After the cache is warm — i.e. after app_create runs once — every subsequent DPI query is a single atomic load + indirect call. No measurable runtime cost vs. a hard-imported call.

Validating a Win7 build

Perry’s CI and dev hosts don’t have Win7 VMs. If you ship to Win7 you need to validate the binary yourself. Three checks:

1. PE subsystem version

Use dumpbin /headers app.exe | findstr "subsystem" (MSVC) or objdump -p app.exe | grep "MajorOSVersion" (LLVM):

$ dumpbin /headers app.exe | findstr "subsystem"
            5.01 subsystem version
               2 subsystem (Windows GUI)

The 5.01 confirms the PE is Win7-compatible. A default build shows 6.00 or higher.

2. Imports

Use dumpbin /imports app.exe | findstr /i "user32" (MSVC) or objdump -p app.exe | grep -A20 "DLL Name: user32" (LLVM). Confirm that SetProcessDpiAwarenessContext and GetDpiForSystem are not in the user32.dll import list. If they are, the lazy retrofit isn’t taking effect — likely you’ve added a use windows::Win32::UI::HiDpi::SetProcessDpiAwarenessContext; somewhere that pulls the symbol back in.

3. Run on a Win7 SP1 VM

There’s no substitute for actually launching the binary. Microsoft’s free Win7 evaluation VM (no longer hosted directly by Microsoft, mirrored on archive.org) is the canonical reference image. Worth keeping a snapshot for regression checks.

Caveats and gotchas

  • The MSVC linker may warn about subsystem version ,5.1 being below the C runtime’s stated minimum on newer toolchains. The warning is benign — the CRT itself runs on Win7, the warning is conservative. Watch for hard errors, not warnings.
  • xwin sysroot setup is unchanged. Cross-compiling from macOS / Linux still uses the perry setup windows xwin’d toolchain. Nothing in --min-windows-version changes the SDK requirements.
  • Static-link the CRT if you want the binary to run on a clean Win7 SP1 install with no Visual C++ Redistributable. Confirm the binary doesn’t import vcruntime140.dll / msvcp140.dll via the dumpbin/objdump check above.
  • perry/thread’s SRWLOCK is Vista+, fine. Perry’s threading primitives use Rust std, which uses SRWLOCK on Windows since Rust 1.42. No WaitOnAddress (Win8+) involvement on the supported Rust versions.

Issue tracking

This feature lands as the resolution to #303. If you hit a Win7-specific failure that isn’t covered here, please file a follow-up referencing this page so we can extend the audit.

Linux (GTK4)

Perry compiles TypeScript apps for Linux using GTK4.

Requirements

GTK4 + libshumate (MapView) + GStreamer (audio playback) development libraries. The release-packages CI pins to these and a build-from-source fails without them. Cairo comes in as a transitive dep of GTK4 on every distro.

# Ubuntu / Debian
sudo apt install libgtk-4-dev libshumate-dev libgstreamer1.0-dev

# Fedora
sudo dnf install gtk4-devel libshumate-devel gstreamer1-devel \
                 gstreamer1-plugins-base-devel

# Arch
sudo pacman -S gtk4 libshumate gstreamer gst-plugins-base

If you only need the CLI (compiling for non-Linux targets) and won’t build perry-ui-gtk4 locally, you can skip libshumate and gstreamer.

Building

perry app.ts -o app --target linux
./app

UI Toolkit

Perry maps UI widgets to GTK4 widgets:

Perry WidgetGTK4 Widget
TextGtkLabel
ButtonGtkButton
TextFieldGtkEntry
SecureFieldGtkPasswordEntry
ToggleGtkSwitch
SliderGtkScale
PickerGtkDropDown
ProgressViewGtkProgressBar
ImageGtkImage
VStackGtkBox (vertical)
HStackGtkBox (horizontal)
ZStackGtkOverlay
ScrollViewGtkScrolledWindow
CanvasCairo drawing
NavigationStackGtkStack

Linux-Specific APIs

  • Menu bar: GMenu / set_menubar
  • Toolbar: GtkHeaderBar
  • Dark mode: GTK settings detection
  • Preferences: GSettings or file-based
  • Keychain: libsecret
  • Notifications: GNotification
  • File dialogs: GtkFileChooserDialog
  • Alerts: GtkMessageDialog

Styling

GTK4 styling uses CSS under the hood. Perry’s styling methods (colors, fonts, corner radius) are translated to CSS properties applied via CssProvider.

Testing with Geisterhand

Perry’s built-in UI fuzzer works on Linux/GTK4. Screenshots use WidgetPaintable + GskRenderer for pixel-accurate capture.

perry app.ts -o app --target linux --enable-geisterhand
./app
# In another terminal:
curl http://127.0.0.1:7676/widgets
curl http://127.0.0.1:7676/screenshot -o screenshot.png

See Geisterhand for full API reference.

Next Steps

Web

--target web and --target wasm are aliases for the same backend. Both produce a self-contained HTML file with embedded WebAssembly and a JavaScript bridge for DOM widgets.

perry app.ts -o app --target web    # same output as --target wasm
open app.html

See WebAssembly / Web for the full documentation: how it works, supported features, UI mapping, FFI, threading, limitations, and examples.

Why one target instead of two?

Perry used to have two browser backends:

  • --target web (perry-codegen-js) — transpiled HIR to JavaScript
  • --target wasm (perry-codegen-wasm) — compiled HIR to WebAssembly

These were consolidated into the WASM target so browser apps get near-native performance, FFI imports, and Web Worker threading without needing a separate JS-emit pipeline. The DOM widget runtime that the old --target web provided is now embedded in wasm_runtime.js. Both flags route through perry-codegen-wasm and produce identical HTML output.

Next Steps

WebAssembly / Web

Perry compiles TypeScript apps to WebAssembly for the browser using --target wasm or its alias --target web. Both flags route through the same backend (perry-codegen-wasm) and produce the same output: a self-contained HTML file with embedded WASM bytecode and a thin JavaScript bridge for DOM widgets and host APIs.

There used to be a separate JavaScript-emitting --target web (perry-codegen-js); it was consolidated into the WASM target so browser apps get near-native performance, FFI imports, and Web Worker threading “for free”.

Building

# Self-contained HTML (default)
perry app.ts -o app --target web
open app.html

# Same thing
perry app.ts -o app --target wasm

# Raw .wasm binary (no HTML wrapper)
perry app.ts -o app.wasm --target wasm

The default output is a single .html file containing a base64-embedded WASM binary, the wasm_runtime.js bridge, and a bootPerryWasm() call that instantiates the module. Open it directly in any modern browser — no build step, no server required for simple apps.

Note: Apps that use fetch() or other web platform APIs that depend on a real origin must be served over HTTP (file:// URLs run into CORS / “Failed to fetch” errors). Any local static server works:

python3 -m http.server 8765
open http://localhost:8765/app.html

How It Works

The perry-codegen-wasm crate compiles HIR directly to WASM bytecode using wasm-encoder. The output WASM:

  • Imports ~280 host functions under the rt namespace (string ops, math, console, JSON, classes, closures, promises, fetch, etc.)
  • Imports user-declared FFI functions under the ffi namespace
  • Exports _start, memory, __indirect_function_table, and every user function as __wasm_func_<idx> (so async function bodies compiled to JS can call back into WASM)

The NaN-boxing scheme matches the native perry-runtime — f64 values with STRING_TAG/POINTER_TAG/INT32_TAG — so the same value representation is used across native and WASM targets. The JS bridge wraps every host import with bit-level reinterpretation so f64 NaN-boxed values pass through the BigInt-based JS↔WASM i64 boundary intact (BigInt(NaN) would otherwise throw).

Supported Features

  • Full TypeScript language: classes (with constructors, methods, getters/setters, inheritance, fields), async/await, closures (with captures), generators, destructuring, template literals, generics, enums, try/catch/finally
  • Module system: cross-module imports, top-level const/let (promoted to WASM globals), circular imports
  • Standard library: String/Array/Object methods, Map/Set, JSON, Date, RegExp, Math, Error, URL/URLSearchParams, Buffer, Promise (with .then/.catch/.allSettled/.race/.any/.all)
  • Async: async/await (compiled to JS Promises), setTimeout/setInterval, fetch() with full request options (method, headers, body)
  • Threading: perry/thread parallelMap/parallelFilter/spawn via Web Worker pool with one WASM instance per worker (see Threading)
  • DOM-based UI: every widget in perry/ui (VStack, HStack, ZStack, Text, Button, TextField, Toggle, Slider, ScrollView, Picker, Image, Canvas, Form, Section, NavigationStack, Table, LazyVStack, TextArea, etc.) maps to a DOM element with flexbox layout. State bindings (bindText/bindSlider/bindToggle/bindForEach/…) work via reactive subscribers.
  • System APIs: localStorage-backed preferences/keychain, dark mode detection (prefers-color-scheme), Web Notifications, clipboard, file open/save dialogs, File System Access API, Web Audio capture
  • FFI: declare function declarations become WASM imports under the ffi namespace
  • Compile-time i18n: perry/i18n t() calls work the same as native targets

UI Mapping

Perry widgets map to HTML elements:

Perry WidgetHTML Element
Text<span>
Button<button>
TextField<input type="text">
SecureField<input type="password">
Toggle<input type="checkbox">
Slider<input type="range">
Picker<select>
ProgressView<progress>
Image / ImageFile<img>
VStack<div> (flexbox column)
HStack<div> (flexbox row)
ZStack<div> (position: relative + absolute children)
ScrollView<div> (overflow: auto)
Canvas<canvas> (2D context)
Table<table>
Divider<hr>
Spacer<div> (flex: 1)

FFI Support

The WASM target supports external FFI functions declared with declare function. They become WASM imports under the "ffi" namespace:

declare function bloom_init_window(w: number, h: number, title: number, fs: number): void
declare function bloom_draw_rect(x: number, y: number, w: number, h: number,
                                  r: number, g: number, b: number, a: number): void

Provide them when instantiating:

// Via __ffiImports global (set before boot)
globalThis.__ffiImports = { bloom_init_window: ..., bloom_draw_rect: ... };

// Or via bootPerryWasm second argument
await bootPerryWasm(wasmBase64, { bloom_init_window: ..., bloom_draw_rect: ... });

Auto-stub for missing imports. The ffi namespace is wrapped in a Proxy so any FFI function the host doesn’t provide is auto-stubbed with a no-op that returns TAG_UNDEFINED. This means apps that use native libraries (e.g. Hone Editor’s 56 hone_editor_* functions) can still instantiate and run in the browser even without the native bindings — the relevant features are simply no-ops.

Module-Level Constants

Top-level const/let declarations are promoted to dedicated WASM globals so functions in the same module can read them, and so two modules’ identical LocalIds don’t collide:

// telemetry.ts
const CHIRP_URL = 'https://api.chirp247.com/api/v1/event'
const API_KEY   = 'my-key'

export function trackEvent(event: string): void {
    fetch(CHIRP_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Chirp-Key': API_KEY },
        body: JSON.stringify({ event }),
    })
}

Both CHIRP_URL and API_KEY become WASM globals indexed by (module_idx, LocalId). Reading them from trackEvent emits a global.get instead of trying to look up a function-local that doesn’t exist.

JavaScript Runtime Bridge

The bridge (wasm_runtime.js) is embedded in the HTML and provides ~280 imports across:

  • NaN-boxing helpers: f64ToU64 / u64ToF64 / nanboxString / nanboxPointer / toJsValue / fromJsValue
  • String table: dynamic JS string array indexed by string ID
  • Handle store: maps integer handle IDs to JS objects, arrays, closures, promises, DOM elements
  • Core ops: console, math, JSON, JSON.parse/stringify, Date, RegExp, URL, Map, Set, Buffer, fetch
  • Closure dispatch: indirect function table + capture array, with closure_call_0/1/2/3/spread
  • Class dispatch: class_new, class_call_method, class_get_field, class_set_field, parent table for inheritance
  • DOM widgets: 168+ perry_ui_* functions covering every widget in perry/ui
  • Async functions: compiled to JS function bodies and merged into the import object as __async_<name>

All host imports are wrapped via wrapImportsForI64() so they automatically reinterpret BigInt args (from WASM i64 params) into f64 internally and reinterpret Number returns back into BigInt. Without this wrapping, every NaN-valued f64 return would crash with “Cannot convert NaN to a BigInt”.

Web Worker Threading

perry/thread works in the browser via a Web Worker pool:

function workerThreadDemo(): void {
    const numbers = [1, 2, 3, 4, 5, 6, 7, 8]
    const squares = parallelMap(numbers, (n: number) => n * n)
    console.log(`squares len=${squares.length}`)
}

Each worker instantiates its own WASM module with the same bytecode and bridge. Values cross between the main thread and workers via structured-clone serialization. See Threading.

Limitations

  • No file system access beyond the File System Access API (window.showDirectoryPicker())
  • No raw TCP/UDP sockets — only fetch() and WebSocket
  • No subprocess spawningchild_process.exec etc. are no-ops
  • No native databases — SQLite, Postgres, MySQL drivers don’t compile to web
  • CORS applies to all fetch() calls — third-party APIs must allow your origin
  • localStorage, not real keychain — fine for preferences, not for secrets
  • Source-mapped stack traces are JS-only; WASM stack frames show wasm-function[N]

Minification

Use --minify to minify the embedded JS runtime bridge in the HTML output. The Rust-native JS minifier strips comments, collapses whitespace, and mangles internal identifiers, compressing the runtime from ~3,400 lines to ~180.

perry app.ts -o app --target web --minify

Example: Counter App

import { App, Text, VStack, Button, State } from "perry/ui"

const count = State(0)

App({
    title: "Counter",
    width: 400,
    height: 300,
    body: VStack(16, [
        Text(`Count: ${count.value}`),
        Button("Increment", () => count.set(count.value + 1)),
    ]),
})
perry counter.ts -o counter --target web
open counter.html

Example: Real-World App (Mango MongoDB GUI)

The Mango MongoDB GUI — 50 modules, 998 functions, classes, async functions, fetch with custom headers, the Hone code editor — compiles to a single 4 MB HTML file via --target web and renders its full UI (welcome screen, query view, edit view) in the browser. SQLite-backed connection storage gracefully degrades to an in-memory transient store on web; the rest of the app works the same as the native version.

Next Steps

Standard Library Overview

Perry natively implements many popular npm packages and Node.js APIs. When you import a supported package, Perry compiles it to native code — no JavaScript runtime involved.

How It Works

import fastify from "fastify"
import mysql from "mysql2/promise"

Perry recognizes these imports at compile time and routes them to native Rust implementations. Most live in standalone perry-ext-* crates backed by the stable perry-ffi ABI (the “well-known native bindings” registry shipped in v0.5.532); a few of the older Node.js built-ins still live in perry-stdlib. Either way the import surface matches the original npm package, so existing code often works unchanged.

Supported Packages

Networking & HTTP

  • node:http / node:https / node:http2 — Node.js stdlib HTTP server modules + WebSocket upgrade dispatch (issue #577). The full IncomingMessage / ServerResponse surface plus TLS via rustls and HTTP/2 via ALPN. See HTTP & Networking.
  • hono — runtime-agnostic web framework. app.fetch works end-to-end via compilePackages (testing + edge runtimes). Long-lived port-listening server pattern is currently blocked on #589. See HTTP & Networking → Hono.
  • fastify — HTTP server framework (native binding, separate from node:http).
  • axios — HTTP client.
  • node-fetch / fetch — HTTP fetch API.
  • ws — WebSocket client/server.

Databases

  • mysql2 — MySQL client
  • pg — PostgreSQL client
  • better-sqlite3 — SQLite
  • mongodb — MongoDB client
  • ioredis / redis — Redis client

Cryptography

  • bcrypt — Password hashing
  • argon2 — Password hashing (Argon2)
  • jsonwebtoken — JWT signing/verification
  • crypto — Node.js crypto module
  • ethers — Ethereum library

Utilities

  • lodash — Utility functions
  • dayjs / moment — Date manipulation
  • uuid — UUID generation
  • nanoid — ID generation
  • slugify — String slugification
  • validator — String validation

CLI & Data

  • commander — CLI argument parsing
  • decimal.js — Arbitrary precision decimals
  • bignumber.js — Big number math
  • lru-cache — LRU caching

Other

  • sharp — Image processing
  • cheerio — HTML parsing
  • nodemailer — Email sending
  • zlib — Compression
  • cron — Job scheduling
  • worker_threads — Background workers
  • exponential-backoff — Retry logic
  • async_hooks — AsyncLocalStorage
  • perry/container — OCI container management
  • perry/compose — Multi-container orchestration

Node.js Built-ins

  • fs — File system
  • path — Path manipulation
  • child_process — Process spawning
  • crypto — Cryptographic functions

Binary Size

Perry automatically detects which stdlib features your code uses:

UsageBinary Size
No stdlib imports~300KB
fs + path only~3MB
Full stdlib~48MB

The compiler links only the required runtime components.

External native bindings

Two packages live in their own GitHub repos with their own semver but plug into the same well-known registry:

Pure-TypeScript drivers compiled via compilePackages (no Rust):

  • @perryts/postgres — pg-compatible wire-protocol driver.
  • @perryts/mysql — mysql2-compatible wire-protocol driver.
  • @perryts/mongodb — mongodb-compatible wire-protocol driver.
  • @perryts/redis — Redis / Valkey RESP2 + RESP3 wire-protocol driver.

Each of these also runs unmodified on Node.js / Bun. See Native Bindings — Overview for the contract they follow.

compilePackages

For npm packages not natively supported, you can compile pure TypeScript/JavaScript packages natively:

{
  "perry": {
    "compilePackages": ["@noble/curves", "@noble/hashes"]
  }
}

See Project Configuration for details.

JavaScript Runtime Fallback

For packages that can’t be compiled natively (native addons, dynamic code, etc.), Perry includes a QuickJS-based JavaScript runtime as a fallback. The exact API surface is internal-only today; the import below is illustrative:

import { jsEval } from "perry/jsruntime"; // illustrative — not yet a public export

Next Steps

File System

Perry implements Node.js file system APIs for reading, writing, and managing files.

Reading Files

const configPath = join(scratch, "config.json")
const content = readFileSync(configPath, "utf-8")
console.log(content)

Binary File Reading

const imagePath = join(scratch, "image.png")
const buffer = readFileBuffer(imagePath)
console.log(`Read ${buffer.length} bytes`)

readFileBuffer reads files as binary data (uses fs::read() internally, not read_to_string()).

Writing Files

const outputPath = join(scratch, "output.txt")
const dataPath = join(scratch, "data.json")
writeFileSync(outputPath, "Hello, World!")
writeFileSync(dataPath, JSON.stringify({ key: "value" }, null, 2))

File Information

if (existsSync(configPath)) {
    const stat = statSync(configPath)
    console.log(`Size: ${stat.size}`)
}

Directory Operations

// Create directory
const outDir = join(scratch, "output")
if (!existsSync(outDir)) mkdirSync(outDir)

// Read directory contents
const files = readdirSync(scratch)
for (const file of files) {
    console.log(file)
}

// Remove an empty directory
rmdirSync(outDir)

For recursive removal Perry exposes rmRecursive (a thin wrapper around std::fs::remove_dir_all). Wired via #193 through js_fs_rm_recursive in the LLVM backend.

import { rmRecursive } from "fs";
rmRecursive("output"); // Recursive remove; returns 1 on success, 0 on failure.

Path Utilities

const dir = dirname(configPath)
const cfgPath = join(dir, "config.json")
const name = basename(cfgPath)        // "config.json"
const abs = resolve("relative/path")  // Absolute path
console.log(`${name} ${abs.length > 0}`)

For import.meta.url → filesystem path conversion, use fileURLToPath from the url module:

import { fileURLToPath } from "url";
import { dirname } from "path";

const dir = dirname(fileURLToPath(import.meta.url));

Threading

fs numeric file descriptors and fs.promises.FileHandle objects are thread-affine across perry/thread. Passing a numeric fd into spawn or parallelMap copies only the number; the receiving thread has its own fd registry, so operations fail with EBADF. Passing a FileHandle produces a detached handle with fd === -1.

Pass file paths across thread boundaries and reopen files inside the worker.

Next Steps

HTTP & Networking

Perry natively implements HTTP servers, clients, and WebSocket support.

Node.js compatibility — node:http / node:https / node:http2

Perry exposes a faithful subset of Node.js’s stdlib HTTP server modules on top of hyper + rustls + tokio-tungstenite. The whole shape — handler signature, IncomingMessage / ServerResponse properties + methods, TLS opts, ALPN-negotiated HTTP/2, WebSocket upgrade dispatch — works unmodified, so unmodified Node servers (Express / Koa / Polka / hono via @hono/node-server / etc.) compile and run natively (issue #577).

http.createServer(handler)

// node:http server (issue #577). Drop-in for Node.js's `http.createServer`
// — same handler shape `(req, res) => …` and same property/method
// surface (`req.method`, `req.url`, `req.headers`, `res.statusCode`,
// `res.setHeader`, `res.end`, `res.write`, `res.writeHead`). The
// canonical Express body-collection pattern (`req.on('data', ...)`,
// `req.on('end', ...)`) works against a fully-buffered request body.
import { createServer } from "node:http"

const httpServer = createServer((req: any, res: any) => {
    if (req.method === "POST" && req.url === "/echo") {
        let chunks: string[] = []
        req.on("data", (chunk: string) => chunks.push(chunk))
        req.on("end", () => {
            const body = chunks.join("")
            res.statusCode = 200
            res.setHeader("Content-Type", "text/plain")
            res.end("got:" + body)
        })
        return
    }
    res.statusCode = 200
    res.setHeader("Content-Type", "application/json")
    res.end(`{"path":"${req.url}"}`)
})

httpServer.listen(3000, () => {
    console.log("[node:http] listening on http://0.0.0.0:3000")
})

Supported on IncomingMessage: .method, .url, .headers, .rawHeaders, .httpVersion, .complete, .aborted, .destroyed, .socket.remoteAddress, .socket.remotePort, .on('data'|'end'|'close'| 'error', cb), .read(), .pause(), .resume(), .destroy().

Supported on ServerResponse: .statusCode (get/set), .statusMessage (set), .setHeader/.getHeader/.removeHeader/.hasHeader/ .getHeaders/.getHeaderNames, .headersSent, .writableEnded, .writableFinished, .writeHead(status, msg?, headers?), .write(chunk), .end(chunk?), .flushHeaders(), .on('finish'|'close', cb). Auto Content-Length on .end() when no Transfer-Encoding was set.

https.createServer({ key, cert }, handler)

// node:https server (issue #577 Phase 2). Same handler surface as
// `node:http`, plus a `{ key, cert }` opts arg with PEM-encoded TLS
// material. rustls 0.23 underneath; the CryptoProvider is installed
// lazily on first `https.createServer` call. ALPN defaults to
// `http/1.1`; opt into HTTP/2 by passing
// `alpnProtocols: ["h2", "http/1.1"]` (or use `node:http2` directly).
import { createServer as createTlsServer } from "node:https"
import { readFileSync } from "node:fs"

const tlsServer = createTlsServer(
    {
        key: readFileSync("/tmp/perry-https-cert/key.pem", "utf8"),
        cert: readFileSync("/tmp/perry-https-cert/cert.pem", "utf8"),
    },
    (req: any, res: any) => {
        res.statusCode = 200
        res.setHeader("Content-Type", "application/json")
        res.end(`{"tls":"ok","path":"${req.url}"}`)
    }
)

tlsServer.listen(443)

Both key and cert are PEM strings (PKCS#8 / RSA / EC keys + multi-cert chains all parse). ALPN defaults to http/1.1 only — programs that want HTTP/2 should reach for node:http2’s createSecureServer (which always advertises [h2, http/1.1]).

http2.createSecureServer({ key, cert }, handler)

// node:http2 server (issue #577 Phase 3). `createSecureServer({ key, cert })`
// drives a hyper-util auto::Builder so HTTP/2 and HTTP/1.1 share a
// single port via ALPN auto-negotiation. The handler signature is
// the same as Phase 1 / Phase 2 — IncomingMessage / ServerResponse
// are reused as Http2ServerRequest / Http2ServerResponse since each
// `:path` request becomes a single buffered IncomingMessage.
import { createSecureServer } from "node:http2"

const h2Server = createSecureServer(
    {
        key: readFileSync("/tmp/perry-https-cert/key.pem", "utf8"),
        cert: readFileSync("/tmp/perry-https-cert/cert.pem", "utf8"),
    },
    (req: any, res: any) => {
        res.statusCode = 200
        res.setHeader("Content-Type", "application/json")
        res.end(`{"h2":"ok","path":"${req.url}","httpVersion":"${req.httpVersion}"}`)
    }
)

h2Server.listen(8443)

Driven through hyper-util’s auto::Builder, so an HTTP/1.1 client (curl without --http2) and an HTTP/2 client (curl with --http2) hit the same handler over the same port.

WebSocket upgrade — Server.on('upgrade', (req, wsId, head) => …)

// node:http + WebSocket upgrade (issue #577 Phase 4). The `'upgrade'`
// event fires once per WebSocket client; the `wsId` argument is
// already a fully-handshaked, perry-ext-ws-registered connection,
// so the usual `wsId.on('message', ...)` / `wsId.send(...)` /
// `wsId.close()` surface works without further plumbing. The
// IncomingMessage `req` carries the original upgrade request
// (URL, headers — useful for routing or auth).
const wsHttpServer = createServer((req: any, res: any) => {
    res.statusCode = 200
    res.end("perry node:http server with ws upgrade")
})

wsHttpServer.on("upgrade", (req: any, wsId: any, _head: any) => {
    wsId.on("message", (msg: string) => {
        wsId.send("echo:" + msg)
    })
    wsId.send("perry-hello")
})

wsHttpServer.listen(3001)

The HTTP/1.1 server detects Upgrade: websocket in the request, performs the handshake server-side (Sec-WebSocket-Accept derived via tungstenite’s derive_accept_key), then registers the upgraded stream in perry-ext-ws’s connection map. The TS-side wsId argument is already a fully-connected client — drive it via the standard wsId.on('message', cb) / wsId.send(msg) / wsId.close() surface that standalone WebSocketServer({ port }) clients use.

Hono

Hono is a runtime-agnostic web framework whose only required interface is app.fetch(req: Request) → Promise<Response>. Add it to perry.compilePackages and the entire app.fetch surface including middleware (hono/logger, hono/cors, hono/jwt), route groups, and JSON responses works unchanged (issues #421, #486, #487 closed). app.fetch is enough for testing, edge-runtime deployments (Cloudflare Workers / Vercel Edge / AWS Lambda / Deno Deploy — those runtimes call app.fetch themselves), and any scenario where some outer host hands you a Request.

import { Hono } from "hono"
import { logger } from "hono/logger"

const app = new Hono()
app.use("*", logger())
app.get("/", (c) => c.json({ message: "hello", ok: true }))

// app.fetch() works end-to-end — feed it a Request, get a Response.
const res = await app.fetch(new Request("http://localhost/"))
console.log(res.status, await res.text())

export default app  // for CF Workers / similar runtimes

package.json:

{
  "perry": {
    "compilePackages": ["hono"]
  }
}

Long-lived HTTP server (port-listening)

The canonical “deploy a hono app as a native binary on a Linux VM” pattern compiles and links on a stock Perry binary. A hand-rolled node:http adapter that drives app.fetch works directly:

import { createServer } from "node:http";

const server = createServer((req, res) => {
  const headers = new Headers();
  headers.set("content-type", "text/plain");
  const fetchReq = new Request(`http://localhost${req.url}`, { method: req.method });
  // ... await app.fetch(fetchReq), then copy status/headers/body onto `res`.
  res.end("ok");
});
server.listen(3000);

The node:http server FFIs and the Web Fetch Headers / Request / Response constructors now link together (issues #589, #1652). For a turnkey adapter, prefer perry’s Fastify binding with a single catch-all route delegating to app.fetch.

@perryts/hono-server

@perryts/hono-server (in-tree at packages/hono-perry-server) packages that catch-all-over-Fastify shim as Hono’s standard serve({ fetch, port }) contract — the Perry counterpart to @hono/node-server / @hono/bun:

import { Hono } from "hono"
import { serve } from "@perryts/hono-server"

const app = new Hono()
app.get("/", (c) => c.json({ ok: true }))

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`listening on :${info.port}`)
})

It translates each Fastify request into a Web Request, awaits app.fetch, and copies the Response’s status / headers / body back onto the reply. Requires Perry ≥ 0.5.1027 (Request.headers, #1649). Tracked at #1654.

Fastify Server

import fastify from "fastify"

const app = fastify()

app.get("/", async (request: any, reply: any) => {
    return { hello: "world" }
})

app.get("/users/:id", async (request: any, reply: any) => {
    const id = request.params.id
    return { id, name: "User " + id }
})

app.post("/data", async (request: any, reply: any) => {
    const body = request.body
    reply.code(201)
    return { received: body }
})

app.listen({ port: 3000 }, () => {
    console.log("Server running on port 3000")
})

Perry’s Fastify implementation is API-compatible with the npm package. Routes, request/reply objects, params, query strings, and JSON body parsing all work.

Fetch API

async function fetchExamples(): Promise<void> {
    // GET request
    const response = await fetch("https://jsonplaceholder.typicode.com/posts/1")
    const data = await response.json()

    // POST request
    const result = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: "hello", body: "world", userId: 1 }),
    })

    console.log(`fetch ok: ${data !== null} status=${result.status}`)
}

Axios

import axios from "axios"

async function axiosExamples(): Promise<void> {
    const getResp = await axios.get("https://jsonplaceholder.typicode.com/users/1")
    const data = getResp.data

    const response = await axios.post("https://jsonplaceholder.typicode.com/users", {
        name: "Perry",
        email: "perry@example.com",
    })

    console.log(`axios ok: ${data !== null} status=${response.status}`)
}

WebSocket

import { WebSocket } from "ws"

function wsExample(): void {
    const ws = new WebSocket("ws://localhost:8080")

    ws.on("open", () => {
        ws.send("Hello, server!")
    })

    ws.on("message", (data: any) => {
        console.log(`Received: ${data}`)
    })

    ws.on("close", () => {
        console.log("Connection closed")
    })
}

AWS S3 / S3-Compatible Object Storage

@bradenmacdonald/s3-lite-client is a zero-dependency, MIT-licensed S3 client (~1.9k LoC, derived from the official MinIO JS client without the lodash/async/xml2js baggage). It compiles natively under perry.compilePackages with no patches required — verified against a SigV4 presigned-URL byte-for-byte match with bun (issue #551).

{
  "perry": {
    "compilePackages": ["@bradenmacdonald/s3-lite-client"]
  }
}
import { S3Client } from "@bradenmacdonald/s3-lite-client"

const s3 = new S3Client({
    endPoint: "https://s3.us-east-1.amazonaws.com",
    region: "us-east-1",
    bucket: "my-bucket",
    accessKey: process.env.AWS_ACCESS_KEY_ID,
    secretKey: process.env.AWS_SECRET_ACCESS_KEY,
})

// Presigned GET URL (no network I/O — pure SigV4 signing)
const url = await s3.presignedGetObject("path/to/object.png", { expirySeconds: 3600 })
console.log(url)

// Upload bytes
await s3.putObject("path/to/object.txt", "hello world", {
    metadata: { "x-amz-acl": "public-read" },
})

// Stream a download — returns a standard fetch Response
const res = await s3.getObject("path/to/object.txt")
console.log(await res.text())

// Head / Delete / List
const meta = await s3.statObject("path/to/object.txt")
console.log(meta.size, meta.lastModified)

for await (const obj of s3.listObjects({ prefix: "path/to/" })) {
    console.log(obj.key, obj.size)
}

await s3.deleteObject("path/to/object.txt")

Same code works against any S3-compatible service — only endPoint changes:

ServiceendPoint
AWS S3https://s3.<region>.amazonaws.com
Cloudflare R2https://<account>.r2.cloudflarestorage.com
MinIOhttp://localhost:9000
Backblaze B2https://s3.<region>.backblazeb2.com
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.com
Supabase Storagehttps://<project>.supabase.co/storage/v1/s3
LocalStack (testing)http://localhost:4566

The full SigV4 signing chain (Web Crypto HMAC-SHA-256 + SHA-256, TextEncoder, URLSearchParams, Headers iteration, typed-array byte marshalling) is exercised end-to-end. Read paths (getObject, statObject, deleteObject, listObjects, presignedGetObject, presignedPostObject) are verified byte-identical to bun against pinned test vectors and will authenticate against real S3.

Multipart uploads (putObject with a ReadableStream source large enough to chunk) exercise additional surface — WritableStream / TransformStream subclassing per #562 — that path compiles but isn’t independently verified against pinned vectors here.

For the AWS SDK v3 (@aws-sdk/client-s3): Perry currently can’t compile it. Its dependency tree pulls in @smithy/* and runtime middleware registration that uses Proxy and dynamic property assignment, neither of which is in Perry’s TypeScript subset. @bradenmacdonald/s3-lite-client covers the same surface (Put/Get/Head/Delete/List/presign + multipart) for almost every real-world need.

Next Steps

Databases

Perry natively implements clients for MySQL, PostgreSQL, SQLite, MongoDB, and Redis.

MySQL

import mysql from "mysql2/promise"

async function mysqlExample(): Promise<void> {
    const connection = await mysql.createConnection({
        host: "localhost",
        user: "root",
        password: "password",
        database: "mydb",
    })

    const [rows] = await connection.execute("SELECT * FROM users WHERE id = ?", [1])
    console.log(rows)

    await connection.end()
}

PostgreSQL

import { Client } from "pg"

async function postgresExample(): Promise<void> {
    const client = new Client({
        host: "localhost",
        port: 5432,
        user: "postgres",
        password: "password",
        database: "mydb",
    })

    await client.connect()
    const result = await client.query("SELECT * FROM users WHERE id = $1", [1])
    console.log(result.rows)
    await client.end()
}

SQLite

import Database from "better-sqlite3"

function sqliteExample(): void {
    const db = new Database("mydb.sqlite")

    db.exec(`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY,
        name TEXT,
        email TEXT
      )
    `)

    const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)")
    insert.run("Perry", "perry@example.com")

    const users = db.prepare("SELECT * FROM users").all()
    console.log(users)
}

MongoDB

import { MongoClient } from "mongodb"

async function mongoExample(): Promise<void> {
    const client = new MongoClient("mongodb://localhost:27017")
    await client.connect()

    const db = client.db("mydb")
    const users = db.collection("users")

    await users.insertOne({ name: "Perry", email: "perry@example.com" })
    const user = await users.findOne({ name: "Perry" })
    console.log(user)

    await client.close()
}

Redis

import Redis from "ioredis"

async function redisExample(): Promise<void> {
    const redis = new Redis()

    await redis.set("key", "value")
    const value = await redis.get("key")
    console.log(value) // "value"

    await redis.del("key")
    await redis.quit()
}

Next Steps

Cryptography

Perry natively implements password hashing, JWT tokens, and Ethereum cryptography.

bcrypt

import bcrypt from "bcrypt"

async function bcryptExample(): Promise<void> {
    const hash = await bcrypt.hash("mypassword", 10)
    const match = await bcrypt.compare("mypassword", hash)
    console.log(match) // true
}

Argon2

import argon2 from "argon2"

async function argon2Example(): Promise<void> {
    const hash = await argon2.hash("mypassword")
    const valid = await argon2.verify(hash, "mypassword")
    console.log(valid) // true
}

JSON Web Tokens

import jwt from "jsonwebtoken"

function jwtExample(): void {
    const secret = "my-secret-key"

    // Sign a token
    const token = jwt.sign({ userId: 123, role: "admin" }, secret, {
        expiresIn: "1h",
    })

    // Verify a token
    const decoded: any = jwt.verify(token, secret)
    console.log(decoded.userId) // 123
}

Node.js Crypto

import crypto from "crypto"

function cryptoExample(): void {
    // Hash
    const hash = crypto.createHash("sha256").update("data").digest("hex")

    // HMAC
    const hmac = crypto.createHmac("sha256", "secret").update("data").digest("hex")

    // Random bytes
    const bytes = crypto.randomBytes(32)

    console.log(`hash_len=${hash.length} hmac_len=${hmac.length} bytes_len=${bytes.length}`)
}

Ethers

import { ethers } from "ethers"

function ethersExample(): void {
    // Utility functions
    const addr = ethers.getAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
    const wei = ethers.parseEther("1.5")
    const ether = ethers.formatEther(wei)
    console.log(`checksum: ${addr}`)
    console.log(`1.5 ether in wei → formatted back: ${ether}`)

    // Create a random wallet
    const wallet = ethers.Wallet.createRandom()
    console.log(`address: ${wallet.address}`)
    console.log(`privateKey length: ${wallet.privateKey.length}`)
}

Next Steps

Containers

The perry/container and perry/compose modules manage OCI containers and multi-container stacks directly from Perry programs — same model as docker compose up, but with the spec as a TS object literal and the orchestration engine running natively in-process (no shell-out to docker-compose).

For the full container subsystem documentation see the dedicated Containers section:

  • Overview — module layout, backend auto-detection, and the canonical lifecycle pattern.
  • Single-Container Lifecycleperry/container: run, inspect, logs, exec, image management.
  • Compose Orchestrationperry/compose: up, down, ps, healthcheck-gated depends_on, env-var interpolation.
  • Networking — internal-only networks, port maps, and the cross-service-DNS workaround.
  • Volumes — named vs. bind mounts and preservation semantics on down().
  • Security — capability isolation, cosign image verification, workload-graph policy tiers.
  • Production Patterns — full Forgejo deployment case study with the patterns it surfaced.

Quick start

import { up } from "perry/compose";

async function bringUpSimpleStack(): Promise<void> {
    const stack = await up({
        version: "3.8",
        services: {
            cache: {
                image: "redis:7-alpine",
                ports: ["6379:6379"],
                networks: ["app-net"],
                healthcheck: {
                    test: ["CMD", "redis-cli", "PING"],
                    interval: "5s",
                    timeout: "3s",
                    retries: 6,
                },
            },
        },
        networks: {
            "app-net": { driver: "bridge" },
        },
    });
    // `stack` is an opaque handle (NaN-boxed integer) — pass it as
    // the first arg to `down` / `ps` / `logs` / `exec`.
    console.log(`stack handle: ${String(stack)}`);
}
import { down } from "perry/compose";

async function tearDown(stack: number): Promise<void> {
    // Default: containers + networks removed; named volumes preserved
    // so a subsequent `up()` against the same spec resumes from
    // committed state.
    await down(stack);

    // Pass `volumes: true` to also drop named volumes — DESTROYS DATA.
    // Useful for test teardown or for a "rip and replace" redeploy.
    await down(stack, { volumes: true });
}

See the linked pages above for the full API surface, production patterns, and case studies.

Utilities

Perry natively implements common utility packages.

lodash

The lodash runtime functions are partially implemented (see crates/perry-stdlib/src/lodash.rs) but the user-facing dispatch from import _ from "lodash"; _.chunk(...) is not wired into the LLVM backend yet. Track the follow-up at issue #200.

import _ from "lodash";

_.chunk([1, 2, 3, 4, 5], 2);     // [[1,2], [3,4], [5]]
_.uniq([1, 2, 2, 3, 3]);          // [1, 2, 3]
_.groupBy(users, "role");
_.sortBy(users, ["name"]);
_.cloneDeep(obj);
_.merge(defaults, overrides);
_.debounce(fn, 300);
_.throttle(fn, 100);

dayjs

dayjs runtime functions are declared (js_dayjs_now, js_dayjs_format, js_dayjs_add, etc.) but the user-facing dispatch from import dayjs from "dayjs"; dayjs() chained methods is not wired into the LLVM backend yet. Track the follow-up at issue #200.

import dayjs from "dayjs";

const now = dayjs();
console.log(now.format("YYYY-MM-DD"));
console.log(now.add(7, "day").format("YYYY-MM-DD"));
console.log(now.subtract(1, "month").toISOString());

const diff = dayjs("2025-12-31").diff(now, "day");
console.log(`${diff} days until end of year`);

moment

Same status as dayjs — the runtime functions exist but the dispatch path is not wired yet.

import moment from "moment";

const now = moment();
console.log(now.format("MMMM Do YYYY"));
console.log(now.fromNow());
console.log(moment("2025-01-01").isBefore(now));

uuid

import { v4 as uuidv4 } from "uuid"

const id = uuidv4()
console.log(id) // e.g., "550e8400-e29b-41d4-a716-446655440000"

nanoid

The default-length nanoid() call is wired. The custom-length form nanoid(10) has a runtime function (js_nanoid_sized) but no dispatch yet — track at issue #200.

import { nanoid } from "nanoid"

const nid = nanoid() // Default 21 chars
console.log(nid)

slugify

The single-arg form is wired. The options-object form slugify("Hello World!", { lower: true }) has a runtime function (js_slugify_with_options) but no dispatch yet — track at issue #200.

import slugify from "slugify"

const slug = slugify("Hello World!")
console.log(slug) // "hello-world"

validator

import validator from "validator"

console.log(validator.isEmail("test@example.com"))  // true
console.log(validator.isURL("https://example.com")) // true
console.log(validator.isUUID(id))                   // true
console.log(validator.isEmpty(""))                  // true

Next Steps

Other Modules

Additional npm packages and Node.js APIs supported by Perry. All listed here are wired through Perry’s well-known native bindings registry (#466) and compile to native code with no JavaScript runtime involvement.

sharp (Image Processing)

Native bindings via perry-ext-sharp (v0.5.551). Resizes, format conversion, and buffer/file output all work.

import sharp from "sharp";

const buf = await sharp("input.jpg")
  .resize(1600, 900)
  .jpeg({ quality: 80 })
  .toBuffer();

await sharp("input.png")
  .resize(300, 200)
  .toFile("output.png");

cheerio (HTML Parsing)

Native bindings via perry-ext-cheerio (v0.5.550).

import * as cheerio from "cheerio";

const html = "<html><body><h1>Hello</h1><p>World</p></body></html>";
const $ = cheerio.load(html);
console.log($("h1").text()); // "Hello"

nodemailer (Email)

import nodemailer from "nodemailer"

async function nodemailerExample(): Promise<void> {
    const transporter = nodemailer.createTransport({
        host: "smtp.example.com",
        port: 587,
        auth: { user: "user", pass: "pass" },
    })

    await transporter.sendMail({
        from: "sender@example.com",
        to: "recipient@example.com",
        subject: "Hello from Perry",
        text: "This email was sent from a compiled TypeScript binary!",
    })
}

zlib (Compression)

Native bindings via perry-ext-zlib (v0.5.541).

import zlib from "zlib";

const compressed = zlib.gzipSync("Hello, World!");
const decompressed = zlib.gunzipSync(compressed);
console.log(decompressed.toString()); // "Hello, World!"

cron / node-cron (Job Scheduling)

Native bindings via perry-ext-cron (v0.5.564). Both cron and node-cron package names route to the same backend.

import { CronJob } from "cron";

const job = new CronJob("*/5 * * * *", () => {
  console.log("Runs every 5 minutes");
});
job.start();

ethers (Ethereum)

Native bindings via perry-ext-ethers (v0.5.556) — backed by ethers-rs-style ABI plumbing through perry-ffi’s BigInt + Buffer surfaces.

import { ethers } from "ethers";

const wallet = ethers.Wallet.createRandom();
console.log("address:", wallet.address);
console.log("private key:", wallet.privateKey);

events (EventEmitter)

Native bindings via perry-ext-events (v0.5.546). The EventEmitter shape matches Node.js — on, off, once, emit, removeAllListeners.

import { EventEmitter } from "events";

const ee = new EventEmitter();
ee.on("data", (chunk) => console.log("got:", chunk));
ee.emit("data", "hello");

exponential-backoff (Retry Logic)

Native bindings via perry-ext-exponential-backoff (v0.5.542).

import { backOff } from "exponential-backoff";

const result = await backOff(() => fetchUnstableEndpoint(), {
  numOfAttempts: 5,
  startingDelay: 200,
  timeMultiple: 2,
});

decimal.js / bignumber.js (Arbitrary Precision)

Native bindings via perry-ext-decimal (v0.5.547). Both package names route to the same backend — Decimal and BigNumber are both exposed.

import Decimal from "decimal.js"

function decimalExample(): void {
    const a = new Decimal("0.1")
    const b = new Decimal("0.2")
    const sum = a.plus(b) // Exactly 0.3 (no floating point errors)

    console.log(sum.toFixed(2))      // "0.30"
    console.log(sum.toNumber())      // 0.3
    console.log(a.times(b).toFixed(2)) // "0.02"
    console.log(a.div(b).toFixed(1))   // "0.5"
    console.log(a.pow(10).toString())  // 1e-10
    console.log(a.sqrt().toFixed(3))   // "0.316"
}

dayjs / date-fns (Date Manipulation)

Native bindings via perry-ext-dayjs (v0.5.548). Both package names route to the same Rust backend — same parse/format/diff surface.

import dayjs from "dayjs";

const now = dayjs();
const tomorrow = now.add(1, "day");
console.log(tomorrow.format("YYYY-MM-DD"));

moment (Legacy Date)

Native bindings via perry-ext-moment (v0.5.549). moment is in maintenance mode upstream — prefer dayjs for new code, but Perry supports both for existing codebases.

import moment from "moment";

const m = moment().add(7, "days");
console.log(m.format());

rate-limiter-flexible

Native bindings via perry-ext-ratelimit (v0.5.552). In-memory limiter is wired; Redis / cluster backing stores are follow-ups.

import { RateLimiterMemory } from "rate-limiter-flexible";

const limiter = new RateLimiterMemory({ points: 5, duration: 1 });
try {
  await limiter.consume("ip-1.2.3.4");
} catch (rateLimitErr) {
  console.warn("blocked:", rateLimitErr);
}

worker_threads

Partially recognized at HIR-lowering time (parentPort / Worker shapes) but full dispatch is incomplete. For data-parallel work today, prefer parallelMap / parallelFilter / spawn from perry/thread (see Threading).

import { Worker, parentPort, workerData } from "worker_threads";

if (parentPort) {
  // Worker thread
  const data = workerData;
  parentPort.postMessage({ result: data.value * 2 });
} else {
  // Main thread
  const worker = new Worker("./worker.ts", {
    workerData: { value: 21 },
  });
  worker.on("message", (msg) => {
    console.log(msg.result); // 42
  });
}

commander (CLI Parsing)

import { Command } from "commander"

function commanderExample(): void {
    const program = new Command()
    program.name("my-cli").version("1.0.0").description("My CLI tool")

    program
        .command("serve")
        .option("-p, --port <number>", "Port number")
        .option("--verbose", "Verbose output")
        .action((options: any) => {
            console.log(`Starting server on port ${options.port}`)
        })

    program.parse(process.argv)
}

lru-cache

The wired constructor takes the npm v7+ options-object shape (new LRUCache({ max: 100 })) — the older positional form new LRUCache(100) falls through to a max=100 default.

import { LRUCache } from "lru-cache"

function lruCacheExample(): void {
    const cache = new LRUCache({ max: 100 }) // max 100 entries

    cache.set("key", "value")
    console.log(cache.get("key"))   // "value"
    console.log(cache.has("key"))   // true
    cache.delete("key")
    cache.clear()
}

child_process

import { spawnBackground, getProcessStatus, killProcess } from "child_process"

function childProcessExample(): void {
    // Spawn a background process
    const { pid, handleId } = spawnBackground("sleep", ["10"], "/tmp/log.txt")

    // Check if it's still running
    const status = getProcessStatus(handleId)
    console.log(status.alive) // true
    console.log(`pid=${pid}`)

    // Kill it
    killProcess(handleId)
}

External native bindings

Two packages live in their own GitHub repos with their own semver — they’re imported by bun add like any npm package, but Rust-backed and compiled natively via perry-ffi:

Pure-TypeScript drivers compiled via compilePackages:

  • @perryts/postgres, @perryts/mysql, @perryts/mongodb, @perryts/redis — wire-protocol clients that also run on Node.js and Bun unchanged.

See Native Bindings — Overview for the contract these external packages follow.

Next Steps

Supported API Reference

This page is auto-generated from Perry’s compile-time API manifest (perry-api-manifest::API_MANIFEST). It is the source of truth for what perry compile accepts; references to symbols not listed here produce R005 UnimplementedApi (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target.

Total: 2781 entries across 114 modules.

Modules


@perryts/pdf

Methods

  • createPdf — module
  • pdfAddLine — module
  • pdfAddText — module
  • pdfNewPage — module
  • pdfSave — module

__disposable__

Methods

  • adopt — instance
  • defer — instance
  • dispose — instance
  • disposeAsync — instance
  • disposed — instance
  • move — instance
  • use — instance

argon2

Methods

  • hash — module
  • verify — module

assert

Classes

  • Assert
  • AssertionError

Methods

  • deepEqual — module
  • deepStrictEqual — module
  • default — module
  • doesNotMatch — module
  • doesNotReject — module
  • doesNotThrow — module
  • equal — module
  • fail — module
  • ifError — module
  • match — module
  • notDeepEqual — module
  • notDeepStrictEqual — module
  • notEqual — module
  • notStrictEqual — module
  • ok — module
  • partialDeepStrictEqual — module
  • rejects — module
  • strict — module
  • strictEqual — module
  • throws — module

Properties

  • strict

assert/strict

Classes

  • Assert
  • AssertionError

Methods

  • deepEqual — module
  • deepStrictEqual — module
  • default — module
  • doesNotMatch — module
  • doesNotReject — module
  • doesNotThrow — module
  • equal — module
  • fail — module
  • ifError — module
  • match — module
  • notDeepEqual — module
  • notDeepStrictEqual — module
  • notEqual — module
  • notStrictEqual — module
  • ok — module
  • partialDeepStrictEqual — module
  • rejects — module
  • strict — module
  • strictEqual — module
  • throws — module

Properties

  • strict

async_hooks

Classes

  • AsyncLocalStorage
  • AsyncResource

Methods

  • asyncId — instance (class: AsyncResource)
  • bind — module (class: AsyncLocalStorage)
  • bind — module (class: AsyncResource)
  • bind — instance (class: AsyncResource)
  • createHook — module
  • disable — instance
  • emitDestroy — instance (class: AsyncResource)
  • enable — instance (class: AsyncHook)
  • enterWith — instance
  • executionAsyncId — module
  • executionAsyncResource — module
  • exit — instance
  • getStore — instance
  • run — instance
  • runInAsyncScope — instance (class: AsyncResource)
  • snapshot — module (class: AsyncLocalStorage)
  • triggerAsyncId — module
  • triggerAsyncId — instance (class: AsyncResource)

Properties

  • asyncWrapProviders
  • default

axios

Methods

  • all — module
  • create — module
  • default — module
  • delete — module
  • get — module
  • head — module
  • options — module
  • patch — module
  • post — module
  • put — module
  • request — module

bcrypt

Methods

  • compare — module
  • hash — module

better-sqlite3

Methods

  • all — instance
  • close — instance
  • columns — instance
  • default — module
  • exec — instance
  • get — instance
  • iterate — instance
  • pluck — instance
  • pragma — instance
  • prepare — instance
  • raw — instance
  • run — instance
  • transaction — instance

bignumber.js

Classes

  • BigNumber

buffer

Classes

  • Blob
  • Buffer
  • File

Methods

  • atob — module
  • btoa — module
  • isAscii — module
  • isUtf8 — module
  • resolveObjectURL — module
  • transcode — module

Properties

  • INSPECT_MAX_BYTES
  • constants
  • kMaxLength
  • kStringMaxLength

cheerio

Methods

  • attr — instance
  • children — instance
  • eq — instance
  • find — instance
  • first — instance
  • hasClass — instance
  • html — instance
  • last — instance
  • length — instance
  • load — module
  • parent — instance
  • select — instance
  • text — instance

child_process

Classes

  • ChildProcess

Methods

  • _forkChild — module
  • exec — module
  • execFile — module
  • execFileSync — module
  • execSync — module
  • fork — module
  • spawn — module
  • spawnSync — module

Properties

  • default

cluster

Classes

  • Worker

Methods

  • disconnect — module
  • fork — module
  • setupMaster — module
  • setupPrimary — module

Properties

  • SCHED_NONE
  • SCHED_RR
  • default
  • isMaster
  • isPrimary
  • isWorker
  • schedulingPolicy
  • settings
  • workers

commander

Methods

  • action — instance
  • command — instance
  • description — instance
  • name — instance
  • option — instance
  • opts — instance
  • parse — instance
  • requiredOption — instance
  • version — instance

console

Classes

  • Console

Methods

  • assert — module
  • clear — module
  • context — module
  • count — module
  • countReset — module
  • createTask — module
  • debug — module
  • dir — module
  • dirxml — module
  • error — module
  • group — module
  • groupCollapsed — module
  • groupEnd — module
  • info — module
  • log — module
  • profile — module
  • profileEnd — module
  • table — module
  • time — module
  • timeEnd — module
  • timeLog — module
  • timeStamp — module
  • trace — module
  • warn — module

constants

Properties

  • COPYFILE_EXCL
  • COPYFILE_FICLONE
  • COPYFILE_FICLONE_FORCE
  • DH_CHECK_P_NOT_PRIME
  • DH_CHECK_P_NOT_SAFE_PRIME
  • DH_NOT_SUITABLE_GENERATOR
  • DH_UNABLE_TO_CHECK_GENERATOR
  • E2BIG
  • EACCES
  • EADDRINUSE
  • EADDRNOTAVAIL
  • EAFNOSUPPORT
  • EAGAIN
  • EALREADY
  • EBADF
  • EBADMSG
  • EBUSY
  • ECANCELED
  • ECHILD
  • ECONNABORTED
  • ECONNREFUSED
  • ECONNRESET
  • EDEADLK
  • EDESTADDRREQ
  • EDOM
  • EDQUOT
  • EEXIST
  • EFAULT
  • EFBIG
  • EHOSTUNREACH
  • EIDRM
  • EILSEQ
  • EINPROGRESS
  • EINTR
  • EINVAL
  • EIO
  • EISCONN
  • EISDIR
  • ELOOP
  • EMFILE
  • EMLINK
  • EMSGSIZE
  • EMULTIHOP
  • ENAMETOOLONG
  • ENETDOWN
  • ENETRESET
  • ENETUNREACH
  • ENFILE
  • ENGINE_METHOD_ALL
  • ENGINE_METHOD_CIPHERS
  • ENGINE_METHOD_DH
  • ENGINE_METHOD_DIGESTS
  • ENGINE_METHOD_DSA
  • ENGINE_METHOD_EC
  • ENGINE_METHOD_NONE
  • ENGINE_METHOD_PKEY_ASN1_METHS
  • ENGINE_METHOD_PKEY_METHS
  • ENGINE_METHOD_RAND
  • ENGINE_METHOD_RSA
  • ENOBUFS
  • ENODATA
  • ENODEV
  • ENOENT
  • ENOEXEC
  • ENOLCK
  • ENOLINK
  • ENOMEM
  • ENOMSG
  • ENOPROTOOPT
  • ENOSPC
  • ENOSR
  • ENOSTR
  • ENOSYS
  • ENOTCONN
  • ENOTDIR
  • ENOTEMPTY
  • ENOTSOCK
  • ENOTSUP
  • ENOTTY
  • ENXIO
  • EOPNOTSUPP
  • EOVERFLOW
  • EPERM
  • EPIPE
  • EPROTO
  • EPROTONOSUPPORT
  • EPROTOTYPE
  • ERANGE
  • EROFS
  • ESPIPE
  • ESRCH
  • ESTALE
  • ETIME
  • ETIMEDOUT
  • ETXTBSY
  • EWOULDBLOCK
  • EXDEV
  • F_OK
  • OPENSSL_VERSION_NUMBER
  • O_APPEND
  • O_CREAT
  • O_DIRECT
  • O_DIRECTORY
  • O_DSYNC
  • O_EXCL
  • O_NOATIME
  • O_NOCTTY
  • O_NOFOLLOW
  • O_NONBLOCK
  • O_RDONLY
  • O_RDWR
  • O_SYMLINK
  • O_SYNC
  • O_TRUNC
  • O_WRONLY
  • POINT_CONVERSION_COMPRESSED
  • POINT_CONVERSION_HYBRID
  • POINT_CONVERSION_UNCOMPRESSED
  • PRIORITY_ABOVE_NORMAL
  • PRIORITY_BELOW_NORMAL
  • PRIORITY_HIGH
  • PRIORITY_HIGHEST
  • PRIORITY_LOW
  • PRIORITY_NORMAL
  • RSA_NO_PADDING
  • RSA_PKCS1_OAEP_PADDING
  • RSA_PKCS1_PADDING
  • RSA_PKCS1_PSS_PADDING
  • RSA_PSS_SALTLEN_AUTO
  • RSA_PSS_SALTLEN_DIGEST
  • RSA_PSS_SALTLEN_MAX_SIGN
  • RSA_X931_PADDING
  • RTLD_DEEPBIND
  • RTLD_GLOBAL
  • RTLD_LAZY
  • RTLD_LOCAL
  • RTLD_NOW
  • R_OK
  • SIGABRT
  • SIGALRM
  • SIGBUS
  • SIGCHLD
  • SIGCONT
  • SIGFPE
  • SIGHUP
  • SIGILL
  • SIGINFO
  • SIGINT
  • SIGIO
  • SIGIOT
  • SIGKILL
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGQUIT
  • SIGSEGV
  • SIGSTKFLT
  • SIGSTOP
  • SIGSYS
  • SIGTERM
  • SIGTRAP
  • SIGTSTP
  • SIGTTIN
  • SIGTTOU
  • SIGURG
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM
  • SIGWINCH
  • SIGXCPU
  • SIGXFSZ
  • SSL_OP_ALL
  • SSL_OP_ALLOW_NO_DHE_KEX
  • SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
  • SSL_OP_CIPHER_SERVER_PREFERENCE
  • SSL_OP_CISCO_ANYCONNECT
  • SSL_OP_COOKIE_EXCHANGE
  • SSL_OP_CRYPTOPRO_TLSEXT_BUG
  • SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS
  • SSL_OP_LEGACY_SERVER_CONNECT
  • SSL_OP_NO_COMPRESSION
  • SSL_OP_NO_ENCRYPT_THEN_MAC
  • SSL_OP_NO_QUERY_MTU
  • SSL_OP_NO_RENEGOTIATION
  • SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION
  • SSL_OP_NO_SSLv2
  • SSL_OP_NO_SSLv3
  • SSL_OP_NO_TICKET
  • SSL_OP_NO_TLSv1
  • SSL_OP_NO_TLSv1_1
  • SSL_OP_NO_TLSv1_2
  • SSL_OP_NO_TLSv1_3
  • SSL_OP_PRIORITIZE_CHACHA
  • SSL_OP_TLS_ROLLBACK_BUG
  • S_IFBLK
  • S_IFCHR
  • S_IFDIR
  • S_IFIFO
  • S_IFLNK
  • S_IFMT
  • S_IFREG
  • S_IFSOCK
  • S_IRGRP
  • S_IROTH
  • S_IRUSR
  • S_IRWXG
  • S_IRWXO
  • S_IRWXU
  • S_IWGRP
  • S_IWOTH
  • S_IWUSR
  • S_IXGRP
  • S_IXOTH
  • S_IXUSR
  • TLS1_1_VERSION
  • TLS1_2_VERSION
  • TLS1_3_VERSION
  • TLS1_VERSION
  • UV_DIRENT_BLOCK
  • UV_DIRENT_CHAR
  • UV_DIRENT_DIR
  • UV_DIRENT_FIFO
  • UV_DIRENT_FILE
  • UV_DIRENT_LINK
  • UV_DIRENT_SOCKET
  • UV_DIRENT_UNKNOWN
  • UV_FS_COPYFILE_EXCL
  • UV_FS_COPYFILE_FICLONE
  • UV_FS_COPYFILE_FICLONE_FORCE
  • UV_FS_O_FILEMAP
  • UV_FS_SYMLINK_DIR
  • UV_FS_SYMLINK_JUNCTION
  • W_OK
  • X_OK
  • default
  • defaultCoreCipherList

cron

Methods

  • describe — module
  • isRunning — instance
  • nextDate — instance
  • schedule — module
  • start — instance
  • stop — instance
  • validate — module

crypto

Classes

  • Cipheriv
  • Decipheriv
  • DiffieHellman
  • DiffieHellmanGroup
  • ECDH
  • KeyObject
  • X509Certificate

Methods

  • Hash — module
  • Hmac — module
  • Sign — module
  • Verify — module
  • argon2 — module
  • argon2Sync — module
  • checkPrime — module
  • checkPrimeSync — module
  • createCipheriv — module
  • createDecipheriv — module
  • createDiffieHellman — module
  • createDiffieHellmanGroup — module
  • createECDH — module
  • createHash — module
  • createHmac — module
  • createPrivateKey — module
  • createPublicKey — module
  • createSecretKey — module
  • createSign — module
  • createSign — module
  • createVerify — module
  • createVerify — module
  • decapsulate — module
  • diffieHellman — module
  • encapsulate — module
  • generateKey — module
  • generateKeyPair — module
  • generateKeyPairSync — module
  • generateKeyPairSync — module
  • generateKeySync — module
  • generatePrime — module
  • generatePrimeSync — module
  • getCipherInfo — module
  • getCiphers — module
  • getCurves — module
  • getDiffieHellman — module
  • getFips — module
  • getHashes — module
  • getRandomValues — module
  • hash — module
  • hkdf — module
  • hkdfSync — module
  • pbkdf2 — module
  • pbkdf2Sync — module
  • privateDecrypt — module
  • privateEncrypt — module
  • publicDecrypt — module
  • publicEncrypt — module
  • randomBytes — module
  • randomFill — module
  • randomFillSync — module
  • randomInt — module
  • randomInt — module
  • randomUUID — module
  • scrypt — module
  • scryptSync — module
  • secureHeapUsed — module
  • setFips — module
  • sign — module
  • timingSafeEqual — module
  • verify — module

Properties

  • Certificate
  • constants
  • subtle
  • webcrypto

date-fns

Methods

  • addDays — module
  • addMonths — module
  • addYears — module
  • differenceInDays — module
  • differenceInHours — module
  • differenceInMinutes — module
  • endOfDay — module
  • format — module
  • isAfter — module
  • isBefore — module
  • parseISO — module
  • startOfDay — module

dayjs

Methods

  • add — instance
  • clone — instance
  • date — instance
  • day — instance
  • dayjs — module
  • default — module
  • diff — instance
  • endOf — instance
  • format — instance
  • hour — instance
  • isAfter — instance
  • isBefore — instance
  • isSame — instance
  • isValid — instance
  • millisecond — instance
  • minute — instance
  • month — instance
  • second — instance
  • startOf — instance
  • subtract — instance
  • toISOString — instance
  • unix — instance
  • valueOf — instance
  • year — instance

decimal.js

Methods

  • abs — instance
  • ceil — instance
  • cmp — instance
  • div — instance
  • eq — instance
  • floor — instance
  • gt — instance
  • gte — instance
  • isNegative — instance
  • isPositive — instance
  • isZero — instance
  • lt — instance
  • lte — instance
  • minus — instance
  • mod — instance
  • neg — instance
  • plus — instance
  • pow — instance
  • round — instance
  • sqrt — instance
  • times — instance
  • toFixed — instance
  • toNumber — instance
  • toString — instance
  • valueOf — instance

dgram

Classes

  • Socket

Methods

  • Socket — module
  • addListener — instance (class: Socket)
  • addMembership — instance (class: Socket)
  • addSourceSpecificMembership — instance (class: Socket)
  • address — instance (class: Socket)
  • bind — instance (class: Socket)
  • close — instance (class: Socket)
  • connect — instance (class: Socket)
  • createSocket — module
  • disconnect — instance (class: Socket)
  • dropMembership — instance (class: Socket)
  • dropSourceSpecificMembership — instance (class: Socket)
  • emit — instance (class: Socket)
  • eventNames — instance (class: Socket)
  • getRecvBufferSize — instance (class: Socket)
  • getSendBufferSize — instance (class: Socket)
  • getSendQueueCount — instance (class: Socket)
  • getSendQueueSize — instance (class: Socket)
  • listenerCount — instance (class: Socket)
  • off — instance (class: Socket)
  • on — instance (class: Socket)
  • once — instance (class: Socket)
  • ref — instance (class: Socket)
  • remoteAddress — instance (class: Socket)
  • removeListener — instance (class: Socket)
  • send — instance (class: Socket)
  • setBroadcast — instance (class: Socket)
  • setMulticastInterface — instance (class: Socket)
  • setMulticastLoopback — instance (class: Socket)
  • setMulticastTTL — instance (class: Socket)
  • setRecvBufferSize — instance (class: Socket)
  • setSendBufferSize — instance (class: Socket)
  • setTTL — instance (class: Socket)
  • unref — instance (class: Socket)

Properties

  • default

diagnostics_channel

Classes

  • BoundedChannel
  • Channel

Methods

  • boundedChannel — module
  • channel — module
  • hasSubscribers — module
  • subscribe — module
  • tracingChannel — module
  • unsubscribe — module

Properties

  • default

dns

Classes

  • Resolver

Methods

  • Resolver — module
  • cancel — instance (class: Resolver)
  • getDefaultResultOrder — module
  • getServers — module
  • getServers — instance (class: Resolver)
  • lookup — module
  • lookupService — module
  • resolve — module
  • resolve — instance (class: Resolver)
  • resolve4 — module
  • resolve4 — instance (class: Resolver)
  • resolve6 — module
  • resolve6 — instance (class: Resolver)
  • resolveAny — module
  • resolveAny — instance (class: Resolver)
  • resolveCaa — module
  • resolveCaa — instance (class: Resolver)
  • resolveCname — module
  • resolveCname — instance (class: Resolver)
  • resolveMx — module
  • resolveMx — instance (class: Resolver)
  • resolveNaptr — module
  • resolveNaptr — instance (class: Resolver)
  • resolveNs — module
  • resolveNs — instance (class: Resolver)
  • resolvePtr — module
  • resolvePtr — instance (class: Resolver)
  • resolveSoa — module
  • resolveSoa — instance (class: Resolver)
  • resolveSrv — module
  • resolveSrv — instance (class: Resolver)
  • resolveTlsa — module
  • resolveTlsa — instance (class: Resolver)
  • resolveTxt — module
  • resolveTxt — instance (class: Resolver)
  • reverse — module
  • reverse — instance (class: Resolver)
  • setDefaultResultOrder — module
  • setLocalAddress — instance (class: Resolver)
  • setServers — module
  • setServers — instance (class: Resolver)

Properties

  • ADDRCONFIG
  • ADDRCONFIG
  • ADDRGETNETWORKPARAMS
  • ADDRGETNETWORKPARAMS
  • ALL
  • ALL
  • BADFAMILY
  • BADFAMILY
  • BADFLAGS
  • BADFLAGS
  • BADHINTS
  • BADHINTS
  • BADNAME
  • BADNAME
  • BADQUERY
  • BADQUERY
  • BADRESP
  • BADRESP
  • BADSTR
  • BADSTR
  • CANCELLED
  • CANCELLED
  • CONNREFUSED
  • CONNREFUSED
  • DESTRUCTION
  • DESTRUCTION
  • EOF
  • EOF
  • FILE
  • FILE
  • FORMERR
  • FORMERR
  • LOADIPHLPAPI
  • LOADIPHLPAPI
  • NODATA
  • NODATA
  • NOMEM
  • NOMEM
  • NONAME
  • NONAME
  • NOTFOUND
  • NOTFOUND
  • NOTIMP
  • NOTIMP
  • NOTINITIALIZED
  • NOTINITIALIZED
  • REFUSED
  • REFUSED
  • SERVFAIL
  • SERVFAIL
  • TIMEOUT
  • TIMEOUT
  • V4MAPPED
  • V4MAPPED
  • default
  • promises

dns/promises

Classes

  • Resolver

Methods

  • Resolver — module
  • cancel — instance (class: Resolver)
  • getDefaultResultOrder — module
  • getServers — module
  • getServers — instance (class: Resolver)
  • lookup — module
  • lookupService — module
  • resolve — module
  • resolve — instance (class: Resolver)
  • resolve4 — module
  • resolve4 — instance (class: Resolver)
  • resolve6 — module
  • resolve6 — instance (class: Resolver)
  • resolveAny — module
  • resolveAny — instance (class: Resolver)
  • resolveCaa — module
  • resolveCaa — instance (class: Resolver)
  • resolveCname — module
  • resolveCname — instance (class: Resolver)
  • resolveMx — module
  • resolveMx — instance (class: Resolver)
  • resolveNaptr — module
  • resolveNaptr — instance (class: Resolver)
  • resolveNs — module
  • resolveNs — instance (class: Resolver)
  • resolvePtr — module
  • resolvePtr — instance (class: Resolver)
  • resolveSoa — module
  • resolveSoa — instance (class: Resolver)
  • resolveSrv — module
  • resolveSrv — instance (class: Resolver)
  • resolveTlsa — module
  • resolveTlsa — instance (class: Resolver)
  • resolveTxt — module
  • resolveTxt — instance (class: Resolver)
  • reverse — module
  • reverse — instance (class: Resolver)
  • setDefaultResultOrder — module
  • setLocalAddress — instance (class: Resolver)
  • setServers — module
  • setServers — instance (class: Resolver)

Properties

  • ADDRGETNETWORKPARAMS
  • BADFAMILY
  • BADFLAGS
  • BADHINTS
  • BADNAME
  • BADQUERY
  • BADRESP
  • BADSTR
  • CANCELLED
  • CONNREFUSED
  • DESTRUCTION
  • EOF
  • FILE
  • FORMERR
  • LOADIPHLPAPI
  • NODATA
  • NOMEM
  • NONAME
  • NOTFOUND
  • NOTIMP
  • NOTINITIALIZED
  • REFUSED
  • SERVFAIL
  • TIMEOUT
  • default

domain

Classes

  • Domain

Methods

  • Domain — module
  • add — instance
  • addListener — instance
  • bind — instance
  • create — module
  • createDomain — module
  • emit — instance
  • enter — instance
  • exit — instance
  • intercept — instance
  • on — instance
  • remove — instance
  • run — instance

Properties

  • _stack
  • active
  • members

dotenv

Methods

  • config — module

ethers

Methods

  • createRandom — module (class: Wallet)
  • formatEther — module
  • formatUnits — module
  • getAddress — module
  • parseEther — module
  • parseUnits — module

events

Classes

  • EventEmitter
  • EventEmitterAsyncResource

Methods

  • EventEmitter — module
  • EventEmitterAsyncResource — module
  • addAbortListener — module
  • addListener — instance
  • asyncId — instance (class: EventEmitterAsyncResource)
  • asyncResource — instance (class: EventEmitterAsyncResource)
  • domain — instance
  • emit — instance
  • emitDestroy — instance (class: EventEmitterAsyncResource)
  • eventNames — instance
  • getEventListeners — module
  • getMaxListeners — instance
  • getMaxListeners — module
  • init — module
  • listenerCount — instance
  • listenerCount — module
  • listeners — instance
  • off — instance
  • on — instance
  • on — module
  • once — instance
  • once — module
  • prependListener — instance
  • prependOnceListener — instance
  • rawListeners — instance
  • removeAllListeners — instance
  • removeListener — instance
  • setMaxListeners — instance
  • setMaxListeners — module
  • triggerAsyncId — instance (class: EventEmitterAsyncResource)

Properties

  • captureRejectionSymbol
  • captureRejections
  • default
  • defaultMaxListeners
  • errorMonitor
  • usingDomains

exponential-backoff

Methods

  • backOff — module

fastify

Methods

  • addHook — instance
  • all — instance
  • body — instance
  • close — instance
  • code — instance
  • default — module
  • delete — instance
  • get — instance
  • head — instance
  • header — instance
  • headers — instance
  • html — instance
  • json — instance
  • listen — instance
  • method — instance
  • on — instance
  • options — instance
  • param — instance
  • params — instance
  • patch — instance
  • post — instance
  • put — instance
  • query — instance
  • rawBody — instance
  • redirect — instance
  • register — instance
  • route — instance
  • send — instance
  • server — instance
  • setErrorHandler — instance
  • status — instance
  • text — instance
  • type — instance
  • url — instance
  • user — instance

fetch

Classes

  • Blob
  • FormData
  • Headers
  • Request
  • Response

Methods

  • default — module

fs

Classes

  • Dir
  • Dirent
  • FileReadStream
  • FileWriteStream
  • ReadStream
  • Stats
  • Utf8Stream
  • WriteStream

Methods

  • _toUnixTimestamp — module
  • _toUnixTimestamp — module
  • access — module
  • accessSync — module
  • appendFile — module
  • appendFileSync — module
  • chmod — module
  • chmodSync — module
  • chown — module
  • chownSync — module
  • close — module
  • closeSync — module
  • copyFile — module
  • copyFileSync — module
  • cp — module
  • cpSync — module
  • createReadStream — module
  • createWriteStream — module
  • exists — module
  • existsSync — module
  • fchmod — module
  • fchmodSync — module
  • fchown — module
  • fchownSync — module
  • fdatasync — module
  • fdatasyncSync — module
  • fstat — module
  • fstatSync — module
  • fsync — module
  • fsyncSync — module
  • ftruncate — module
  • ftruncateSync — module
  • futimes — module
  • futimesSync — module
  • glob — module
  • globSync — module
  • lchmod — module
  • lchmodSync — module
  • lchown — module
  • lchownSync — module
  • link — module
  • linkSync — module
  • lstat — module
  • lstatSync — module
  • lutimes — module
  • lutimesSync — module
  • mkdir — module
  • mkdirSync — module
  • mkdtemp — module
  • mkdtempDisposableSync — module
  • mkdtempSync — module
  • open — module
  • openAsBlob — module
  • openSync — module
  • opendir — module
  • opendirSync — module
  • read — module
  • readFile — module
  • readFileSync — module
  • readSync — module
  • readdir — module
  • readdirSync — module
  • readlink — module
  • readlinkSync — module
  • readv — module
  • readvSync — module
  • realpath — module
  • realpathSync — module
  • rename — module
  • renameSync — module
  • rm — module
  • rmSync — module
  • rmdir — module
  • rmdirSync — module
  • stat — module
  • statSync — module
  • statfs — module
  • statfsSync — module
  • symlink — module
  • symlinkSync — module
  • truncate — module
  • truncateSync — module
  • unlink — module
  • unlinkSync — module
  • unwatchFile — module
  • utimes — module
  • utimesSync — module
  • watch — module
  • watchFile — module
  • write — module
  • writeFile — module
  • writeFileSync — module
  • writeSync — module
  • writev — module
  • writevSync — module

Properties

  • constants
  • promises

fs/promises

Methods

  • access — module
  • appendFile — module
  • chmod — module
  • chown — module
  • copyFile — module
  • cp — module
  • glob — module
  • lchmod — module
  • lchown — module
  • link — module
  • lstat — module
  • lutimes — module
  • mkdir — module
  • mkdtemp — module
  • mkdtempDisposable — module
  • open — module
  • opendir — module
  • pull — instance (class: FileHandle)
  • pullSync — instance (class: FileHandle)
  • readFile — module
  • readdir — module
  • readlink — module
  • realpath — module
  • rename — module
  • rm — module
  • rmdir — module
  • stat — module
  • statfs — module
  • symlink — module
  • truncate — module
  • unlink — module
  • utimes — module
  • watch — module
  • writeFile — module
  • writer — instance (class: FileHandle)

Properties

  • constants
  • default

http

Classes

  • Agent
  • ClientRequest
  • IncomingMessage
  • IncomingMessage
  • OutgoingMessage
  • OutgoingMessage
  • Server
  • Server
  • ServerResponse
  • ServerResponse
  • WebSocket

Methods

  • Agent — module
  • Server — module
  • __get_aborted — instance (class: ClientRequest)
  • __get_aborted — instance (class: IncomingMessage)
  • __get_complete — instance (class: IncomingMessage)
  • __get_connection — instance (class: ClientRequest)
  • __get_createConnection — instance (class: Agent)
  • __get_createSocket — instance (class: Agent)
  • __get_defaultPort — instance (class: Agent)
  • __get_destroyed — instance (class: Agent)
  • __get_destroyed — instance (class: ClientRequest)
  • __get_destroyed — instance (class: IncomingMessage)
  • __get_finished — instance (class: ClientRequest)
  • __get_freeSockets — instance (class: Agent)
  • __get_headers — instance (class: IncomingMessage)
  • __get_headersSent — instance (class: ServerResponse)
  • __get_headersTimeout — instance (class: HttpServer)
  • __get_host — instance (class: ClientRequest)
  • __get_httpVersion — instance (class: IncomingMessage)
  • __get_keepAlive — instance (class: Agent)
  • __get_keepAliveMsecs — instance (class: Agent)
  • __get_keepAliveTimeout — instance (class: HttpServer)
  • __get_keepAliveTimeoutBuffer — instance (class: HttpServer)
  • __get_listening — instance (class: HttpServer)
  • __get_maxFreeSockets — instance (class: Agent)
  • __get_maxHeadersCount — instance (class: HttpServer)
  • __get_maxHeadersCount — instance (class: ClientRequest)
  • __get_maxRequestsPerSocket — instance (class: HttpServer)
  • __get_maxSockets — instance (class: Agent)
  • __get_maxTotalSockets — instance (class: Agent)
  • __get_method — instance (class: ClientRequest)
  • __get_method — instance (class: IncomingMessage)
  • __get_path — instance (class: ClientRequest)
  • __get_protocol — instance (class: Agent)
  • __get_protocol — instance (class: ClientRequest)
  • __get_requestTimeout — instance (class: HttpServer)
  • __get_requests — instance (class: Agent)
  • __get_reusedSocket — instance (class: ClientRequest)
  • __get_socket — instance (class: ClientRequest)
  • __get_sockets — instance (class: Agent)
  • __get_statusCode — instance (class: IncomingMessage)
  • __get_statusCode — instance (class: ServerResponse)
  • __get_statusMessage — instance (class: IncomingMessage)
  • __get_timeout — instance (class: HttpServer)
  • __get_trailers — instance (class: IncomingMessage)
  • __get_url — instance (class: IncomingMessage)
  • __get_writableEnded — instance (class: ClientRequest)
  • __get_writableEnded — instance (class: ServerResponse)
  • __get_writableFinished — instance (class: ClientRequest)
  • __get_writableFinished — instance (class: ServerResponse)
  • __set_createConnection — instance (class: Agent)
  • __set_createSocket — instance (class: Agent)
  • __set_headersTimeout — instance (class: HttpServer)
  • __set_keepAlive — instance (class: Agent)
  • __set_keepAliveMsecs — instance (class: Agent)
  • __set_keepAliveTimeout — instance (class: HttpServer)
  • __set_keepAliveTimeoutBuffer — instance (class: HttpServer)
  • __set_maxFreeSockets — instance (class: Agent)
  • __set_maxHeadersCount — instance (class: HttpServer)
  • __set_maxRequestsPerSocket — instance (class: HttpServer)
  • __set_maxSockets — instance (class: Agent)
  • __set_maxTotalSockets — instance (class: Agent)
  • __set_protocol — instance (class: Agent)
  • __set_requestTimeout — instance (class: HttpServer)
  • __set_sendDate — instance (class: ServerResponse)
  • __set_statusCode — instance (class: ServerResponse)
  • __set_statusMessage — instance (class: ServerResponse)
  • __set_strictContentLength — instance (class: ServerResponse)
  • __set_timeout — instance (class: HttpServer)
  • _connectionListener — module
  • abort — instance (class: ClientRequest)
  • addListener — instance (class: HttpServer)
  • addListener — instance (class: IncomingMessage)
  • addListener — instance (class: ServerResponse)
  • addTrailers — instance (class: ServerResponse)
  • address — instance (class: HttpServer)
  • appendHeader — instance (class: ServerResponse)
  • close — instance (class: Agent)
  • close — instance (class: HttpServer)
  • closeAllConnections — instance (class: HttpServer)
  • closeIdleConnections — instance (class: HttpServer)
  • cork — instance (class: ClientRequest)
  • cork — instance (class: ServerResponse)
  • createServer — module
  • createServer — module
  • defaultPort — instance (class: Agent)
  • destroy — instance (class: Agent)
  • destroy — instance (class: IncomingMessage)
  • destroy — instance (class: ClientRequest)
  • destroyed — instance (class: Agent)
  • end — instance (class: ServerResponse)
  • flushHeaders — instance (class: ClientRequest)
  • flushHeaders — instance (class: ServerResponse)
  • freeSockets — instance (class: Agent)
  • get — module
  • getHeader — instance (class: ClientRequest)
  • getHeader — instance (class: ServerResponse)
  • getHeaderNames — instance (class: ClientRequest)
  • getHeaderNames — instance (class: ServerResponse)
  • getHeaders — instance (class: ClientRequest)
  • getHeaders — instance (class: ServerResponse)
  • getName — instance (class: Agent)
  • getRawHeaderNames — instance (class: ClientRequest)
  • getStatus — instance (class: ServerResponse)
  • hasHeader — instance (class: ClientRequest)
  • hasHeader — instance (class: ServerResponse)
  • headers — instance (class: IncomingMessage)
  • headersTimeout — instance (class: HttpServer)
  • httpVersion — instance (class: IncomingMessage)
  • keepAlive — instance (class: Agent)
  • keepAliveMsecs — instance (class: Agent)
  • keepAliveTimeout — instance (class: HttpServer)
  • keepAliveTimeoutBuffer — instance (class: HttpServer)
  • keepSocketAlive — instance (class: Agent)
  • listen — instance (class: HttpServer)
  • listenerCount — instance (class: ClientRequest)
  • listening — instance (class: HttpServer)
  • maxFreeSockets — instance (class: Agent)
  • maxHeadersCount — instance (class: HttpServer)
  • maxRequestsPerSocket — instance (class: HttpServer)
  • maxSockets — instance (class: Agent)
  • maxTotalSockets — instance (class: Agent)
  • method — instance (class: IncomingMessage)
  • on — instance (class: HttpServer)
  • on — instance (class: IncomingMessage)
  • on — instance (class: ServerResponse)
  • pause — instance (class: IncomingMessage)
  • protocol — instance (class: Agent)
  • read — instance (class: IncomingMessage)
  • removeHeader — instance (class: ClientRequest)
  • removeHeader — instance (class: ServerResponse)
  • request — module
  • requestTimeout — instance (class: HttpServer)
  • requests — instance (class: Agent)
  • resume — instance (class: IncomingMessage)
  • reuseSocket — instance (class: Agent)
  • setEncoding — instance (class: IncomingMessage)
  • setGlobalProxyFromEnv — module
  • setHeader — instance (class: ClientRequest)
  • setHeader — instance (class: ServerResponse)
  • setHeaders — instance (class: ServerResponse)
  • setMaxIdleHTTPParsers — module
  • setNoDelay — instance (class: ClientRequest)
  • setSocketKeepAlive — instance (class: ClientRequest)
  • setStatus — instance (class: ServerResponse)
  • setTimeout — instance (class: HttpServer)
  • setTimeout — instance (class: IncomingMessage)
  • setTimeout — instance (class: ClientRequest)
  • setTimeout — instance (class: ServerResponse)
  • sockets — instance (class: Agent)
  • statusCode — instance (class: IncomingMessage)
  • statusMessage — instance (class: IncomingMessage)
  • timeout — instance (class: HttpServer)
  • trailers — instance (class: IncomingMessage)
  • uncork — instance (class: ClientRequest)
  • uncork — instance (class: ServerResponse)
  • url — instance (class: IncomingMessage)
  • validateHeaderName — module
  • validateHeaderValue — module
  • write — instance (class: ServerResponse)
  • writeContinue — instance (class: ServerResponse)
  • writeEarlyHints — instance (class: ServerResponse)
  • writeHead — instance (class: ServerResponse)
  • writeProcessing — instance (class: ServerResponse)

Properties

  • METHODS
  • STATUS_CODES
  • globalAgent
  • maxHeaderSize

http2

Classes

  • Http2ServerRequest
  • Http2ServerResponse

Methods

  • connect — module
  • createSecureServer — module
  • createServer — module
  • getDefaultSettings — module
  • getPackedSettings — module
  • getUnpackedSettings — module
  • performServerHandshake — module

Properties

  • constants
  • default
  • sensitiveHeaders

https

Classes

  • Agent
  • Server
  • Server

Methods

  • Agent — module
  • Server — module
  • __get_headersTimeout — instance (class: HttpsServer)
  • __get_keepAliveTimeout — instance (class: HttpsServer)
  • __get_keepAliveTimeoutBuffer — instance (class: HttpsServer)
  • __get_listening — instance (class: HttpsServer)
  • __get_maxHeadersCount — instance (class: HttpsServer)
  • __get_maxRequestsPerSocket — instance (class: HttpsServer)
  • __get_requestTimeout — instance (class: HttpsServer)
  • __get_timeout — instance (class: HttpsServer)
  • __set_headersTimeout — instance (class: HttpsServer)
  • __set_keepAliveTimeout — instance (class: HttpsServer)
  • __set_keepAliveTimeoutBuffer — instance (class: HttpsServer)
  • __set_maxHeadersCount — instance (class: HttpsServer)
  • __set_maxRequestsPerSocket — instance (class: HttpsServer)
  • __set_requestTimeout — instance (class: HttpsServer)
  • __set_timeout — instance (class: HttpsServer)
  • addListener — instance (class: HttpsServer)
  • address — instance (class: HttpsServer)
  • close — instance (class: HttpsServer)
  • closeAllConnections — instance (class: HttpsServer)
  • closeIdleConnections — instance (class: HttpsServer)
  • createServer — module
  • createServer — module
  • get — module
  • headersTimeout — instance (class: HttpsServer)
  • keepAliveTimeout — instance (class: HttpsServer)
  • keepAliveTimeoutBuffer — instance (class: HttpsServer)
  • listen — instance (class: HttpsServer)
  • listening — instance (class: HttpsServer)
  • maxHeadersCount — instance (class: HttpsServer)
  • maxRequestsPerSocket — instance (class: HttpsServer)
  • on — instance (class: HttpsServer)
  • request — module
  • requestTimeout — instance (class: HttpsServer)
  • setTimeout — instance (class: HttpsServer)
  • timeout — instance (class: HttpsServer)

Properties

  • globalAgent

inspector

Classes

  • Session

Methods

  • Session — module
  • close — module
  • connect — instance (class: Session)
  • connectToMainThread — instance (class: Session)
  • disconnect — instance (class: Session)
  • on — instance (class: Session)
  • once — instance (class: Session)
  • open — module
  • post — instance (class: Session)
  • url — module
  • waitForDebugger — module

Properties

  • Network
  • console
  • default

inspector/promises

Classes

  • Session

Methods

  • Session — module
  • connect — instance (class: Session)
  • connectToMainThread — instance (class: Session)
  • disconnect — instance (class: Session)
  • on — instance (class: Session)
  • once — instance (class: Session)
  • post — instance (class: Session)

Properties

  • default

ioredis

Classes

  • Redis

Methods

  • connect — instance
  • createClient — module
  • decr — instance
  • del — instance
  • disconnect — instance
  • exists — instance
  • expire — instance
  • get — instance
  • incr — instance
  • quit — instance
  • set — instance

iroh

Methods

  • acceptBi — instance
  • acceptOne — instance
  • bind — module
  • close — instance
  • connClose — instance
  • connect — instance
  • nodeId — instance
  • openBi — instance
  • streamFinish — instance
  • streamReadToEnd — instance
  • streamWrite — instance

jsonwebtoken

Methods

  • decode — module
  • sign — module
  • verify — module

lodash

Methods

  • camelCase — module
  • chunk — module
  • clamp — module
  • clamp — module
  • compact — module
  • drop — module
  • first — module
  • flatten — module
  • head — module
  • inRange — module
  • kebabCase — module
  • last — module
  • max — module
  • maxBy — module
  • mean — module
  • meanBy — module
  • min — module
  • minBy — module
  • random — module
  • range — module
  • reverse — module
  • size — module
  • snakeCase — module
  • sum — module
  • sumBy — module
  • tail — module
  • take — module
  • times — module
  • uniq — module

lru-cache

Methods

  • clear — instance
  • default — module
  • delete — instance
  • get — instance
  • has — instance
  • set — instance
  • size — instance

module

Classes

  • Module
  • SourceMap

Methods

  • Module — module
  • SourceMap — module
  • _findPath — module
  • _initPaths — module
  • _load — module
  • _nodeModulePaths — module
  • _preloadModules — module
  • _resolveFilename — module
  • _resolveLookupPaths — module
  • createRequire — module
  • enableCompileCache — module
  • findPackageJSON — module
  • findSourceMap — module
  • flushCompileCache — module
  • getCompileCacheDir — module
  • getSourceMapsSupport — module
  • isBuiltin — module
  • register — module
  • registerHooks — module
  • runMain — module
  • setSourceMapsSupport — module
  • stripTypeScriptTypes — module
  • syncBuiltinESMExports — module

Properties

  • Module
  • _cache
  • _extensions
  • _pathCache
  • builtinModules
  • constants
  • default
  • globalPaths

moment

Methods

  • default — module
  • moment — module

mongodb

Methods

  • close — instance
  • collection — instance
  • connect — module
  • connect — instance
  • countDocuments — instance
  • db — instance
  • deleteMany — instance
  • deleteOne — instance
  • find — instance
  • findOne — instance
  • insertMany — instance
  • insertOne — instance
  • updateMany — instance
  • updateOne — instance

mysql2

Classes

  • Pool

Methods

  • beginTransaction — instance
  • commit — instance
  • createConnection — module
  • createPool — module
  • end — instance (class: Pool)
  • end — instance
  • execute — instance (class: Pool)
  • execute — instance (class: PoolConnection)
  • execute — instance
  • getConnection — instance
  • query — instance (class: Pool)
  • query — instance (class: PoolConnection)
  • query — instance
  • release — instance
  • rollback — instance

mysql2/promise

Classes

  • Pool

Methods

  • beginTransaction — instance
  • commit — instance
  • createConnection — module
  • createPool — module
  • end — instance (class: Pool)
  • end — instance
  • execute — instance (class: Pool)
  • execute — instance (class: PoolConnection)
  • execute — instance
  • getConnection — instance
  • query — instance (class: Pool)
  • query — instance (class: PoolConnection)
  • query — instance
  • release — instance
  • rollback — instance

nanoid

Methods

  • nanoid — module

net

Classes

  • BlockList
  • Server
  • Socket
  • SocketAddress
  • Stream

Methods

  • BlockList — module
  • Server — module
  • Socket — module
  • SocketAddress — module
  • Stream — module
  • __set_dropMaxConnection — instance (class: Server)
  • __set_maxConnections — instance (class: Server)
  • _createServerHandle — module
  • _normalizeArgs — module
  • addAddress — instance (class: BlockList)
  • addListener — instance (class: Socket)
  • addListener — instance (class: Server)
  • addRange — instance (class: BlockList)
  • addSubnet — instance (class: BlockList)
  • address — instance (class: Socket)
  • address — instance (class: SocketAddress)
  • address — instance (class: Server)
  • autoSelectFamilyAttemptedAddresses — instance (class: Socket)
  • bufferSize — instance (class: Socket)
  • bytesRead — instance (class: Socket)
  • bytesWritten — instance (class: Socket)
  • check — instance (class: BlockList)
  • close — instance (class: Server)
  • connect — module
  • connect — instance (class: Socket)
  • connecting — instance (class: Socket)
  • cork — instance (class: Socket)
  • createConnection — module
  • createServer — module
  • destroy — instance (class: Socket)
  • destroyed — instance (class: Socket)
  • dropMaxConnection — instance (class: Server)
  • end — instance (class: Socket)
  • eventNames — instance (class: Socket)
  • eventNames — instance (class: Server)
  • exportKeyingMaterial — instance (class: Socket)
  • family — instance (class: SocketAddress)
  • flowlabel — instance (class: SocketAddress)
  • fromJSON — instance (class: BlockList)
  • getCertificate — instance (class: Socket)
  • getCipher — instance (class: Socket)
  • getConnections — instance (class: Server)
  • getDefaultAutoSelectFamily — module
  • getDefaultAutoSelectFamilyAttemptTimeout — module
  • getPeerCertificate — instance (class: Socket)
  • getProtocol — instance (class: Socket)
  • getSession — instance (class: Socket)
  • getTypeOfService — instance (class: Socket)
  • isBlockList — module (class: BlockList)
  • isIP — module
  • isIPv4 — module
  • isIPv6 — module
  • isSessionReused — instance (class: Socket)
  • listen — instance (class: Server)
  • listenerCount — instance (class: Socket)
  • listenerCount — instance (class: Server)
  • listeners — instance (class: Socket)
  • listeners — instance (class: Server)
  • listening — instance (class: Server)
  • localAddress — instance (class: Socket)
  • localFamily — instance (class: Socket)
  • localPort — instance (class: Socket)
  • maxConnections — instance (class: Server)
  • off — instance (class: Socket)
  • off — instance (class: Server)
  • on — instance (class: Socket)
  • once — instance (class: Socket)
  • once — instance (class: Server)
  • parse — module (class: SocketAddress)
  • pause — instance (class: Socket)
  • pending — instance (class: Socket)
  • port — instance (class: SocketAddress)
  • rawListeners — instance (class: Socket)
  • rawListeners — instance (class: Server)
  • readyState — instance (class: Socket)
  • ref — instance (class: Socket)
  • remoteAddress — instance (class: Socket)
  • remoteFamily — instance (class: Socket)
  • remotePort — instance (class: Socket)
  • removeAllListeners — instance (class: Socket)
  • removeAllListeners — instance (class: Server)
  • removeListener — instance (class: Socket)
  • removeListener — instance (class: Server)
  • resetAndDestroy — instance (class: Socket)
  • resume — instance (class: Socket)
  • rules — instance (class: BlockList)
  • setDefaultAutoSelectFamily — module
  • setDefaultAutoSelectFamilyAttemptTimeout — module
  • setDefaultEncoding — instance (class: Socket)
  • setEncoding — instance (class: Socket)
  • setKeepAlive — instance (class: Socket)
  • setMaxSendFragment — instance (class: Socket)
  • setNoDelay — instance (class: Socket)
  • setTimeout — instance (class: Socket)
  • setTypeOfService — instance (class: Socket)
  • timeout — instance (class: Socket)
  • toJSON — instance (class: BlockList)
  • uncork — instance (class: Socket)
  • unref — instance (class: Socket)
  • upgradeToTLS — instance (class: Socket)
  • write — instance (class: Socket)

node-cron

Methods

  • schedule — module
  • validate — module

node-fetch

Classes

  • Blob
  • FormData
  • Headers
  • Request
  • Response

Methods

  • default — module

nodemailer

Methods

  • createTransport — module
  • sendMail — instance
  • verify — instance

os

Methods

  • arch — module
  • availableParallelism — module
  • cpus — module
  • endianness — module
  • freemem — module
  • getPriority — module
  • homedir — module
  • hostname — module
  • loadavg — module
  • machine — module
  • networkInterfaces — module
  • platform — module
  • release — module
  • setPriority — module
  • tmpdir — module
  • totalmem — module
  • type — module
  • uptime — module
  • userInfo — module
  • version — module

Properties

  • EOL
  • constants
  • default
  • devNull

path

Methods

  • _makeLong — module
  • basename — module
  • dirname — module
  • extname — module
  • format — module
  • isAbsolute — module
  • join — module
  • matchesGlob — module
  • normalize — module
  • parse — module
  • relative — module
  • resolve — module
  • toNamespacedPath — module

Properties

  • default
  • delimiter
  • posix
  • sep
  • win32

path/posix

Methods

  • _makeLong — module
  • basename — module
  • dirname — module
  • extname — module
  • format — module
  • isAbsolute — module
  • join — module
  • matchesGlob — module
  • normalize — module
  • parse — module
  • relative — module
  • resolve — module
  • toNamespacedPath — module

Properties

  • default
  • delimiter
  • posix
  • sep
  • win32

path/win32

Methods

  • _makeLong — module
  • basename — module
  • dirname — module
  • extname — module
  • format — module
  • isAbsolute — module
  • join — module
  • matchesGlob — module
  • normalize — module
  • parse — module
  • relative — module
  • resolve — module
  • toNamespacedPath — module

Properties

  • default
  • delimiter
  • posix
  • sep
  • win32

perf_hooks

Classes

  • Performance
  • PerformanceEntry
  • PerformanceMark
  • PerformanceMeasure
  • PerformanceObserver
  • PerformanceObserverEntryList
  • PerformanceResourceTiming

Methods

  • createHistogram — module
  • disconnect — instance (class: PerformanceObserver)
  • monitorEventLoopDelay — module
  • observe — instance (class: PerformanceObserver)
  • takeRecords — instance (class: PerformanceObserver)
  • timerify — module

Properties

  • constants
  • performance

perry/ads

Methods

  • js_ads_banner_create — module
  • js_ads_banner_destroy — module
  • js_ads_interstitial_load — module
  • js_ads_interstitial_show — module
  • js_ads_rewarded_load — module
  • js_ads_rewarded_show — module

perry/audio

Methods

  • createBus — module
  • crossfade — module
  • destroyBus — module
  • fadeIn — module
  • fadeOut — module
  • getDuration — module
  • getPosition — module
  • isPlaying — module
  • loadSound — module
  • muteBus — module
  • onEnded — module
  • onLoaded — module
  • pause — module
  • play — module
  • resume — module
  • resumeAll — module
  • setMasterVolume — module
  • setPan — module
  • setRate — module
  • setVolume — module
  • soloBus — module
  • stop — module
  • suspend — module
  • unload — module

perry/background

Methods

  • cancel — module
  • registerTask — module
  • schedule — module

perry/compose

Methods

  • config — module
  • down — module
  • exec — module
  • logs — module
  • ps — module
  • restart — module
  • start — module
  • stop — module
  • up — module

perry/container

Methods

  • composeUp — module
  • create — module
  • detectBackend — module
  • downAll — module
  • downByProject — module
  • exec — module
  • getAvailableBackends — module
  • getBackend — module
  • getBackendPriority — module
  • inspect — module
  • list — module
  • listImages — module
  • logs — module
  • pullImage — module
  • remove — module
  • removeIfExists — module
  • removeImage — module
  • run — module
  • selectBackendFor — module
  • setBackend — module
  • setBackends — module
  • start — module
  • stop — module

perry/container-compose

Methods

  • config — module
  • down — module
  • exec — module
  • logs — module
  • ps — module
  • restart — module
  • start — module
  • stop — module
  • up — module

perry/i18n

Methods

  • Currency — module
  • FormatNumber — module
  • FormatTime — module
  • LongDate — module
  • Percent — module
  • Raw — module
  • ShortDate — module
  • t — module

perry/media

Methods

  • createPlayer — module
  • destroy — module
  • getCurrentTime — module
  • getDuration — module
  • getState — module
  • isPlaying — module
  • onStateChange — module
  • onTimeUpdate — module
  • pause — module
  • play — module
  • seek — module
  • setNowPlaying — module
  • setRate — module
  • setVolume — module
  • stop — module

perry/plugin

Classes

  • PluginApi

Methods

  • discoverPlugins — module
  • emitEvent — module
  • emitHook — module
  • initPlugins — module
  • invokeTool — module
  • listHooks — module
  • listPlugins — module
  • listTools — module
  • loadPlugin — module
  • pluginCount — module
  • setPluginConfig — module
  • unloadPlugin — module

perry/system

Methods

  • appGetLaunchUrl — module
  • appGroupDelete — module
  • appGroupGet — module
  • appGroupSet — module
  • appOnOpenUrl — module
  • audioGetLevel — module
  • audioGetPeak — module
  • audioGetWaveform — module
  • audioRegisterCallback — module
  • audioSetOutputFilename — module
  • audioStart — module
  • audioStartRecording — module
  • audioStop — module
  • audioStopRecording — module
  • audioUnregisterCallback — module
  • geolocationGetCurrent — module
  • geolocationRequestPermission — module
  • geolocationStopWatch — module
  • geolocationWatch — module
  • getAppBuildNumber — module
  • getAppIcon — module
  • getAppVersion — module
  • getBundleId — module
  • getDeviceIdiom — module
  • getDeviceModel — module
  • getLocale — module
  • getOSVersion — module
  • getSafeAreaInsets — module
  • imagePickerPick — module
  • isDarkMode — module
  • keychainDelete — module
  • keychainGet — module
  • keychainSave — module
  • networkGetStatus — module
  • networkOnChange — module
  • networkStopOnChange — module
  • notificationCancel — module
  • notificationOnBackgroundReceive — module
  • notificationOnReceive — module
  • notificationOnTap — module
  • notificationRegisterRemote — module
  • notificationSend — module
  • openURL — module
  • preferencesGet — module
  • preferencesSet — module
  • shareText — module
  • shareUrl — module
  • takeScreenshot — module

perry/thread

Methods

  • parallelFilter — module
  • parallelMap — module
  • spawn — module

perry/tui

Methods

  • AnimatedSpinner — module
  • Box — module
  • Input — module
  • InputAt — module
  • List — module
  • ProgressBar — module
  • Select — module
  • Spacer — module
  • Spinner — module
  • Table — module
  • Tabs — module
  • Text — module
  • TextArea — module
  • TextStyled — module
  • boxSetAlignItems — module
  • boxSetFlexBasis — module
  • boxSetFlexBasisPct — module
  • boxSetFlexDirection — module
  • boxSetFlexGrow — module
  • boxSetFlexShrink — module
  • boxSetGap — module
  • boxSetHeight — module
  • boxSetHeightPct — module
  • boxSetJustifyContent — module
  • boxSetPadding — module
  • boxSetPaddingEach — module
  • boxSetWidth — module
  • boxSetWidthPct — module
  • columns — instance (class: TuiStdout)
  • enter — module
  • exit — module
  • exit — instance (class: TuiApp)
  • focus — module
  • focus — instance (class: FocusManager)
  • focusNext — module
  • focusNext — instance (class: FocusManager)
  • focusPrevious — module
  • focusPrevious — instance (class: FocusManager)
  • get — instance (class: State)
  • get — instance (class: RefBox)
  • render — module
  • rows — instance (class: TuiStdout)
  • run — module
  • set — instance (class: State)
  • set — instance (class: RefBox)
  • state — module
  • useApp — module
  • useEffect — module
  • useFocus — module
  • useFocusManager — module
  • useInput — module
  • useMemo — module
  • useRef — module
  • useState — module
  • useStateSet — module
  • useStateTuple — module
  • useStdout — module
  • waitUntilExit — module
  • waitUntilExit — instance (class: TuiApp)
  • write — instance (class: TuiStdout)

perry/ui

Methods

  • App — module
  • AttributedText — module
  • BottomNavigation — module
  • Button — module
  • CameraView — module
  • Canvas — module
  • Divider — module
  • ForEach — module
  • HStack — module
  • HStackWithInsets — module
  • Image — module
  • ImageFile — module
  • ImageGallery — module
  • ImageSymbol — module
  • LazyVStack — module
  • NavStack — module
  • Picker — module
  • ProgressView — module
  • ScrollView — module
  • Section — module
  • SecureField — module
  • Slider — module
  • Spacer — module
  • SplitView — module
  • State — module
  • TabBar — module
  • Table — module
  • Text — module
  • TextArea — module
  • TextField — module
  • Toggle — module
  • VStack — module
  • VStackWithInsets — module
  • WebView — module
  • Window — module
  • ZStack — module
  • addKeyboardShortcut — module
  • alert — module
  • alertWithButtons — module
  • appSetMaxSize — module
  • appSetMinSize — module
  • appSetTimer — module
  • attributedTextAppend — module
  • attributedTextClear — module
  • blur — module
  • bottomNavAddItem — module
  • bottomNavSetBadge — module
  • bottomNavSetSelected — module
  • bottomNavSetTintColor — module
  • bottomNavSetUnselectedTintColor — module
  • cameraFreeze — module
  • cameraRegisterFrameCallback — module
  • cameraSampleColor — module
  • cameraSetOnTap — module
  • cameraStart — module
  • cameraStop — module
  • cameraUnfreeze — module
  • cameraUnregisterFrameCallback — module
  • clipboardRead — module
  • clipboardWrite — module
  • currentModifiers — module
  • embedNSView — module
  • focus — module
  • frameSplitAddChild — module
  • frameSplitCreate — module
  • imageGalleryAddImage — module
  • imageGallerySetIndex — module
  • isKeyDown — module
  • lazyvstackEndRefreshing — module
  • lazyvstackSetRefreshControl — module
  • lazyvstackSetScrollEndCallback — module
  • loadImage — module
  • menuAddItem — module
  • menuAddItemWithShortcut — module
  • menuAddSeparator — module
  • menuAddStandardAction — module
  • menuAddSubmenu — module
  • menuBarAddMenu — module
  • menuBarAttach — module
  • menuBarCreate — module
  • menuClear — module
  • menuCreate — module
  • onActivate — module
  • onAppKeyDown — module
  • onAppKeyUp — module
  • onKeyDown — module
  • onKeyUp — module
  • onTerminate — module
  • openFileDialog — module
  • openFolderDialog — module
  • pollOpenFile — module
  • registerGlobalHotkey — module
  • saveFileDialog — module
  • scrollViewSetScrollEndCallback — module
  • scrollviewSetScrollEndCallback — module
  • setText — module
  • sheetCreate — module
  • sheetDismiss — module
  • sheetPresent — module
  • showToast — module
  • toolbarAddItem — module
  • toolbarAttach — module
  • toolbarCreate — module
  • trayAttachMenu — module
  • trayCreate — module
  • trayDestroy — module
  • trayOnClick — module
  • traySetIcon — module
  • traySetTooltip — module
  • webviewCanGoBack — module
  • webviewClearCookies — module
  • webviewEvaluateJs — module
  • webviewGoBack — module
  • webviewGoForward — module
  • webviewLoadUrl — module
  • webviewReload — module

perry/updater

Methods

  • clearSentinel — module
  • compareVersions — module
  • computeFileSha256 — module
  • getBackupPath — module
  • getExePath — module
  • getSentinelPath — module
  • installUpdate — module
  • performRollback — module
  • readSentinel — module
  • relaunch — module
  • verifyHash — module
  • verifySignature — module
  • verifySignatureV2 — module
  • writeSentinel — module

perry/widget

Methods

  • Widget — module

perry/workloads

Methods

  • graph — module
  • inspectGraph — module
  • node — module
  • runGraph — module

Properties

  • policy
  • runtime

pg

Classes

  • Client
  • Pool

Methods

  • Pool — module
  • connect — module
  • connect — instance (class: Client)
  • end — instance (class: Pool)
  • end — instance
  • query — instance (class: Pool)
  • query — instance

process

Methods

  • _debugEnd — module
  • _debugProcess — module
  • _fatalException — module
  • _getActiveHandles — module
  • _getActiveRequests — module
  • _kill — module
  • _linkedBinding — module
  • _rawDebug — module
  • _startProfilerIdleNotifier — module
  • _stopProfilerIdleNotifier — module
  • _tickCallback — module
  • abort — module
  • addUncaughtExceptionCaptureCallback — module
  • availableMemory — module
  • binding — module
  • chdir — module
  • constrainedMemory — module
  • cpuUsage — module
  • cwd — module
  • dlopen — module
  • emitWarning — module
  • execve — module
  • exit — module
  • getActiveResourcesInfo — module
  • getBuiltinModule — module
  • getegid — module
  • geteuid — module
  • getgid — module
  • getgroups — module
  • getuid — module
  • hasUncaughtExceptionCaptureCallback — module
  • hrtime — module
  • initgroups — module
  • kill — module
  • loadEnvFile — module
  • memoryUsage — module
  • nextTick — module
  • openStdin — module
  • reallyExit — module
  • ref — module
  • resourceUsage — module
  • setSourceMapsEnabled — module
  • setSourceMapsEnabled — module
  • setUncaughtExceptionCaptureCallback — module
  • setegid — module
  • seteuid — module
  • setgid — module
  • setgroups — module
  • setuid — module
  • sourceMapsEnabled — module
  • sourceMapsEnabled — module
  • threadCpuUsage — module
  • umask — module
  • unref — module
  • uptime — module

Properties

  • _eval
  • _events
  • _eventsCount
  • _exiting
  • _maxListeners
  • _preload_modules
  • allowedNodeEnvironmentFlags
  • arch
  • argv
  • argv0
  • config
  • debugPort
  • domain
  • env
  • execArgv
  • execPath
  • features
  • finalization
  • moduleLoadList
  • permission
  • pid
  • platform
  • ppid
  • release
  • report
  • stderr
  • stdin
  • stdout
  • title
  • version
  • versions

punycode

Methods

  • decode — module
  • encode — module
  • toASCII — module
  • toUnicode — module

Properties

  • default
  • ucs2
  • version

querystring

Methods

  • decode — module
  • encode — module
  • escape — module
  • parse — module
  • stringify — module
  • unescape — module
  • unescapeBuffer — module

Properties

  • default

rate-limiter-flexible

Classes

  • RateLimiterAbstract
  • RateLimiterMemory

readline

Methods

  • clearLine — module
  • clearScreenDown — module
  • close — instance
  • createInterface — module
  • cursorTo — module
  • emitKeypressEvents — module
  • getCursorPos — instance
  • getPrompt — instance
  • iterator — instance
  • line — instance
  • moveCursor — module
  • on — instance
  • pause — instance
  • prompt — instance
  • question — instance
  • resume — instance
  • setPrompt — instance
  • terminal — instance
  • write — instance

readline/promises

Classes

  • Interface
  • Readline

Methods

  • close — instance
  • createInterface — module
  • question — instance

redis

Classes

  • Redis

Methods

  • createClient — module

repl

Classes

  • REPLServer
  • Recoverable

Methods

  • REPLServer — module
  • Recoverable — module
  • addListener — instance (class: REPLServer)
  • clearBufferedCommand — instance (class: REPLServer)
  • defineCommand — instance (class: REPLServer)
  • displayPrompt — instance (class: REPLServer)
  • emit — instance (class: REPLServer)
  • on — instance (class: REPLServer)
  • once — instance (class: REPLServer)
  • setupHistory — instance (class: REPLServer)
  • start — module
  • write — instance (class: REPLServer)

Properties

  • REPL_MODE_SLOPPY
  • REPL_MODE_STRICT
  • builtinModules
  • default

sea

Methods

  • getAsset — module
  • getAssetAsBlob — module
  • getAssetKeys — module
  • getRawAsset — module
  • isSea — module

Properties

  • default

sharp

Methods

  • blur — instance
  • default — module
  • flip — instance
  • flop — instance
  • grayscale — instance
  • height — instance
  • jpeg — instance
  • metadata — instance
  • png — instance
  • resize — instance
  • rotate — instance
  • sharp — module
  • toBuffer — instance
  • toFile — instance
  • webp — instance
  • width — instance

slugify

Methods

  • default — module
  • slugify — module

sqlite

Classes

  • DatabaseSync
  • SQLTagStore
  • Session
  • StatementSync

Methods

  • @@__perry_wk_dispose — instance
  • DatabaseSync — module
  • Session — module
  • StatementSync — module
  • __perry_dispose__ — instance
  • aggregate — instance (class: DatabaseSync)
  • all — instance (class: SQLTagStore)
  • all — instance
  • applyChangeset — instance
  • backup — module
  • capacity — instance (class: SQLTagStore)
  • changeset — instance
  • clear — instance (class: SQLTagStore)
  • close — instance
  • columns — instance
  • createSession — instance
  • createTagStore — instance (class: DatabaseSync)
  • db — instance (class: SQLTagStore)
  • enableDefensive — instance (class: DatabaseSync)
  • enableLoadExtension — instance
  • exec — instance
  • expandedSQL — instance
  • function — instance (class: DatabaseSync)
  • get — instance (class: SQLTagStore)
  • get — instance
  • isOpen — instance
  • isTransaction — instance
  • iterate — instance (class: SQLTagStore)
  • iterate — instance
  • limits — instance
  • loadExtension — instance
  • location — instance
  • open — instance
  • patchset — instance
  • prepare — instance
  • run — instance (class: SQLTagStore)
  • run — instance
  • setAllowBareNamedParameters — instance
  • setAllowUnknownNamedParameters — instance
  • setAuthorizer — instance (class: DatabaseSync)
  • setReadBigInts — instance
  • setReturnArrays — instance
  • size — instance (class: SQLTagStore)
  • sourceSQL — instance

Properties

  • constants

stream

Classes

  • Duplex
  • PassThrough
  • Readable
  • Stream
  • Transform
  • Writable

Methods

  • _isArrayBufferView — module
  • _isUint8Array — module
  • _uint8ArrayToBuffer — module
  • addAbortSignal — module
  • addListener — instance
  • allowHalfOpen — instance
  • closed — instance
  • compose — module
  • cork — instance
  • default — module
  • destroy — instance
  • destroyed — instance
  • duplexPair — module
  • emit — instance
  • end — instance
  • errored — instance
  • eventNames — instance
  • finished — module
  • getDefaultHighWaterMark — module
  • getMaxListeners — instance
  • isDestroyed — module
  • isDisturbed — module
  • isErrored — module
  • isPaused — instance
  • isReadable — module
  • isWritable — module
  • listenerCount — instance
  • listeners — instance
  • off — instance
  • on — instance
  • once — instance
  • pause — instance
  • pipe — instance
  • pipeline — module
  • prependListener — instance
  • prependOnceListener — instance
  • push — instance
  • rawListeners — instance
  • read — instance
  • readable — instance
  • readableAborted — instance
  • readableDidRead — instance
  • readableEncoding — instance
  • readableEnded — instance
  • readableFlowing — instance
  • readableHighWaterMark — instance
  • readableLength — instance
  • readableObjectMode — instance
  • removeAllListeners — instance
  • removeListener — instance
  • resume — instance
  • setDefaultHighWaterMark — module
  • setEncoding — instance
  • setMaxListeners — instance
  • uncork — instance
  • unpipe — instance
  • unshift — instance
  • writable — instance
  • writableCorked — instance
  • writableEnded — instance
  • writableFinished — instance
  • writableHighWaterMark — instance
  • writableLength — instance
  • writableNeedDrain — instance
  • writableObjectMode — instance
  • write — instance

Properties

  • promises
  • promises

stream/consumers

Methods

  • arrayBuffer — module
  • blob — module
  • buffer — module
  • bytes — module
  • json — module
  • text — module

Properties

  • default

stream/promises

Methods

  • finished — module
  • finished — module
  • pipeline — module
  • pipeline — module

stream/web

Classes

  • ByteLengthQueuingStrategy
  • CompressionStream
  • CountQueuingStrategy
  • DecompressionStream
  • ReadableByteStreamController
  • ReadableStream
  • ReadableStreamBYOBReader
  • ReadableStreamBYOBRequest
  • ReadableStreamDefaultController
  • ReadableStreamDefaultReader
  • TextDecoderStream
  • TextEncoderStream
  • TransformStream
  • TransformStreamDefaultController
  • WritableStream
  • WritableStreamDefaultController
  • WritableStreamDefaultWriter

Properties

  • default

streams

Classes

  • ByteLengthQueuingStrategy
  • CountQueuingStrategy
  • DecompressionStream
  • ReadableStream
  • TextDecoder
  • TextEncoder
  • TransformStream
  • WritableStream

string_decoder

Classes

  • StringDecoder

Methods

  • end — instance (class: StringDecoder)
  • write — instance (class: StringDecoder)

sys

Classes

  • MIMEParams
  • MIMEType
  • TextDecoder
  • TextEncoder

Methods

  • MIMEParams — module
  • MIMEType — module
  • _errnoException — module
  • _exceptionWithHostPort — module
  • _extend — module
  • aborted — module
  • callbackify — module
  • convertProcessSignalToExitCode — module
  • debug — module
  • debuglog — module
  • deprecate — module
  • diff — module
  • format — module
  • formatWithOptions — module
  • getCallSites — module
  • getSystemErrorMap — module
  • getSystemErrorMessage — module
  • getSystemErrorName — module
  • inherits — module
  • inspect — module
  • isArray — module
  • isDeepStrictEqual — module
  • parseArgs — module
  • parseEnv — module
  • promisify — module
  • setTraceSigInt — module
  • stripVTControlCharacters — module
  • styleText — module
  • toUSVString — module
  • transferableAbortController — module
  • transferableAbortSignal — module

Properties

  • default
  • types

test

Methods

  • after — module
  • afterEach — module
  • before — module
  • beforeEach — module
  • default — module
  • describe — module
  • enable — module (class: timers)
  • expectFailure — module
  • fn — module (class: mock)
  • getter — module (class: mock)
  • it — module
  • method — module (class: mock)
  • only — module
  • property — module (class: mock)
  • reset — module (class: mock)
  • restoreAll — module (class: mock)
  • run — module
  • runAll — module (class: timers)
  • setDefaultSnapshotSerializers — module (class: snapshot)
  • setResolveSnapshotPath — module (class: snapshot)
  • setTime — module (class: timers)
  • setter — module (class: mock)
  • skip — module
  • suite — module
  • test — module
  • tick — module (class: timers)
  • todo — module

Properties

  • assert
  • mock
  • snapshot

test/reporters

Methods

  • dot — module
  • junit — module
  • lcov — module
  • spec — module
  • tap — module

Properties

  • default

timers

Methods

  • clearImmediate — module
  • clearInterval — module
  • clearTimeout — module
  • setImmediate — module
  • setInterval — module
  • setTimeout — module

Properties

  • promises

timers/promises

Methods

  • setImmediate — module
  • setInterval — module
  • setTimeout — module

Properties

  • scheduler

tls

Classes

  • SecureContext

Methods

  • SecureContext — module
  • Server — module
  • TLSSocket — module
  • addListener — instance (class: Server)
  • address — instance (class: Server)
  • checkServerIdentity — module
  • close — instance (class: Server)
  • connect — module
  • createSecureContext — module
  • createServer — module
  • eventNames — instance (class: Server)
  • getCACertificates — module
  • getCiphers — module
  • getTicketKeys — instance (class: Server)
  • listen — instance (class: Server)
  • listenerCount — instance (class: Server)
  • off — instance (class: Server)
  • on — instance (class: Server)
  • once — instance (class: Server)
  • removeAllListeners — instance (class: Server)
  • removeListener — instance (class: Server)
  • setDefaultCACertificates — module
  • setSecureContext — instance (class: Server)
  • setTicketKeys — instance (class: Server)

Properties

  • CLIENT_RENEG_LIMIT
  • CLIENT_RENEG_WINDOW
  • DEFAULT_CIPHERS
  • DEFAULT_ECDH_CURVE
  • DEFAULT_MAX_VERSION
  • DEFAULT_MIN_VERSION
  • rootCertificates

tty

Classes

  • ReadStream
  • WriteStream

Methods

  • ReadStream — module
  • WriteStream — module
  • _refreshSize — instance (class: WriteStream)
  • addListener — instance (class: WriteStream)
  • clearLine — instance (class: WriteStream)
  • clearScreenDown — instance (class: WriteStream)
  • cursorTo — instance (class: WriteStream)
  • getColorDepth — instance (class: WriteStream)
  • getWindowSize — instance (class: WriteStream)
  • hasColors — instance (class: WriteStream)
  • isatty — module
  • moveCursor — instance (class: WriteStream)
  • off — instance (class: WriteStream)
  • on — instance (class: WriteStream)
  • once — instance (class: WriteStream)
  • removeAllListeners — instance (class: WriteStream)
  • removeListener — instance (class: WriteStream)
  • setRawMode — instance (class: ReadStream)

tursodb

Methods

  • close — instance
  • exec — instance
  • execBatch — instance
  • isAutocommit — instance
  • lastInsertRowid — instance
  • open — module
  • queryAll — instance
  • queryOne — instance

url

Classes

  • URL
  • URLPattern
  • URLSearchParams
  • Url

Methods

  • Url — module
  • domainToASCII — module
  • domainToUnicode — module
  • exec — instance (class: URLPattern)
  • fileURLToPath — module
  • fileURLToPathBuffer — module
  • format — module
  • parse — module
  • pathToFileURL — module
  • resolve — module
  • resolveObject — module
  • test — instance (class: URLPattern)
  • urlToHttpOptions — module

Properties

  • default

util

Classes

  • MIMEParams
  • MIMEType
  • TextDecoder
  • TextEncoder

Methods

  • MIMEParams — module
  • MIMEType — module
  • _errnoException — module
  • _exceptionWithHostPort — module
  • _extend — module
  • aborted — module
  • callbackify — module
  • convertProcessSignalToExitCode — module
  • debug — module
  • debuglog — module
  • deprecate — module
  • diff — module
  • format — module
  • formatWithOptions — module
  • getCallSites — module
  • getSystemErrorMap — module
  • getSystemErrorMessage — module
  • getSystemErrorName — module
  • inherits — module
  • inspect — module
  • isArray — module
  • isDeepStrictEqual — module
  • parseArgs — module
  • parseEnv — module
  • promisify — module
  • setTraceSigInt — module
  • stripVTControlCharacters — module
  • styleText — module
  • toUSVString — module
  • transferableAbortController — module
  • transferableAbortSignal — module

Properties

  • default
  • types

util/types

Methods

  • isAnyArrayBuffer — module
  • isArgumentsObject — module
  • isArrayBuffer — module
  • isArrayBufferView — module
  • isAsyncFunction — module
  • isBigInt64Array — module
  • isBigIntObject — module
  • isBigUint64Array — module
  • isBooleanObject — module
  • isBoxedPrimitive — module
  • isCryptoKey — module
  • isDataView — module
  • isDate — module
  • isExternal — module
  • isFloat16Array — module
  • isFloat32Array — module
  • isFloat64Array — module
  • isGeneratorFunction — module
  • isGeneratorObject — module
  • isInt16Array — module
  • isInt32Array — module
  • isInt8Array — module
  • isKeyObject — module
  • isMap — module
  • isMapIterator — module
  • isModuleNamespaceObject — module
  • isNativeError — module
  • isNumberObject — module
  • isPromise — module
  • isProxy — module
  • isRegExp — module
  • isSet — module
  • isSetIterator — module
  • isSharedArrayBuffer — module
  • isStringObject — module
  • isSymbolObject — module
  • isTypedArray — module
  • isUint16Array — module
  • isUint32Array — module
  • isUint8Array — module
  • isUint8ClampedArray — module
  • isWeakMap — module
  • isWeakSet — module

uuid

Methods

  • v1 — module
  • v4 — module
  • v7 — module
  • validate — module

v8

Classes

  • DefaultDeserializer
  • DefaultSerializer
  • Deserializer
  • GCProfiler
  • Serializer

Methods

  • addDeserializeCallback — instance (class: startupSnapshot)
  • addSerializeCallback — instance (class: startupSnapshot)
  • cachedDataVersionTag — module
  • createHook — instance (class: promiseHooks)
  • deserialize — module
  • getCppHeapStatistics — module
  • getHeapCodeStatistics — module
  • getHeapSnapshot — module
  • getHeapSpaceStatistics — module
  • getHeapStatistics — module
  • isBuildingSnapshot — instance (class: startupSnapshot)
  • isStringOneByteRepresentation — module
  • onAfter — instance (class: promiseHooks)
  • onBefore — instance (class: promiseHooks)
  • onInit — instance (class: promiseHooks)
  • onSettled — instance (class: promiseHooks)
  • queryObjects — module
  • readDouble — instance (class: Deserializer)
  • readHeader — instance (class: Deserializer)
  • readRawBytes — instance (class: Deserializer)
  • readUint32 — instance (class: Deserializer)
  • readUint64 — instance (class: Deserializer)
  • readValue — instance (class: Deserializer)
  • releaseBuffer — instance (class: Serializer)
  • serialize — module
  • setDeserializeMainFunction — instance (class: startupSnapshot)
  • setFlagsFromString — module
  • setHeapSnapshotNearHeapLimit — module
  • start — instance (class: GCProfiler)
  • startCpuProfile — module
  • stop — instance (class: GCProfiler)
  • stopCoverage — module
  • takeCoverage — module
  • writeDouble — instance (class: Serializer)
  • writeHeader — instance (class: Serializer)
  • writeHeapSnapshot — module
  • writeRawBytes — instance (class: Serializer)
  • writeUint32 — instance (class: Serializer)
  • writeUint64 — instance (class: Serializer)
  • writeValue — instance (class: Serializer)

Properties

  • promiseHooks
  • startupSnapshot

validator

Methods

  • isEmail — module
  • isEmpty — module
  • isJSON — module
  • isURL — module
  • isUUID — module

vm

Classes

  • Script

Methods

  • compileFunction — module
  • createCachedData — instance
  • createContext — module
  • createScript — module
  • dependencySpecifiers — instance
  • error — instance
  • evaluate — instance
  • hasAsyncGraph — instance
  • hasTopLevelAwait — instance
  • identifier — instance
  • instantiate — instance
  • isContext — module
  • link — instance
  • linkRequests — instance
  • measureMemory — module
  • moduleRequests — instance
  • namespace — instance
  • runInContext — module
  • runInNewContext — module
  • runInThisContext — module
  • setExport — instance
  • status — instance

Properties

  • constants
  • default

wasi

Classes

  • WASI

Methods

  • WASI — module
  • finalizeBindings — instance (class: WASI)
  • getImportObject — instance (class: WASI)
  • initialize — instance (class: WASI)
  • start — instance (class: WASI)

Properties

  • wasiImport

worker_threads

Classes

  • BroadcastChannel
  • MessageChannel
  • MessagePort
  • Worker

Methods

  • BroadcastChannel — module
  • MessageChannel — module
  • cpuUsage — instance (class: Worker)
  • getEnvironmentData — module
  • getHeapSnapshot — instance (class: Worker)
  • getHeapStatistics — instance (class: Worker)
  • isMarkedAsUntransferable — module
  • markAsUncloneable — module
  • markAsUntransferable — module
  • moveMessagePortToContext — module
  • off — instance (class: Worker)
  • on — instance (class: Worker)
  • once — instance (class: Worker)
  • postMessageToThread — module
  • receiveMessageOnPort — module
  • ref — instance (class: Worker)
  • setEnvironmentData — module
  • startCpuProfile — instance (class: Worker)
  • startHeapProfile — instance (class: Worker)
  • terminate — instance (class: Worker)
  • unref — instance (class: Worker)

Properties

  • SHARE_ENV
  • isInternalThread
  • isMainThread
  • locks
  • parentPort
  • resourceLimits
  • threadId
  • threadName
  • workerData

ws

Classes

  • Client
  • WebSocket
  • WebSocketServer

Methods

  • Server — module
  • WebSocket — module
  • addListener — instance (class: Client)
  • close — instance
  • close — instance (class: Client)
  • closeClient — module
  • handleUpgrade — instance
  • on — instance
  • on — instance (class: Client)
  • send — instance
  • send — instance (class: Client)
  • sendToClient — module

Properties

  • CLOSED
  • CLOSING
  • CONNECTING
  • OPEN

zlib

Classes

  • BrotliCompress
  • BrotliCompress
  • BrotliDecompress
  • BrotliDecompress
  • Deflate
  • Deflate
  • DeflateRaw
  • DeflateRaw
  • Gunzip
  • Gunzip
  • Gzip
  • Gzip
  • Inflate
  • Inflate
  • InflateRaw
  • InflateRaw
  • Unzip
  • Unzip
  • ZstdCompress
  • ZstdDecompress

Methods

  • brotliCompress — module
  • brotliCompressSync — module
  • brotliDecompress — module
  • brotliDecompressSync — module
  • crc32 — module
  • createBrotliCompress — module
  • createBrotliDecompress — module
  • createDeflate — module
  • createDeflateRaw — module
  • createGunzip — module
  • createGzip — module
  • createInflate — module
  • createInflateRaw — module
  • createUnzip — module
  • createZstdCompress — module
  • createZstdDecompress — module
  • deflate — module
  • deflateRaw — module
  • deflateRawSync — module
  • deflateSync — module
  • gunzip — module
  • gunzipSync — module
  • gzip — module
  • gzipSync — module
  • inflate — module
  • inflateRaw — module
  • inflateRawSync — module
  • inflateSync — module
  • unzip — module
  • unzipSync — module
  • zstdCompress — module
  • zstdCompressSync — module
  • zstdDecompress — module
  • zstdDecompressSync — module

Properties

  • codes
  • constants

Containers — Overview

Perry ships a first-class container subsystem that lets a TypeScript program manage OCI containers and multi-container stacks directly, without shelling out to docker compose or hand-rolling subprocess wrappers. The user-facing API is split across two TypeScript modules:

ModuleUse case
perry/containerSingle-container lifecycle: run, create, start, stop, remove, inspect, logs, exec, plus image management.
perry/composeMulti-service orchestration: up, down, ps, logs, exec, start, stop, restart, config — driven by a TS object literal that mirrors the Compose spec.

Both modules compile to direct calls into a Rust backend that talks to whatever OCI-compatible runtime is on the host. There is no JavaScript runtime in the loop, no YAML file emitter, no docker-compose shell-out: the spec is a TS object, the engine is in-process, and orchestration logic (dependency ordering, rollback, healthcheck waits) runs natively.

Backend auto-detection

You do not configure a runtime up-front. On first use, Perry probes a platform-specific priority list of OCI runtimes (with a 2-second timeout per candidate) and caches the first one that responds:

PlatformProbe order
macOS / iOSapple/containerorbstackcolimarancher-desktoplimapodmannerdctldocker
Linuxpodmannerdctldocker
Windowspodmannerdctldocker

The choices reflect three priorities: platform-native runtimes win (apple/container on macOS, the others on Linux), daemonless / rootless runtimes (podman, nerdctl) beat daemon-based ones, and docker is always the last fallback.

The same ComposeSpec produces deterministic behavior across every backend in this list — same project-namespaced names, same DNS aliases, same ContainerInfo shape from inspect, with explicit warnings (or hard failures, opt-in) when a feature like privileged: true can’t be honored on the chosen runtime. See Cross-Backend Determinism for the architecture.

import { getBackend, detectBackend } from "perry/container";

async function pickBackend(): Promise<void> {
    // Synchronous: returns the canonical name of the active backend
    // (`"docker"`, `"podman"`, `"apple/container"`, `"orbstack"`,
    // `"colima"`, `"lima"`, `"nerdctl"`, …). When called before any
    // async FFI has triggered detection, getBackend() performs a
    // synchronous in-place probe with the same 2 s timeout per
    // candidate that detectBackend() uses, so the result is live.
    console.log(`backend: ${getBackend()}`);

    // Async + verbose: returns a JSON array of every probed backend
    // with availability + version + reason for unavailable ones. Use
    // this when you want to surface a "diagnostics" panel to the user.
    const probed = await detectBackend();
    console.log(probed);
}

Picking a specific backend explicitly

Auto-detect is the default, but Perry exposes four mechanisms for overriding it. Each has its own use case — the four compose cleanly, so a single program can use multiple.

#MechanismWhenAPI
1Auto-detect“just work”(default — none)
2Env varprocess-level pin (CI matrix, dev override)PERRY_CONTAINER_BACKEND=docker ./app
3Programmatic pinTS-runtime pin before first opawait setBackend('podman')
4Capability-awarepick the best backend for the specJSON.parse(selectBackendFor(JSON.stringify(spec)))
import {
  setBackend, setBackends, getBackend, getBackendPriority,
  getAvailableBackends, selectBackendFor, up,
} from 'perry/container';

// (3a) Pin a specific backend for everything in this process.
await setBackend('docker');

// (3b) Or — try a list in user-defined priority order (first
//      available wins). Useful for "prefer rootless, fall back to
//      docker" patterns and CI matrix lanes.
await setBackends(['podman', 'docker']);

// (4) Or — let Perry pick the best backend FOR THIS SPEC.
//     Spec uses privileged: true → returns "docker" / "podman" (not apple).
//     Trivial spec on macOS → returns "apple/container".
const best = JSON.parse(selectBackendFor(JSON.stringify(spec))) as string;
await setBackend(best);
await up(spec);

// Diagnostics — which backends does Perry know about, and which are
// actually installed on this host?
console.log(getBackend());                                          // "docker" (active)
console.log(JSON.parse(getBackendPriority()));                      // ["apple/container", ...]
console.log(JSON.parse(await getAvailableBackends()));              // BackendInfo[] — full probe

setBackend() rejects after the first container op fires — the global backend OnceLock can’t be reset. Set it before any other perry/container or perry/compose call. See Cross-Backend Determinism for the full architecture and the capability-aware selectBackendFor() semantics.

Environment variables

VariableEffect
PERRY_CONTAINER_BACKEND=<name>Process-level backend pin (skips auto-detection). Same effect as calling setBackend(name) from TS, but works before the first op fires. Errors with NoBackendFound if the named backend isn’t probeable.
PERRY_NO_INSTALL_PROMPT=1Disable the interactive installer when no backend is found. Defaults to allowed when stderr is a TTY.
PERRY_CONTAINER_VERIFY_IMAGES=1Run cosign verify against every pulled image before use. See Security.
PERRY_ALLOW_UNTRUSTED_SHARED_KERNEL=1Opt out of the workload-graph requirement that policy.tier = "untrusted" runs in a microVM. Not recommended for actual untrusted code.
PERRY_NO_DEFAULT_SIGINT_CLEANUP=1Skip the default SIGINT/SIGTERM handler that drains COMPOSE_HANDLES. Tests + tools that own their own teardown set this.

Module layout

TypeScript code
    ↓  import { run } from 'perry/container'
    ↓  import { up }  from 'perry/compose'
HIR (perry-hir)        — recognises the import paths as native modules
codegen (perry-codegen)— emits direct calls to FFI symbols (NativeModSig dispatch table)
FFI bridge (perry-stdlib::container)
    ↓
ComposeEngine (perry-container-compose)
    ↓
ContainerBackend trait → CliBackend<P: CliProtocol>  (DockerProtocol / AppleContainerProtocol / LimaProtocol)
    ↓
docker / podman / apple/container / colima / orbstack / lima / nerdctl

The split exists so the compiler can stay agnostic about which runtime will actually execute the spec: HIR + codegen reference symbol strings only, and the runtime backend is swappable without recompilation of user code.

Canonical lifecycle

The pattern most production deployments follow is the same as docker compose up -d / down:

  1. up() — bring the stack up, return an opaque integer handle, and exit when every service is started (up() does not block on healthchecks; for that, see Healthchecks & readiness).
  2. Run a separate readiness probe (or rely on the in-spec healthcheck block) to verify the stack is actually serving.
  3. Exit 0: the containers keep running thanks to docker’s daemon (restart: unless-stopped survives host reboots).
  4. down(handle) later (typically from a separate invocation) to tear the stack down. Volumes are preserved by default; pass { volumes: true } to also drop them.

Perry’s runtime currently does not deliver process.on('SIGINT', ...) handlers to your TS code, so a Ctrl-C-tears-down pattern can’t be written today. The example deployments under example-code/forgejo-deployment use the two-invocation pattern (./forgejo_app and ./forgejo_app --down) instead.

When to use which module

Reach for perry/container when:

  • You need to run a single utility container (CI helper, build tool, database migration runner, capability sandbox) and clean up after it.
  • You’re building a higher-level abstraction on top of OCI primitives.
  • You need fine-grained per-container security knobs (cap_add, seccomp, read_only, user).

Reach for perry/compose when:

  • You’re deploying a multi-service application (web + db, app + cache + worker, etc.).
  • You need dependency-ordered startup with healthcheck conditions.
  • You want named volumes, custom networks, and rollback-on-failure semantics.
  • You’d otherwise reach for a docker-compose.yaml file.

The two modules share a runtime; you can mix them in the same program if you e.g. use perry/compose for the long-running stack and perry/ container for one-off tasks against the same containers.

  • Single-container lifecycle — every perry/container call documented with examples.
  • Compose orchestrationperry/compose and the ComposeSpec shape, including the canonical TS-object pattern.
  • Networking — networks, the internal flag, and the cross-service-DNS gotcha (and how to work around it today).
  • Volumes — named-vs-bind, preservation across down(), and the forgejo-pgdata-style stable-name pattern.
  • Security — capabilities, image verification with cosign, and the workload-graph policy tiers.
  • Production patterns — case study using the example-code/forgejo-deployment example and the gotchas it surfaced.

Single-Container Lifecycle (perry/container)

perry/container exposes the OCI primitives that operate on one container at a time: create, start, run, stop, remove, exec, logs, inspect, plus image management. For multi-service stacks, see perry/compose — but you can mix the two modules in the same program (a long-running compose stack plus one-off run() helpers against it is a normal pattern).

Every async function returns a Promise. The runtime backend (docker, podman, apple/container, …) is auto-detected on first use; see Overview for the probe order and override knobs.

Running a container

run() creates and starts a container in one shot, returning a handle:

import { run, remove } from "perry/container";

async function runAlpine(): Promise<void> {
    const handle = await run({
        image: "alpine:3.19",
        cmd: ["echo", "hello from perry"],
        rm: false,
        // Production-friendly defaults: drop every Linux capability and
        // run as a non-root user. Add `cap_add` only for the specific
        // capabilities a workload actually needs.
        user: "nobody",
        cap_drop: ["ALL"],
    });
    console.log(`container handle: ${String(handle)}`);

    // `force: true` removes the container even if still running (the
    // FFI calls `docker rm -f` / `podman rm -f`).
    await remove(handle as unknown as string, true);
}

The full ContainerSpec accepts:

FieldTypeEffect
imagestring(required) Image reference, e.g. "alpine:3.19".
namestringExplicit container name. Defaults to {md5(image)[0..8]}-{random_hex8} when unset.
cmdstring[]Command-line override (overrides the image’s CMD).
entrypointstring[]Entrypoint override.
envRecord<string, string>Environment variables.
portsstring[]Port maps in "host:container" form, e.g. ["8080:80"].
volumesstring[]Volume mounts in "host:container[:ro]" form, e.g. ["./data:/data:ro"].
networkstringNetwork name to attach to.
rmbooleanAuto-remove on exit (docker run --rm).
labelsRecord<string, string>Container labels.
read_onlybooleanMount the root filesystem read-only.
privilegedbooleanRun privileged. Use sparingly.
userstringUID, username, or "UID:GID".
workdirstringWorking directory inside the container.
cap_addstring[]Linux capabilities to add (e.g. ["NET_BIND_SERVICE"]).
cap_dropstring[]Linux capabilities to drop (e.g. ["ALL"]).
seccompstringSeccomp profile path or "default".

See Security for the security knobs in depth.

Hardened single-container run

For an untrusted workload (e.g. running user-supplied code, executing a build script from an untrusted source) the recommended starting point is “drop everything, add back what you need”:

import { run as runSecure } from "perry/container";

// Maximum-isolation single-container run for an untrusted workload:
//   - read-only root filesystem
//   - no Linux capabilities at all
//   - non-root user
//   - working directory pinned
//   - default seccomp profile
async function runUntrustedWorkload(): Promise<void> {
    await runSecure({
        image: "alpine:3.19",
        cmd: ["sh", "-c", "echo isolated && exit 0"],
        read_only: true,
        cap_drop: ["ALL"],
        user: "nobody",
        workdir: "/tmp",
        seccomp: "default",
    });
}

Inspect, list, logs, exec

import {
    list,
    inspect,
    logs,
    exec,
} from "perry/container";

async function inspectAll(): Promise<void> {
    const containers = await list(true); // all=true → include stopped
    console.log(containers);

    const id = "my-container-id";
    const info = await inspect(id);
    console.log(info.status); // "running" | "exited" | …

    // Tail the last 50 stdout/stderr lines.
    const tailed = await logs(id, { tail: 50 });
    console.log(tailed.stdout);

    // Run a command inside the container; returns a ContainerLogs
    // handle whose stdout/stderr you can read.
    const r = await exec(id, ["ls", "-la"]);
    console.log(r.stdout);
}
FunctionSignatureNotes
list(all?)(all: boolean) → Promise<ContainerInfo[]>all=true includes stopped containers.
inspect(id)(id: string) → Promise<ContainerInfo>Throws if the container doesn’t exist.
logs(id, opts?)(id, { tail?: number }) → Promise<ContainerLogs>Returns a registry handle to a { stdout, stderr } pair.
exec(id, cmd, opts?)(id, cmd[], { env?, workdir? })Runs a command in the container. Returns a ContainerLogs handle.
stop(id, timeout?)(id, seconds: number)Sends SIGTERM, then SIGKILL after timeout seconds.
start(id)(id)Re-starts a stopped container.
remove(id, force?)(id, force: boolean)force=true is docker rm -f.

Note on the logs and exec return shape: today the FFI returns a registry-id handle into a Vec<ContainerLogs> rather than a JS object. Treat the returned value as opaque — a future ergonomics task will expose .stdout / .stderr directly on the JS side. The ContainerLogs shape over the wire is { stdout: string, stderr: string }.

Image management

import { pullImage, listImages, removeImage } from "perry/container";

async function manageImages(): Promise<void> {
    await pullImage("postgres:16-alpine");
    const images = await listImages();
    console.log(`${images.length} images`);
    await removeImage("postgres:16-alpine", false);
}
FunctionSignature
pullImage(reference)(reference: string) → Promise<void>
listImages()() → Promise<ImageInfo[]>
removeImage(reference, force?)(reference: string, force: boolean) → Promise<void>

When PERRY_CONTAINER_VERIFY_IMAGES=1 is set, every run(), create(), and pullImage() call routes through cosign keyless verification against the Chainguard identity. See Security → Image verification.

Container naming

The default name is {md5(image)[0..8]}-{random_hex8} — a stable 8-character hash of the image plus a per-call random suffix. This is fine for one-off run() calls but makes containers hard to find later unless you set name: explicitly. For anything you’ll re-target later (with inspect, logs, exec, etc.), set name: upfront.

const handle = await run({
  image: "alpine:3.19",
  name: "build-helper",   // ← stable handle
  cmd: ["sh", "-c", "echo 'hi from build-helper'"],
  rm: true,
});

Backend introspection

import { getBackend, detectBackend } from "perry/container";

async function pickBackend(): Promise<void> {
    // Synchronous: returns the canonical name of the active backend
    // (`"docker"`, `"podman"`, `"apple/container"`, `"orbstack"`,
    // `"colima"`, `"lima"`, `"nerdctl"`, …). When called before any
    // async FFI has triggered detection, getBackend() performs a
    // synchronous in-place probe with the same 2 s timeout per
    // candidate that detectBackend() uses, so the result is live.
    console.log(`backend: ${getBackend()}`);

    // Async + verbose: returns a JSON array of every probed backend
    // with availability + version + reason for unavailable ones. Use
    // this when you want to surface a "diagnostics" panel to the user.
    const probed = await detectBackend();
    console.log(probed);
}

getBackend() is synchronous and returns the canonical backend name ("docker", "podman", "apple/container", etc.). It will perform a synchronous in-place probe on first call so the result is always the live name; calls after the first hit a cached OnceLock and return instantly.

detectBackend() is async and returns a JSON array of every probed candidate with { name, available, reason, version, mode, isolationLevel } per entry. Use it to surface a “diagnostics” view in your CLI / dashboard.

See also

Compose Orchestration (perry/compose)

perry/compose brings the docker compose up / down / ps / exec / logs workflow into TypeScript. The spec is a TS object literal that mirrors the Compose Specification, the engine is in-process Rust (no shell-out to a docker-compose binary), and dependency ordering / rollback / interpolation all run natively.

Bringing up a single-service stack

import { up } from "perry/compose";

async function bringUpSimpleStack(): Promise<void> {
    const stack = await up({
        version: "3.8",
        services: {
            cache: {
                image: "redis:7-alpine",
                ports: ["6379:6379"],
                networks: ["app-net"],
                healthcheck: {
                    test: ["CMD", "redis-cli", "PING"],
                    interval: "5s",
                    timeout: "3s",
                    retries: 6,
                },
            },
        },
        networks: {
            "app-net": { driver: "bridge" },
        },
    });
    // `stack` is an opaque handle (NaN-boxed integer) — pass it as
    // the first arg to `down` / `ps` / `logs` / `exec`.
    console.log(`stack handle: ${String(stack)}`);
}

The handle returned from up() is an opaque integer (NaN-boxed with POINTER_TAG); pass it as the first argument to down / ps / logs / exec. The template-string interpolation ${stack} renders as [object Object] because of the NaN-boxing tag; coerce explicitly with String(stack) if you need to log it.

Multi-service stack with healthcheck-gated startup

import { up as upMulti } from "perry/compose";

async function bringUpMultiServiceStack(): Promise<void> {
    // depends_on with `condition: 'service_healthy'` blocks the
    // dependent service until the dependency's healthcheck reports
    // healthy. Use the map form (not the bare-array form) to pass
    // the condition.
    await upMulti({
        version: "3.8",
        services: {
            db: {
                image: "postgres:16-alpine",
                container_name: "app-db", // stable DNS target for siblings
                environment: {
                    POSTGRES_USER:     "app",
                    POSTGRES_PASSWORD: "${APP_DB_PASSWORD:-changeme}",
                    POSTGRES_DB:       "app",
                },
                volumes: ["app-pgdata:/var/lib/postgresql/data"],
                networks: ["app-db-net"],
                healthcheck: {
                    test: ["CMD-SHELL", "pg_isready -U app -d app"],
                    interval: "5s",
                    timeout: "3s",
                    retries: 10,
                    start_period: "30s",
                },
            },
            api: {
                image: "myorg/api:1.0",
                depends_on: { db: { condition: "service_healthy" } },
                environment: {
                    DATABASE_URL: "postgres://app:changeme@app-db:5432/app",
                },
                ports: ["8080:8080"],
                networks: ["app-db-net", "app-web-net"],
                restart: "unless-stopped",
            },
        },
        networks: {
            "app-db-net":  { driver: "bridge", internal: true }, // db unreachable from host
            "app-web-net": { driver: "bridge" },
        },
        volumes: {
            "app-pgdata": { driver: "local" },
        },
    });
}

This pattern combines several production-grade primitives:

PrimitiveWhat it does
container_name: 'app-db'Forces a stable container name so docker’s embedded DNS resolves app-db to the postgres container’s IP. See the DNS gotcha below.
healthcheck: { test: [...], interval, retries, start_period }Per-service liveness probe. Compose-spec § service.healthcheck shape — Perry’s engine honors it for depends_on gating.
depends_on: { db: { condition: 'service_healthy' } }Holds the dependent service back until the dependency reports healthy. Three valid conditions: service_started, service_healthy, service_completed_successfully.
networks: { ..., internal: true }Marks the network as internal-only — postgres is unreachable from the host or from sibling stacks. See Networking.
restart: 'unless-stopped'The runtime restarts the container after a crash, but not after an explicit docker stop.

The full ComposeSpec shape is exported from perry/compose as ComposeSpec, with sub-types Service, ComposeNetwork, ComposeVolume, Build, and Healthcheck.

Recognised Service fields

The full set Perry’s engine understands (matches compose-spec § services):

interface Service {
  image?: string;
  container_name?: string;
  ports?: string[];                                              // "host:container[:proto]"
  environment?: Record<string, string> | string[];               // map or KEY=VALUE list
  labels?: Record<string, string>;
  volumes?: string[];                                            // "host:container[:ro]" or "named:container"
  build?: Build;                                                 // { context, dockerfile, args, … }
  depends_on?: string[] | Record<string, { condition?: string }>;
  restart?: "no" | "always" | "on-failure" | "unless-stopped";
  entrypoint?: string | string[];
  command?: string | string[];
  networks?: string[];
  healthcheck?: Healthcheck;
  user?: string;
  working_dir?: string;
  read_only?: boolean;
  privileged?: boolean;
  cap_add?: string[];
  cap_drop?: string[];
}

Healthcheck shape

interface Healthcheck {
  test?: string[];           // ["CMD", "<cmd>", ...] | ["CMD-SHELL", "<line>"] | ["NONE"]
  interval?: string;         // Go duration: "5s", "2m", "1h30m"
  timeout?: string;
  retries?: number;
  start_period?: string;     // grace period before retries count
  disable?: boolean;
}

Environment variable interpolation

Compose’s ${VAR} and ${VAR:-default} placeholders work in TS-side specs too — Perry expands them against process.env at the FFI boundary, before the JSON gets parsed:

import { up as upEnv } from "perry/compose";

// Compose YAML interpolation (`${VAR}` / `${VAR:-default}`) is applied
// to TS-side specs at the FFI boundary too — set `process.env` keys
// before calling up() and they'll resolve in the spec values.
async function envInterpolatedStack(): Promise<void> {
    await upEnv({
        version: "3.8",
        services: {
            web: {
                image: "nginx:${NGINX_VERSION:-alpine}",
                ports: ["${WEB_PORT:-8080}:80"],
                environment: {
                    SERVER_NAME: "${WEB_DOMAIN:-localhost}",
                },
            },
        },
    });
}

Set the env vars before invoking your binary:

NGINX_VERSION=1.27 WEB_PORT=9000 ./my-stack

Without this, the literal string "${NGINX_VERSION:-alpine}" would flow through to docker as the image tag and the pull would fail.

Cross-service DNS

Each service registers its service key (db, api, …) as a network alias automatically — Perry’s engine emits --network-alias <key> per service per network on every run. So this just works:

api: {
  image: "myapp/api",
  environment: {
    // ✅ "db" resolves in DNS via the auto-registered service-key alias
    DATABASE_URL: "postgres://user:pw@db:5432/app",
  },
}

container_name is no longer required for cross-service DNS. You can still set one if you want a stable name visible to docker ps, but the service key alone is enough for in-network resolution. Pre-v0.5.372 docs described a workaround using container_name pinning — that pattern still works but is now optional.

Tearing down

import { down } from "perry/compose";

async function tearDown(stack: number): Promise<void> {
    // Default: containers + networks removed; named volumes preserved
    // so a subsequent `up()` against the same spec resumes from
    // committed state.
    await down(stack);

    // Pass `volumes: true` to also drop named volumes — DESTROYS DATA.
    // Useful for test teardown or for a "rip and replace" redeploy.
    await down(stack, { volumes: true });
}

down(handle) removes containers and networks, and preserves named volumes by default. Pass { volumes: true } to also drop the volumes (destroys committed data — use only for “rip and replace” redeploy or test cleanup).

down optionTypeDefaultEffect
volumesbooleanfalseAlso remove named volumes after containers + networks.
removeOrphansbooleanfalseRemove containers labelled with this stack’s project but not in the current spec.

Status / logs / exec

import {
    ps,
    logs as composeLogs,
    exec as composeExec,
    config,
    start,
    stop,
    restart,
} from "perry/compose";

async function manageStack(stack: number): Promise<void> {
    // Status of every service in the stack (returns a registry
    // handle to a ContainerInfo[]; user-side array materialisation
    // is a follow-up ergonomics task).
    const statusHandle = await ps(stack);
    console.log(statusHandle);

    // Aggregated logs from one or all services.
    await composeLogs(stack, { service: "db", tail: 200 });

    // Exec a command inside a service's container by service KEY
    // (not container name) — the engine resolves the service to its
    // running container internally.
    await composeExec(stack, "db", ["pg_isready"]);

    // Resolved YAML the engine actually used (post-interpolation).
    const yaml = await config(stack);
    console.log(yaml);

    // Stop / start / restart by service key. `services: []` (or
    // omitted) targets every service in the stack.
    await stop(stack, ["api"]);
    await start(stack, ["api"]);
    await restart(stack, []);
}

Like perry/container.{logs, exec}, the compose logs and exec return registry-id handles for the ContainerLogs array. Treat them as opaque for now; user-side materialisation is a planned ergonomics task.

FunctionSignature
ps(handle)(handle) → Promise<ContainerInfo[]>
logs(handle, opts?)(handle, { service?, tail? }) → Promise<ContainerLogs>
exec(handle, service, cmd[])(handle, service, cmd[]) → Promise<ContainerLogs>
config(handle)(handle) → Promise<string> (resolved YAML)
start(handle, services?)(handle, services?: string[]) → Promise<void>
stop(handle, services?)(handle, services?: string[]) → Promise<void>
restart(handle, services?)(handle, services?: string[]) → Promise<void>
down(handle, opts?)(handle, { volumes?, removeOrphans? }) → Promise<void>

exec targets a service by its service key (e.g. 'db', not the container name) — the engine resolves the key to its tracked container name internally.

Idempotency

up() is idempotent: if a service is already running with a matching configuration, it’s left alone; if it exists but is stopped, it’s started; only when it doesn’t exist at all is it created from scratch. This makes “redeploy” a no-op-or-restart operation rather than a tear-down-and-recreate.

⚠️ Idempotency works at the service granularity, not field-level. If you change the spec (e.g. update an image tag), you’ll want down(handle, { volumes: false }) followed by up(newSpec) so the old containers are replaced with the new image.

Waiting for readiness

up() returns as soon as the engine has started every service — not when each service is ready. To block until the stack is serving:

  1. Use the healthcheck block on the service (built-in, runtime handles it). Combined with depends_on: { svc: { condition: 'service_healthy' } }, dependent services wait for the dependency to report healthy.
  2. Run an explicit probe loop in your code. The Forgejo example does this for both postgres (pg_isready) and Forgejo (/api/healthz over HTTP), each with its own timeout budget.

Errors and rollback

If any service fails to start, the engine rolls back the entire stack: every container created during this up() call is stopped + removed, every network created is removed, and (subject to the standard session_volumes semantics) created volumes are removed too. The returned Promise rejects with a ServiceStartupFailed containing the failing service name and the underlying backend error.

try {
  const stack = await up({ /* … */ });
} catch (err: any) {
  // err.message is "Service '<name>' failed to start: <reason>"
  console.error(err);
  process.exit(1);
}

See also

Networking

Compose stacks join one or more user-defined networks. Each container spec lists the networks it joins; the engine creates the networks (if they don’t already exist) before starting any service. This page covers the day-to-day networking patterns Perry users hit.

Defining networks

const stack = await up({
  version: "3.8",
  services: {
    api: { image: "myapp/api", networks: ["app-net"] },
    db:  { image: "postgres:16-alpine", networks: ["app-net"] },
  },
  networks: {
    "app-net": { driver: "bridge" },
  },
});

Recognised ComposeNetwork fields:

FieldTypeEffect
driverstringNetwork driver ("bridge" is the default; "overlay" for swarm).
externalbooleanDon’t create — assume the network already exists.
namestringOverride the network’s runtime name.
internalbooleanInternal-only: containers attached have no external bridge or routing. See below.
driver_optsRecord<string, string>Driver-specific options.
labelsRecord<string, string>Network labels.

Internal-only networks (internal: true)

A network with internal: true blocks egress to anything outside the network. Containers on it can talk to each other, but cannot reach the host or the public internet, and the host cannot reach them via published ports. This is the canonical “private database side-channel” pattern:

networks: {
  "app-db-net":  { driver: "bridge", internal: true },  // db <-> api only
  "app-web-net": { driver: "bridge" },                  // api <-> host
},
services: {
  db: {
    image: "postgres:16-alpine",
    networks: ["app-db-net"],   // db is reachable ONLY from app-db-net
    // no `ports:` — postgres is unpublished
  },
  api: {
    image: "myapp/api",
    networks: ["app-db-net", "app-web-net"],
    ports: ["8080:8080"],       // api published on the host
  },
},

The api container straddles both networks: it can reach db over app-db-net and accept inbound HTTP from the host on app-web-net. postgres is invisible to anything not on app-db-net.

Cross-service DNS

Within a user-defined bridge network, docker’s embedded DNS resolves container names to IP addresses. So if a service’s container_name is forgejo-db, sibling containers on the same network can connect to it as forgejo-db:5432.

⚠️ Important: Perry’s compose engine generates per-service container names of the form {md5(image)[0..8]}-{random_hex8} by default. It does not (yet) register the service KEY (db, api, …) as a network alias the way docker compose does. So a config like:

api: {
  image: "myapp/api",
  environment: {
    DATABASE_URL: "postgres://user:pw@db:5432/app",  // ❌ "db" doesn't resolve
  },
}

will fail at runtime with dial tcp: lookup db on 127.0.0.11:53: no such host. Until service-key network aliasing lands, set container_name explicitly and use those names in sibling URLs:

// IMPORTANT: Perry's compose engine creates each container with a
// `{md5}-{random_hex}` derived name and DOES NOT (yet) register the
// service KEY (`db`, `api`, …) as a network alias. So
// `DATABASE_URL: 'postgres://user:pw@db:5432/app'` would fail name
// resolution at runtime. Two ways to make sibling-DNS work:
//
//   (a) Set `container_name` explicitly on each service so the
//       chosen name is what Docker's embedded DNS resolves. This is
//       the simplest pattern and is what the Forgejo example uses.
//
//   (b) Wait for service-key network-alias support (planned).
//
// Until (b) lands, prefer (a):
import { up as upDns } from "perry/compose";

async function dnsAwareStack(): Promise<void> {
    await upDns({
        version: "3.8",
        services: {
            db: {
                image: "postgres:16-alpine",
                container_name: "myapp-db", // ← stable DNS target
                networks: ["myapp-net"],
                environment: { POSTGRES_PASSWORD: "x" },
            },
            api: {
                image: "myapp/api",
                container_name: "myapp-api",
                networks: ["myapp-net"],
                environment: {
                    // Use the container_name as the hostname:
                    DATABASE_URL: "postgres://postgres:x@myapp-db:5432/postgres",
                },
            },
        },
        networks: { "myapp-net": { driver: "bridge" } },
    });
}

The Forgejo example uses this pattern (container_name: 'forgejo-db' + FORGEJO__database__HOST: 'forgejo-db:5432'). It’s a documented workaround that keeps user code idiomatic; replacing container_name with service-key alias registration is a planned runtime change that will not require any user-facing API change.

Port mapping

Inside a service spec, ports: ["host:container[:proto]"] publishes ports to the host. Examples:

SpecBehavior
"8080:80"Host port 8080 → container port 80 (TCP).
"8080:80/udp"Host port 8080 → container port 80 (UDP).
"127.0.0.1:8080:80"Bind only to loopback on the host (don’t expose to other LAN hosts).
"3000-3010:3000-3010"Range mapping (UDP/TCP, host:container both inclusive).

For services that should never be host-published (private databases, internal-only side-cars), simply don’t list any ports. Combined with internal: true on the network, those services are unreachable from the host even if a port slipped into the spec by mistake.

Single-network shorthand

When every service joins the same network, you can put networks: ['<name>'] on each service and networks: { <name>: {...} } once at the root. The engine deduplicates network creation across services.

Networks created in this session vs. external

Perry tracks session networks (created during this up() call) and distinguishes them from external: true networks (assumed pre-existing and shared across stacks). On down(), only session networks are torn down — external networks are left alone, matching docker-compose semantics.

networks: {
  // Session: created if missing; removed on down()
  "app-net": { driver: "bridge" },

  // External: must already exist; never touched on down()
  "shared-public-net": { external: true, name: "external_pub_v1" },
},

Network options for production

Common per-network knobs you’ll want for production:

PatternSpec
Disable masquerade / NAT (host-side)driver_opts: { "com.docker.network.bridge.enable_ip_masquerade": "false" }
Custom MTU (matches host network)driver_opts: { "com.docker.network.driver.mtu": "1450" }
Stable bridge name (for iptables rules)driver_opts: { "com.docker.network.bridge.name": "br-myapp" }
Tag for monitoringlabels: { team: "platform", environment: "prod" }

See also

Volumes

Container filesystems are ephemeral by default — once a container is removed, anything written to its layers is gone. Production deployments need volumes for the data that should survive container restarts + upgrades: database storage, uploaded files, generated config, etc.

Perry supports the three Compose-spec volume modes:

ModeSpec exampleUse case
Named volume["app-pgdata:/var/lib/postgresql/data"]Database state, durable per-app data.
Bind mount["./config:/app/config:ro"]Host-supplied config or secrets.
System pass-through["/etc/timezone:/etc/timezone:ro"]Read-only access to host system files.

Declaring named volumes

Named volumes must be declared at the spec root and referenced by name in each service’s volumes array:

const stack = await up({
  services: {
    db: {
      image: "postgres:16-alpine",
      volumes: ["app-pgdata:/var/lib/postgresql/data"],
    },
  },
  volumes: {
    "app-pgdata": { driver: "local" },
  },
});

Recognised ComposeVolume fields:

FieldTypeEffect
driverstringVolume driver ("local" is the default).
externalbooleanDon’t create — assume the volume already exists.
namestringOverride the volume’s runtime name.

Bind mounts

For host-supplied data, use the host:container[:options] form:

volumes: [
  "./config:/app/config:ro",     // read-only config dir from host
  "/var/log/myapp:/app/logs",    // bidirectional logs
],

Permissions are governed by the host filesystem and the container’s running UID. If the container runs as a non-root user (as it should — see Security), make sure the host directory is owned by a matching UID, or explicitly set the container UID via USER_UID / USER_GID env vars in the image (the Forgejo image does this).

System pass-throughs

Read-only mounts of host system files are common for time / DNS / locale alignment:

volumes: [
  "/etc/timezone:/etc/timezone:ro",
  "/etc/localtime:/etc/localtime:ro",
],

Best-effort: hosts where the source path doesn’t exist (e.g. some minimal Alpine VMs) just see a missing mount source — docker tolerates it; the container falls back to UTC / system defaults.

Preservation on down()

By default, down(handle) preserves named volumes:

await down(stack);                       // containers + networks gone, volumes survive
await down(stack, { volumes: false });   // same — explicit preserve
await down(stack, { volumes: true });    // ⚠ volumes ALSO removed (DESTROYS DATA)

This matches docker compose down semantics:

CommandContainersNetworksVolumes
down(handle)removedremovedkept
down(handle, { volumes: true })removedremovedremoved

After a down(handle), you can up(spec) again with the same volume declarations and the database / file state from before is still there. That’s how the Forgejo example supports “deploy → tear-down → redeploy” cycles without data loss.

⚠️ Forgejo / Postgres redeploy gotcha: if you used randomly generated passwords or secret keys on the first deploy, the next redeploy with new random secrets will fail because postgres authenticates against the old password and Forgejo can’t decrypt the existing config dir with a different SECRET_KEY. For redeploys against the same volumes, set FORGEJO_DB_PASSWORD / FORGEJO_SECRET_KEY / FORGEJO_INTERNAL_TOKEN to stable values (e.g. via an .env file). The Forgejo example’s doc-comment has the canonical pattern.

External volumes

Mark a volume external: true to share it across stacks or to use a volume created by a different process (e.g. docker volume create team-shared-cache ahead of time):

volumes: {
  "shared-cache": { external: true, name: "team-shared-cache" },
},

External volumes are never removed by down(handle, { volumes: true }) — that flag only drops volumes the engine itself created. This matches docker-compose semantics; if you want the external volume gone, remove it explicitly with docker volume rm team-shared-cache.

Volume naming and ownership

Perry doesn’t currently namespace volume names by project — the name you write in the spec is the literal docker volume name. So forgejo-pgdata is created as the docker volume forgejo-pgdata, and two stacks both declaring forgejo-pgdata would share it.

For multi-stack isolation, prefix the volume name with the project / stack identifier:

volumes: {
  "myapp-staging-pgdata":   { driver: "local" },
  "myapp-production-pgdata": { driver: "local" },
},

Inspecting volume state

The perry/container and perry/compose modules don’t expose a JS inspectVolume() helper today — for now, inspect with the underlying runtime CLI:

docker volume ls --filter name=app-       # list app-prefixed volumes
docker volume inspect app-pgdata          # mountpoint, driver, labels
docker run --rm -v app-pgdata:/data \      # mount + inspect contents
  alpine ls -la /data

Backup patterns

The standard “tar the volume into the host” backup recipe:

docker run --rm -v app-pgdata:/data:ro -v $(pwd):/backup alpine \
  tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .

For a pure-Perry approach, drive that with perry/container.run():

await run({
  image: "alpine:3.19",
  cmd: ["sh", "-c",
    "tar czf /backup/pgdata-$(date +%F).tar.gz -C /data ."],
  volumes: [
    "app-pgdata:/data:ro",
    "./backups:/backup",
  ],
  rm: true,
});

See also

Security

Containers don’t isolate themselves; you isolate them. Perry exposes the standard OCI security knobs on both ContainerSpec (single-container) and ComposeService (orchestrated stacks), plus first-party support for Sigstore / cosign image verification and a workload-graph policy tier API for declarative isolation levels.

Per-container security knobs

The same set of fields work on run(), create(), and any service in a compose up():

FieldTypeEffectCross-backend
read_onlybooleanMount the root filesystem as read-only. Forces all writable state to be in declared volumes.All backends
privilegedbooleanRun privileged: grants ALL Linux capabilities + access to host devices. Avoid unless absolutely necessary.Docker / Podman / Lima only — apple/container has no concept and drops the field with a warning
userstringUID, username, or "UID:GID" — runs the container’s processes as that identity. The image’s CMD ignores this if it does its own user-switching, but most properly-built images respect it.All backends
workdirstringWorking directory inside the container.All backends
cap_addstring[]Linux capabilities to add. Specific (e.g. ["NET_BIND_SERVICE"]), not blanket.All backends
cap_dropstring[]Capabilities to drop. ["ALL"] is the canonical “drop everything” starting point.All backends
seccompstringSeccomp profile path or "default" (uses the runtime’s default profile).Docker / Podman / Lima only — apple/container has no equivalent and drops the field with a warning

⚠️ Cross-backend security caveat. privileged, seccomp, --security-opt no-new-privileges, IPC/PID namespace sharing, and SELinux mount labels are not honored on apple/container — its Apple-VM model means those concepts don’t translate. Perry’s normalization pass drops the fields and emits a tracing::warn! rather than silently downgrading the security policy. For production deployments that demand cross-backend parity, set EnforcementMode::Strict on the engine — any unsupported security field becomes a hard up() failure rather than a silent drop. Full matrix at Cross-Backend Determinism.

Start with maximum isolation and add back only what the workload needs:

import { run as runSecure } from "perry/container";

// Maximum-isolation single-container run for an untrusted workload:
//   - read-only root filesystem
//   - no Linux capabilities at all
//   - non-root user
//   - working directory pinned
//   - default seccomp profile
async function runUntrustedWorkload(): Promise<void> {
    await runSecure({
        image: "alpine:3.19",
        cmd: ["sh", "-c", "echo isolated && exit 0"],
        read_only: true,
        cap_drop: ["ALL"],
        user: "nobody",
        workdir: "/tmp",
        seccomp: "default",
    });
}

Field-by-field rationale:

  • read_only: true — even an exploit that lands code execution can’t persist to the image’s filesystem. Anything mutable goes into a declared volume.
  • cap_drop: ["ALL"] — removes Linux capabilities the workload didn’t explicitly ask for. Most apps need none.
  • user: "nobody" — non-root inside the container. If the image doesn’t have a nobody user, replace with "65534:65534" (the numeric UID/GID of nobody on most distros).
  • workdir: "/tmp" — the only writable location under read_only: true is /tmp (which is tmpfs-backed by default).
  • seccomp: "default" — uses docker’s default seccomp profile (~50 syscalls blocked).

Capability addition patterns

cap_drop: ["ALL"] plus targeted cap_add:

WorkloadCapabilities
Web server binding to port 80/443cap_add: ["NET_BIND_SERVICE"]
Network namespace manipulationcap_add: ["NET_ADMIN"]
Kernel time settingcap_add: ["SYS_TIME"]
chown to other users (rare)cap_add: ["CHOWN"]
Bind-mount filesystems insidecap_add: ["SYS_ADMIN"] (still avoid if possible)

The full capability list is in man capabilities(7). Always start with cap_drop: ["ALL"] and add only what fails when removed — most applications need zero capabilities.

Image verification

Set PERRY_CONTAINER_VERIFY_IMAGES=1 to enable cosign keyless verification on every run(), create(), and pullImage() call:

export PERRY_CONTAINER_VERIFY_IMAGES=1
./my-app

Perry’s verifier:

  1. Resolves the image tag to its digest via inspect_image.
  2. Looks up the digest in an in-memory VERIFICATION_CACHE — subsequent runs against the same digest are free.
  3. Runs cosign verify --certificate-identity ${CHAINGUARD_IDENTITY} --certificate-oidc-issuer ${CHAINGUARD_ISSUER} <ref>@<digest> and caches pass/fail.
  4. On fail, the FFI rejects with a verification failed error (the container is never created).

Default identity / issuer point at Chainguard’s keyless signing flow:

ConstValue
CHAINGUARD_IDENTITYhttps://github.com/chainguard-images/images/.github/workflows/sign.yaml@refs/heads/main
CHAINGUARD_ISSUERhttps://token.actions.githubusercontent.com

For your own org’s images, override these via the (planned) per-call verification options. For now, using Chainguard-signed base images is the path of least resistance — cgr.dev/chainguard/<tool> is signed.

Cosign required. Set PERRY_CONTAINER_VERIFY_IMAGES=1 only when cosign is installed and on PATH. The verification is OFF by default so the bare-metal ./my-app execution doesn’t depend on a separate cosign install.

Capability sandbox helper

For one-off command execution against an untrusted image (CI helper, build tool, code-evaluation sandbox), use the run_capability pattern which wraps run() with the maximum-isolation defaults:

  • read_only: true
  • cap_drop: ["ALL"]
  • No network attached
  • user: "nobody"
  • Image verified via cosign before pull

This is the same path the internal perry-stdlib::container::capability module uses for shell-command sandboxing in plugin systems.

Workload-graph policy tiers (perry/workloads)

For multi-node deployments where different workloads have different trust levels, the workload-graph engine accepts a per-node policy:

import { graph, runGraph, runtime, policy } from "perry/workloads";

const g = graph("my-app", {
  trusted_db:    { image: "postgres:16-alpine",
                   runtime: runtime.oci(),
                   policy:  policy.default() },        // no extra hardening

  isolated_api:  { image: "myapp/api",
                   runtime: runtime.oci(),
                   policy:  policy.isolated() },       // no_network=true

  hardened_proxy: { image: "myapp/proxy",
                    runtime: runtime.oci(),
                    policy:  policy.hardened() },      // read_only_root + seccomp

  untrusted_eval: { image: "myapp/sandbox",
                    runtime: runtime.microvm(),         // ← required by tier
                    policy:  policy.untrusted() },     // microVM-only, all hardening on
});

await runGraph(g);

The four PolicyTier levels and what they enforce:

Tierno_networkread_only_rootseccompmicrovm
default()
isolated()
hardened()
untrusted()required

untrusted requires kernel-level isolation (i.e. a microVM, not a shared-kernel container). When the active backend doesn’t expose a microVM runtime (apple/container’s VM mode, Lima, Firecracker), the engine returns BackendNotAvailable rather than silently dropping the isolation guarantee. Use PERRY_ALLOW_UNTRUSTED_SHARED_KERNEL=1 to opt out — not recommended for actually-untrusted code.

User-explicit per-flag overrides on top of a tier are honored: setting policy.tier = "default" and no_network: true produces an isolated-network default-tier node.

Defense in depth

Stacking patterns for production:

  1. Verify images (PERRY_CONTAINER_VERIFY_IMAGES=1).
  2. Run as non-root (user: "nobody" or numeric UID).
  3. Drop all capabilities, add specific ones back (cap_drop: ["ALL"] + minimal cap_add).
  4. Read-only root filesystem (read_only: true).
  5. Internal networks for the database side (internal: true on the db’s network — see Networking).
  6. No published ports for private services (omit ports: on internal-only services).
  7. Resource limits (planned: mem_limit, cpu_limit on Service).

See also

Production Patterns

This page is a guided tour of example-code/forgejo-deployment, a working production-quality deployment of Forgejo (self-hosted Git) using the real Forgejo image from the official data.forgejo.org registry. The example was driven end-to-end against live Docker; the patterns here are what survived.

The full source is at example-code/forgejo-deployment/main.ts. This page documents the patterns, not every line.

Lifecycle: up + verify + exit 0 then a separate --down

Perry’s runtime currently does not deliver process.on('SIGINT', ...) to your TS code. So the canonical “Ctrl-C tears down the stack” pattern isn’t writable today. Instead, follow the docker compose up -d / docker compose down model: deploy + verify + exit 0, with teardown behind a separate --down invocation:

async function main() {
  const args = process.argv.slice(2);
  const config = buildConfig();
  if (args.includes("--down")) {
    await cmdDown(config);
  } else {
    await cmdUp(config);
  }
}

The example’s cmdUp:

  1. Pre-flight backend probe + port-conflict guard.
  2. Call up() with the canonical spec.
  3. Poll readiness probes (postgres pg_isready, then forgejo /api/healthz).
  4. Print an operator-facing banner with URLs + “how to tear down”.
  5. Exit 0. Containers keep running thanks to restart: unless-stopped.

The example’s cmdDown:

  1. Re-call up() with the same spec — idempotent: services already running are detected and skipped, returning the same handle the original deploy got.
  2. Call down(handle, { volumes: destroy }). destroy is set from FORGEJO_DESTROY_ON_EXIT=1.

Two-network split: internal db + public web

The Forgejo example puts postgres on an internal-only network and forgejo on both that network and a public bridge:

networks: {
  "forgejo-db-net":  { driver: "bridge", internal: true }, // postgres unreachable from host
  "forgejo-web-net": { driver: "bridge" },                 // forgejo's web + SSH ports
},
services: {
  db: {
    networks: ["forgejo-db-net"],
    // no `ports:` — postgres is invisible to the host
  },
  forgejo: {
    networks: ["forgejo-db-net", "forgejo-web-net"],
    ports: ["3000:3000", "2222:22"],  // public web + SSH
  },
},

Why: postgres should never be reachable from the host (or from sibling stacks), but forgejo needs both inbound HTTP from the host AND outbound DB queries to postgres. Two networks is the cleanest expression of that split.

Stable container names for cross-service DNS

Perry’s compose engine creates each container with a {md5}-{random} derived name and doesn’t yet register the service KEY (db, forgejo) as a network alias. So FORGEJO__database__HOST: 'db:5432' would fail name resolution at runtime. The Forgejo example pins explicit container_name values:

const dbHostname      = "forgejo-db";
const forgejoHostname = "forgejo-app";

services: {
  db: {
    image: `postgres:${pgVersion}`,
    container_name: dbHostname,                  // ← stable target
    // …
  },
  forgejo: {
    image: `data.forgejo.org/forgejo/forgejo:${version}`,
    container_name: forgejoHostname,
    environment: {
      FORGEJO__database__HOST: `${dbHostname}:5432`,  // ← refers to it
      // …
    },
  },
},

See Networking → Cross-service DNS for the full backstory and why this is the workaround until service-key network-alias support lands.

OpenSSH on :22 + START_SSH_SERVER=false

Forgejo’s official image runs /usr/sbin/sshd on container port 22 in its entrypoint script, then runs the forgejo binary. If you also set FORGEJO__server__START_SSH_SERVER=true, forgejo’s Go-based built-in SSH server tries to bind :22 too — and the container exit-0’s with “bind: address already in use”.

The standard Forgejo deployment pattern is to let OpenSSH handle SSH on :22 and tell forgejo not to start its own:

environment: {
  FORGEJO__server__START_SSH_SERVER: "false",   // ← critical
  FORGEJO__server__SSH_PORT:         "2222",    // public host port
  FORGEJO__server__SSH_LISTEN_PORT:  "22",      // container-internal port
  // …
},
Forgejo writes git users’ authorized_keys to /data/git/.ssh/, which the in-container OpenSSH consumes. Git operations route through sshd on
22, then forgejo’s gitea-shell script.

Healthcheck-gated dependency startup

postgres takes ~5–10 seconds to initialise on first run (initdb + listener bind). Without gating, forgejo starts immediately, can’t connect, and burns retry budget. The fix is a per-service healthcheck plus depends_on: { svc: { condition: 'service_healthy' } }:

db: {
  image: "postgres:16-alpine",
  // …
  healthcheck: {
    test: ["CMD-SHELL", "pg_isready -U forgejo -d forgejo"],
    interval: "5s",
    timeout: "3s",
    retries: 10,
    start_period: "30s",
  },
},
forgejo: {
  // …
  depends_on: { db: { condition: "service_healthy" } },
},

Even with that, the example also runs an explicit readiness loop post-up() for the full HTTP /api/healthz path — the healthcheck gates container startup but the operator banner shouldn’t print until the API is serving:

async function waitForForgejo(stack: number, timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      // Probe from INSIDE the forgejo container so the docker-proxy
      // bind-up window doesn't trip the host-side curl.
      await exec(stack, "forgejo", [
        "wget", "-q", "-O", "/dev/null",
        "--timeout=2", "--tries=1",
        "http://127.0.0.1:3000/api/healthz",
      ]);
      return true;
    } catch (_e) {
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
  return false;
}

/api/healthz is Forgejo’s no-auth liveness endpoint that returns 200 once the web server is up AND the database / cache subsystems pinged successfully. Don’t use /api/v1/version — when REQUIRE_SIGNIN_VIEW=true (a production-hardening default) it returns 401, and wget exits non-zero on HTTP error responses.

Stable secrets for redeploy

The Forgejo example’s buildConfig() uses truthy-fallback semantics for env vars (process.env[name] || fallback) because Perry’s process.env[NONEXISTENT] returns an empty-ish value where strict equality to undefined / '' doesn’t hold:

function envOr(name: string, fallback: string): string {
  return (process.env[name] as string | undefined) || fallback;
}

The defaults for the three secret-bearing fields are random hex:

dbPassword:      envOr('FORGEJO_DB_PASSWORD', randomHex(32)),
secretKey:       envOr('FORGEJO_SECRET_KEY',     randomHex(32)),
internalT:       envOr('FORGEJO_INTERNAL_TOKEN', randomHex(52)),

This is fine for first-run / dev / smoke-test, but breaks any subsequent run against the same volumes because:

  • Postgres rows were authored under the prior password — new password rejects the connection.
  • Forgejo’s /data/gitea/conf/app.ini is encrypted with the prior SECRET_KEY — Forgejo can’t decrypt it on startup.

For production, set them to stable values via an .env file or a secrets manager:

# .env
FORGEJO_DB_PASSWORD=$(openssl rand -hex 32)
FORGEJO_SECRET_KEY=$(openssl rand -hex 32)
FORGEJO_INTERNAL_TOKEN=$(openssl rand -hex 52)

# deploy.sh
source .env
./forgejo_app

Generate once, store in a secrets manager, redeploy as many times as needed against the same volumes.

First-run admin user

Forgejo’s installer is locked (INSTALL_LOCK=true) so the GUI installer doesn’t run on first request. To create the initial admin user, exec the forgejo admin user create CLI inside the container:

docker exec forgejo-app forgejo admin user create \
  --admin --username root --email root@example.com \
  --random-password

The --random-password flag prints the generated password to stdout once — capture it from the docker logs and store it somewhere safe.

Idempotent redeploy

Running ./forgejo_app a second time on a healthy stack is a no-op: up() calls inspect on each service, sees running, and skips. The operator banner prints immediately and the readiness loops exit fast because the services are already serving. This is by design — it’s the same property docker compose up -d has.

For a “rip and replace” upgrade (new image tag, new env values that require recreate), do an explicit --down first:

./forgejo_app --down                        # preserve volumes
FORGEJO_VERSION=12 ./forgejo_app            # redeploy with new version

The volumes carry forward automatically; up() detects the existing forgejo-data and forgejo-pgdata volumes via inspect_volume and attaches them to the new containers without re-creating.

Running it

# Build perry once
cargo build --release -p perry-runtime -p perry-stdlib -p perry

# Build the example
cd example-code/forgejo-deployment
../../target/release/perry compile main.ts -o forgejo_app

# Deploy
./forgejo_app
# 🔧 Backend: docker
# 🚀 Deploying Forgejo 11 (data.forgejo.org/forgejo/forgejo:11)
# …
# 🎉  Forgejo 11 is up and ready.

# Visit http://localhost:3000/ in a browser.

# Tear down (preserves volumes for redeploy):
./forgejo_app --down

# Tear down + drop volumes (DESTROYS DATA):
FORGEJO_DESTROY_ON_EXIT=1 ./forgejo_app --down

See also

Internationalization (i18n)

Perry’s i18n system lets you write natural English strings and have them automatically translated at compile time. Zero ceremony, near-zero runtime cost.

// String literals in UI component calls are automatically localizable keys.
// (Conceptually the docs write `Button("Next")` / `Text("Hello, {name}!", ...)`
// from "perry/ui"; we exercise the runtime API that backs them — t() — here.)
const next = t("Next")                                  // Automatically localized
const hello = t("Hello, {name}!", { name: "Alice" })    // With interpolation
console.log(next, hello)

The same key-resolution and interpolation runs through the explicit t() API for non-UI strings:

import { t, Currency, Percent, ShortDate, LongDate, FormatNumber, FormatTime, Raw } from "perry/i18n"

Design Principles

  • Zero ceremony: String literals in UI components are automatically localizable keys
  • Compile-time validation: Missing translations, parameter mismatches, and plural form errors caught during build
  • Embedded string table: All translations baked into the binary as a flat 2D table. Near-zero runtime lookup cost
  • Platform-native locale detection: Uses OS APIs on every platform (no env vars needed on mobile)

Quick Start

1. Add i18n config to perry.toml

[i18n]
locales = ["en", "de"]
default_locale = "en"

2. Extract strings from your code

perry i18n extract src/main.ts

This scans your source files and creates locales/en.json and locales/de.json:

// locales/en.json
{
  "Next": "Next",
  "Back": "Back"
}

// locales/de.json (empty values = needs translation)
{
  "Next": "",
  "Back": ""
}

3. Translate

Fill in locales/de.json:

{
  "Next": "Weiter",
  "Back": "Zurck"
}

4. Build

perry compile src/main.ts -o myapp

Perry validates all translations at compile time and bakes them into the binary. At runtime, the app detects the user’s system locale and shows the right language.

How It Works

  1. Detection: String literals in UI component calls (Button, Text, Label, etc.) are automatically treated as i18n keys
  2. Transform: The compiler replaces Expr::String("Next") with Expr::I18nString { key: "Next", string_idx: 0 } in the HIR
  3. Codegen: For each I18nString, the compiler emits a locale branch that selects the correct translation at runtime
  4. Locale detection: At startup, perry_i18n_init() detects the system locale via native APIs and sets the global locale index

Locale Detection

PlatformMethod
macOSCFLocaleCopyCurrent() (CoreFoundation)
iOSCFLocaleCopyCurrent() (CoreFoundation)
Android__system_property_get("persist.sys.locale")
WindowsGetUserDefaultLocaleName() (Win32)
LinuxLANG / LC_ALL / LC_MESSAGES env vars

The detected locale is fuzzy-matched against your configured locales: de_DE.UTF-8 matches de, en-US matches en, etc.

Platform Output

When compiling for mobile targets, Perry generates platform-native locale resources alongside the binary:

PlatformOutput
iOS/macOS{locale}.lproj/Localizable.strings inside .app bundle
Androidres/values-{locale}/strings.xml
DesktopStrings embedded in binary (no extra files)

Next Steps

Interpolation & Plurals

Parameterized Strings

Use {param} placeholders in your strings and pass values as a second argument:

// Use {param} placeholders in your strings and pass values as a second arg.
const greeting = t("Hello, {name}!", { name: "Alice" })
const total = t("Total: {price}", { price: 23.10 })
console.log(greeting, total)

Translation files use the same {param} syntax:

// locales/en.json
{
  "Hello, {name}!": "Hello, {name}!",
  "Total: {price}": "Total: {price}"
}

// locales/de.json
{
  "Hello, {name}!": "Hallo, {name}!",
  "Total: {price}": "Gesamt: {price}"
}

Parameters are substituted at runtime after the locale-appropriate template is selected. The substitution handles any value type (numbers, strings, dates) by converting to string.

Compile-Time Validation

Perry validates parameters across all locales during compilation:

ConditionSeverity
{param} in translation but not provided in codeError
Param in code but {param} not in translationError
Parameter set differs between locales for same keyError

Plural Rules

Plural forms use dot-suffix keys based on CLDR plural categories: .zero, .one, .two, .few, .many, .other.

Locale Files

// locales/en.json
{
  "You have {count} items.one": "You have {count} item.",
  "You have {count} items.other": "You have {count} items."
}

// locales/de.json
{
  "You have {count} items.one": "Du hast {count} Artikel.",
  "You have {count} items.other": "Du hast {count} Artikel."
}

// locales/pl.json (Polish: one, few, many)
{
  "You have {count} items.one": "Masz {count} element.",
  "You have {count} items.few": "Masz {count} elementy.",
  "You have {count} items.many": "Masz {count} elementow.",
  "You have {count} items.other": "Masz {count} elementu."
}

Usage in Code

Reference the base key without any suffix. Perry detects the plural variants automatically:

// Reference the base key without any suffix — Perry picks the plural variant
// from the `count` parameter and the current locale's CLDR rules.
const cartCount = 3
const itemMessage = t("You have {count} items", { count: cartCount })
console.log(itemMessage)

Perry determines which plural form to use based on the count parameter value and the current locale’s CLDR rules.

Supported Locales

Perry includes hand-rolled CLDR plural rules for 30+ locales:

PatternLocales
one/otherEnglish, German, Dutch, Swedish, Danish, Norwegian, Finnish, Estonian, Hungarian, Turkish, Greek, Hebrew, Italian, Spanish, Portuguese, Catalan, Bulgarian, Hindi, Bengali, Swahili, …
one (0-1) / otherFrench
no distinctionJapanese, Chinese, Korean, Vietnamese, Thai, Indonesian, Malay
one/few/manyRussian, Ukrainian, Serbian, Croatian, Bosnian, Polish
one/few/otherCzech, Slovak
zero/one/two/few/many/otherArabic
one/few/otherRomanian, Lithuanian
zero/one/otherLatvian

Compile-Time Validation

ConditionSeverity
.other form missing for any localeError
Required CLDR category missing (e.g., .few for Polish)Error
Extra category locale doesn’t use (e.g., .few for English)Warning

Explicit API for Non-UI Strings

For strings outside UI components (API responses, notifications, etc.), use t():

// For strings outside UI components (API responses, notifications, …), use t():
const message = t("Your order has been shipped.")
const welcome = t("Welcome back, {name}!", { name: "Alice" })
console.log(message, welcome)

This uses the same key lookup, validation, and interpolation as UI strings.

Locale-Aware Formatting

Perry provides format wrapper functions that automatically format values according to the current locale. Import them from perry/i18n:

// All format wrappers come from perry/i18n.
// (The same `Currency`, `Percent`, … you'd pass into a Text(...) param object.)
const price = Currency(23.10)
const discount = Percent(0.15)
const population = FormatNumber(1234567.89)
const due = ShortDate(Date.now())
const event = LongDate(Date.now())
const at = FormatTime(Date.now())
const code = Raw(12345)

// Compose them with t() the same way you'd compose with Text(...):
console.log(t("Total: {price}", { price }))
console.log(t("Discount: {rate}", { rate: discount }))
console.log(t("Population: {n}", { n: population }))
console.log(t("Due: {d}", { d: due }))
console.log(t("Event: {d}", { d: event }))
console.log(t("At: {t}", { t: at }))
console.log(t("Code: {amount}", { amount: code }))

Format Wrappers

Currency

Formats a number as currency with the locale’s symbol, decimal separator, and symbol placement:

// Currency: locale-appropriate symbol, separator, and placement.
//   en: "$23.10"   de: "23,10 €"   ja: "¥23.10"
const cur = Currency(23.10)
console.log(t("Total: {price}", { price: cur }))
en: "Total: $23.10"
de: "Total: 23,10 €"
fr: "Total: 23,10 €"
ja: "Total: ¥23.10"

Percent

Formats a decimal as a percentage (value is multiplied by 100):

// Percent: input is a decimal (0.15 → 15 %). en omits the space; de/fr add it.
const rate = Percent(0.15)
console.log(t("Discount: {rate}", { rate }))
en: "Discount: 15%"
de: "Discount: 15 %"
fr: "Discount: 15 %"

FormatNumber

Formats a number with locale-appropriate grouping and decimal separators:

// FormatNumber: locale-appropriate grouping + decimal separators.
//   en: "1,234,567.89"   de: "1.234.567,89"   fr: "1 234 567,89"
const n = FormatNumber(1234567.89)
console.log(t("Population: {n}", { n }))
en: "Population: 1,234,567.89"
de: "Population: 1.234.567,89"
fr: "Population: 1 234 567,89"

ShortDate / LongDate / FormatDate

Formats a timestamp (milliseconds since epoch) as a date:

// ShortDate / LongDate take a millisecond timestamp.
const now = Date.now()
const short = ShortDate(now)   // en: "3/22/2026"   de: "22.03.2026"
const long = LongDate(now)     // en: "March 22, 2026"   de: "22. März 2026"
console.log(t("Due: {d}", { d: short }))
console.log(t("Event: {d}", { d: long }))
ShortDate
  en: "Due: 3/22/2026"
  de: "Due: 22.03.2026"
  ja: "Due: 2026/03/22"

LongDate
  en: "Event: March 22, 2026"
  de: "Event: 22. März 2026"
  fr: "Event: 22 mars 2026"

FormatTime

Formats a timestamp as time (12h vs 24h based on locale):

// FormatTime: 12h vs 24h based on the active locale.
const ts = Date.now()
const formatted = FormatTime(ts)
console.log(t("At: {t}", { t: formatted }))
en: "At: 3:45 PM"
de: "At: 15:45"
fr: "At: 15:45"

Raw

Pass-through — prevents any automatic formatting. Use when a parameter name might trigger auto-formatting but you want the raw value:

// Raw is a pass-through — prevents auto-formatting when the param name
// might otherwise trigger it.
const orderCode = Raw(12345)
console.log(t("Code: {amount}", { amount: orderCode }))

All locales: "Code: 12345" (no currency formatting despite the name).

Locale-Specific Formatting Rules

Perry includes hand-rolled formatting rules for 25+ locales:

FeatureExample Locales
Decimal: . / Thousands: ,en, ja, zh, ko
Decimal: , / Thousands: .de, nl, tr, es, it, pt
Decimal: , / Thousands: (narrow space)fr
Decimal: , / Thousands: (non-breaking space)ru, uk, pl, sv, da, no, fi
Currency before number: $23.10en, ja, zh, ko
Currency after number: 23,10 €de, fr, es, it, ru
Percent with space: 42 %de, fr, es, ru
Percent without space: 42%en, ja, zh
Date order: M/D/Yen
Date order: D.M.Yde, fr, es, ru
Date order: Y/M/Dja, zh, ko, sv
24-hour timede, fr, es, ja, zh, ru (most)
12-hour time (AM/PM)en

Currency Configuration

Configure default currency codes per locale in perry.toml:

[i18n]
locales = ["en", "de", "fr"]
default_locale = "en"

[i18n.currencies]
en = "USD"
de = "EUR"
fr = "EUR"

When Currency(value) is called, the locale’s configured currency code determines the symbol and formatting rules.

i18n CLI Tools

perry i18n extract

Scans your TypeScript source files for localizable strings and generates or updates locale JSON files.

perry i18n extract src/main.ts

What It Does

  1. Recursively scans all .ts and .tsx files in the source directory
  2. Detects string literals in UI component calls: Button("..."), Text("..."), Label("..."), etc.
  3. Also detects t("...") calls from perry/i18n
  4. Creates locales/ directory if it doesn’t exist
  5. For each configured locale, creates or updates a JSON file:
    • Default locale: New keys are pre-filled with themselves as values
    • Non-default locales: New keys are added with empty string values (indicating “needs translation”)

Example Output

Scanning for localizable strings...
  Found 12 localizable string(s)
  Updated locales/en.json (3 new, 1 unused)
  Updated locales/de.json (3 new, 1 unused)
  Updated locales/fr.json (3 new, 1 unused)
Done.

Workflow

Typical translation workflow:

# 1. Write code with English strings
#    Button("Next"), Text("Hello, {name}!", { name })

# 2. Extract strings to locale files
perry i18n extract src/main.ts

# 3. Send locales/de.json to translators (empty values need filling)

# 4. Build — Perry validates everything
perry compile src/main.ts -o myapp

Detected Patterns

The scanner detects these UI component patterns:

  • Button("string")
  • Text("string")
  • Label("string")
  • TextField("string")
  • TextArea("string")
  • Tab("string")
  • NavigationTitle("string")
  • SectionHeader("string")
  • SecureField("string")
  • Alert("string")
  • t("string") (explicit i18n API)

Both double-quoted and single-quoted strings are supported. Escaped quotes are handled correctly.

Build Output

During compilation, Perry reports i18n status:

  i18n: 2 locale(s) [en, de], default: en
    Loaded locales/en.json (12 keys)
    Loaded locales/de.json (12 keys)
  i18n: 12 localizable string(s) detected
  i18n warning: Missing translation for key "Settings" in locale "de"
  i18n warning: Unused i18n key "Old Label" in locale "en"

Key Registry

Perry maintains a .perry/i18n-keys.json file, updated on every build:

{
  "keys": [
    { "key": "Next", "string_idx": 0 },
    { "key": "Hello, {name}!", "string_idx": 1 },
    { "key": "You have {count} items", "string_idx": 2 }
  ]
}

This file serves as the source of truth for what strings exist in the codebase.

Auto-Update

Ship updates to your Perry desktop app without rolling your own download + replace + relaunch flow. Two modules cooperate:

  • @perry/updater — high-level wrapper. The 90% case: manifest fetch, semver compare, download, verify, install, relaunch, crash-loop rollback.
  • perry/updater — ambient primitives the wrapper is built on. Reach for these only when you need a custom flow (multi-channel rollouts, your own progress UI, an integration with an external supervisor).

The trust model and wire format follow Tauri’s updater: JSON manifest over HTTPS, SHA-256 + Ed25519 signature over the digest, atomic binary replace with a .prev backup, detached relaunch. Every snippet below is excerpted from docs/examples/updater/snippets.ts — CI compile-links it on every PR.

Desktop only. iOS / TestFlight, Android Play Store, and sideloaded APKs own the install pipeline at the OS level — replacing your own binary at runtime is structurally impossible there. The crate still compiles on mobile targets so cross-platform code doesn’t need #ifdefs, but the install path is a no-op. Gate updater code with process.platform if your app ships everywhere.

Quick start

// import { checkForUpdate, initUpdater, markHealthy } from "@perry/updater"

Drop a “Check for updates” handler somewhere in your menu or a periodic timer. checkForUpdate returns null when up to date or when the manifest has no entry for the current platform.

// Pseudocode using @perry/updater's wrapper. Drop into a "Check for updates"
// menu item or a periodic timer. The manifest URL must serve over HTTPS.
//
// const update = await checkForUpdate({
//     manifestUrl: "https://updates.example.com/myapp.json",
//     publicKey: "BASE64_ED25519_PUBKEY",
//     currentVersion: "1.4.0",
// })
//
// if (update !== null) {
//     console.log(`v${update.version} is available`)
//     console.log(update.notes)
//
//     await update.download((downloaded, total) => {
//         const pct = Math.round((downloaded / total) * 100)
//         console.log(`downloading: ${pct}%`)
//     })
//
//     await update.installAndRelaunch()  // never returns — process.exit inside
// }

Call initUpdater() once near the top of main(). It handles boot-time crash-loop detection: if the new binary you just installed crashes during boot more than crashLoopThreshold times, the wrapper restores the previous version and exits so the OS / launcher restarts you on the rollback.

// Boot-time: detect a crash-looping new install and roll back. Call this
// near the top of `main()`, right after process initialization.
//
// await initUpdater({
//     autoRollback:       true,    // default
//     healthCheckMs:      60_000,  // clear sentinel after this many ms alive
//     crashLoopThreshold: 2,       // restarts before rollback fires
// })
//
// // Optional: tell the updater explicitly that this version is healthy
// // (e.g. after a successful login or migration finished).
// // markHealthy()

Manifest

Serve a single JSON file over HTTPS. One entry per <os>-<arch> you publish for; clients ignore entries that don’t match their platform.

// The manifest is a single JSON file you serve over HTTPS. Each platform
// triple is `<os>-<arch>` (darwin-aarch64, darwin-x86_64, windows-x86_64,
// linux-x86_64, linux-aarch64). The wrapper picks the entry matching the
// running host and ignores the rest.
//
// {
//   "schemaVersion": 1,
//   "version": "1.4.0",
//   "pubDate": "2026-04-27T10:00:00Z",
//   "notes": "Bug fixes and performance improvements",
//   "platforms": {
//     "darwin-aarch64": {
//       "url":       "https://example.com/app-1.4.0-darwin-aarch64.bin",
//       "sha256":    "0123456789abcdef...",
//       "signature": "base64sig==",
//       "size":      12345678
//     }
//   }
// }
FieldMeaning
schemaVersion1 (legacy, digest-only signature) or 2 (recommended — version-bound signature; see below).
versionSemver string of the offered version (e.g. "1.4.0").
pubDateISO-8601 timestamp the build was published — surfaced as metadata.
notesMarkdown release notes shown to the user.
platforms.<os>-<arch>.urlDirect download URL (HTTPS).
platforms.<os>-<arch>.sha256Lowercase hex SHA-256 of the binary.
platforms.<os>-<arch>.signatureBase64 Ed25519 signature. v1: over the raw 32-byte digest. v2: over digest || version_utf8.
platforms.<os>-<arch>.sizeByte length of the binary — used for progress reporting.

Platform keys are canonical Rust-style triples:

HostTriple
Apple Silicon macdarwin-aarch64
Intel macdarwin-x86_64
Windows 10/11 64-bitwindows-x86_64
Linux 64-bitlinux-x86_64
Linux ARM64linux-aarch64

Trust model

The signed payload is SHA256(binary) || version_utf8 — the 32-byte raw digest concatenated with the UTF-8 bytes of the version string. This binds the version into the signature, so an on-path attacker can’t replay a previously-signed older binary as a “new version” by serving a manifest that pairs the old binary’s URL + signature with a higher version number (#229).

Sign side:

payload = sha256(binary).digest() + version.encode("utf-8")
signature = ed25519_sign(secret_key, payload)  # 64-byte signature

Verification on the client:

  1. SHA-256 the downloaded file. Reject if it doesn’t match manifest.sha256.
  2. Build the v2 payload (digest || version_utf8) using manifest.version.
  3. Ed25519-verify the signature against the bundled public key.
  4. Reject on any decode error, size mismatch, or signature failure.

If an attacker swaps the manifest’s version field while keeping the old signature, step 3 fails because the signature was made over the original version. If they swap both version and signature (using a previously-signed older binary), step 1 fails because sha256 of the older binary doesn’t match the rewritten higher-version label either — every plausible attack on the version metadata invalidates something.

schemaVersion: 1 (legacy, digest-only)

The signed payload is the raw 32-byte digest only. This shape is vulnerable to old-binary replay (#229): an on-path attacker can serve a manifest claiming a higher version while pointing at a previously-signed older binary, and signature verification still passes because the version isn’t bound into the signature. Existing v1 manifests stay supported by the client during migration; new deployments should use v2.

Migration

@perry/updater v0.5.391+ accepts both schemaVersion: 1 and schemaVersion: 2. Bumping your manifest from 1 → 2 requires:

  1. Update sign-side tooling to compute the new payload (sha256(binary) + version.encode("utf-8")) and sign that.
  2. Bump schemaVersion in the manifest from 1 to 2.
  3. Make sure all deployed clients are running a perry-updater version that knows about v2 BEFORE you publish a v2 manifest. Older clients reject schemaVersion: 2 with an unsupported manifest schemaVersion error. (Plan: ship a perry-updater bump with v2 support to your users via a v1 manifest first; once they’re on the v2-aware client, the next manifest can be v2.)

Keypair

You generate the keypair once and bake the public key into your app at build time; the secret key stays on a release-signing machine alongside the rest of your build artifacts. Compromise of the manifest server alone never lets an attacker push a binary your client will accept.

Sign-side CLI (v0.5.395+)

perry updater ships three subcommands that produce v2-shape signatures without needing any custom tooling:

# 1) One-time keypair generation. Save kp.json with mode 0600 and
#    NEVER commit the secret_key field.
perry updater keygen --output kp.json

# 2) Per-release signing. The output JSON envelope contains every
#    manifest-entry field (sha256, signature, size, version, schemaVersion=2).
perry updater sign \
    --binary perry-darwin-aarch64.tar.gz \
    --version 1.2.3 \
    --secret-key kp.json

# 3) Sanity-check the signature locally before uploading the manifest —
#    this is the same algorithm the runtime uses, so a passing verify
#    here predicts a passing verify on the client.
perry updater verify \
    --binary perry-darwin-aarch64.tar.gz \
    --version 1.2.3 \
    --signature '<base64 from step 2>' \
    --pubkey '<public_key from kp.json>'

Compose a final manifest by piping sign output through jq for each asset, then merging into the per-platform layout shown above. CI tip: pass --secret-key-b64 "$ED25519_SECRET_KEY" instead of --secret-key file so the secret can come from a repository secret without ever hitting the worker’s filesystem.

Install + rollback flow

manifest fetch  →  semver compare  →  download to <exe>.staged
                                          ↓
                                      sha256 verify  →  ed25519 verify
                                          ↓
                                      arm sentinel (state: "armed")
                                          ↓
                                      install:  rename <exe> → <exe>.prev
                                                rename <exe>.staged → <exe>
                                                chmod +x   (Unix only)
                                          ↓
                                      detached relaunch  →  process.exit(0)

next boot  →  initUpdater() reads sentinel
                  ├── healthCheckMs alive  → clearSentinel  (success)
                  ├── graceful exit         → clearSentinel  (success)
                  └── restartCount ≥ N      → performRollback + exit

installUpdate is atomic where the OS lets us be: POSIX rename(2) on the same filesystem, NTFS rename-while-open (the PE loader opens with FILE_SHARE_DELETE so Windows tolerates this since Vista), and Linux’s mmap’d-inode-stays-alive semantics. If the staging directory ends up on a different filesystem (a separate mount for /tmp, for instance) the rename falls back to copy + remove, which has a small non-atomic window.

The sentinel is a JSON file at a per-OS user-writable path:

PlatformDefault location
macOS~/Library/Application Support/<app>/updater.sentinel
Windows%LOCALAPPDATA%\<app>\updater.sentinel
Linux$XDG_STATE_HOME/<app>/updater.sentinel

<app> comes from the PERRY_APP_ID environment variable, falling back to the basename of the running exe. Set PERRY_APP_ID in your launch environment so the sentinel path stays stable across rename / relocation of the binary.

Low-level primitives

Use these when the high-level wrapper doesn’t fit — custom progress UI, a multi-channel manifest, an external supervisor that handles restarts, etc.

import {
    compareVersions,
    verifyHash,
    verifySignature,
    computeFileSha256,
    writeSentinel,
    readSentinel,
    clearSentinel,
    getExePath,
    getBackupPath,
    getSentinelPath,
    installUpdate,
    performRollback,
    relaunch,
} from "perry/updater"

compareVersions(current, candidate)

Returns -1 (update available), 0 (equal), 1 (downgrade — never offered), or -2 (parse error). Prerelease tags handled per the semver spec.

// Returns -1 (current < candidate, update available), 0 (equal),
// 1 (current > candidate, never offered as an update), -2 (parse error).
const cmp = compareVersions("1.4.0", "1.4.1")
if (cmp === -1) {
    console.log("update available")
} else if (cmp === 0) {
    console.log("up to date")
}

verifyHash / verifySignature / computeFileSha256

// SHA-256 + Ed25519 verification of a binary on disk. The signed payload is
// the *raw 32-byte SHA-256 digest* — not the hex string and not the file
// bytes themselves. Sign side: `sha256(file) | ed25519_sign(secret_key)`.
const stagedPath = getExePath() + ".staged"
const expectedHex = "0123456789abcdef..." // from your manifest
const sigB64 = "...base64..."             // 64-byte signature, base64
const pubB64 = "...base64..."             // 32-byte public key, base64

if (verifyHash(stagedPath, expectedHex) !== 1) {
    const actual = computeFileSha256(stagedPath)
    console.error(`hash mismatch — expected ${expectedHex}, got ${actual}`)
}
if (verifySignature(stagedPath, sigB64, pubB64) !== 1) {
    console.error("signature verification failed")
}

verifyHash and verifySignature return 1 on success, 0 on any failure (file missing, decode error, mismatch). computeFileSha256 returns the hex digest as a string, or "" on failure — useful for logging the actual hash when a verifyHash mismatch fires.

installUpdate / performRollback / relaunch

// `installUpdate` atomically replaces `targetPath` with `stagedPath`,
// keeping the displaced version at `<target>.prev` for rollback.
const target = getExePath()
const staged = target + ".staged"

if (installUpdate(staged, target) !== 1) {
    console.error("install failed")
} else {
    const pid = relaunch(target)
    if (pid < 0) {
        console.error("relaunch failed; restart manually")
    } else {
        // Detached child is now running the new binary — get out of its way.
        process.exit(0)
    }
}
// `performRollback` restores `<target>.prev` over `target` and moves the
// current (likely-broken) target to `<target>.broken` as a safety net.
if (performRollback(getExePath()) !== 1) {
    console.error("no backup to roll back to")
}

relaunch returns the child PID, or -1 on failure. The new process is fully detached (setsid on Unix, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP on Windows) so closing the current process doesn’t take it down.

Path resolution

// Resolved per platform. macOS walks up to the surrounding `.app` bundle;
// Linux honors $APPIMAGE; Windows / bare ELF returns the canonical exe.
console.log("running exe   :", getExePath())
console.log("backup target :", getBackupPath())
// Sentinel path is keyed off PERRY_APP_ID — set this env var so the path
// stays stable across rename/relocation of the binary.
console.log("sentinel path :", getSentinelPath())

getExePath() accounts for platform quirks:

  • macOS: walks up to the surrounding .app bundle if applicable — the .app directory is the codesign unit, so that’s what you replace.
  • Linux: honors $APPIMAGE when set. The AppImage runtime points current_exe() inside a read-only squashfs mount; the real target to replace is the AppImage file itself.
  • Windows / bare ELF / bare Mach-O: returns the canonicalized exe path.

Sentinel

// Low-level sentinel API. Most apps use `initUpdater()` from @perry/updater
// instead of touching this directly, but it's here when you need it (custom
// rollback policies, multi-process apps, integration with another supervisor).
const sentinelPath = getSentinelPath()
writeSentinel(sentinelPath, JSON.stringify({ state: "armed", restartCount: 0 }))
const raw = readSentinel(sentinelPath)
if (raw) {
    const state = JSON.parse(raw) as { state: string; restartCount: number }
    if (state.restartCount >= 2) {
        // looks like a crash loop — recover or roll back
        clearSentinel(sentinelPath)
    }
}

writeSentinel is atomic (tmp file + rename), creates the parent directory if needed, and returns 1 on success / 0 on any IO error. clearSentinel is idempotent — returns 1 whether the file existed or not.

What’s not here (yet)

  • UI primitives — a “Restart now” modal with ProgressView belongs inside perry/ui proper rather than the updater package. Tracked as a follow-up.
  • Privileged install for system-wide locations (/Applications, Program Files). The current install path only handles user-writable locations (~/Applications, ~/.local/bin, %LOCALAPPDATA%). UAC / SMJobBless is a separate concern.
  • Delta updates (bsdiff), multi-channel (stable / beta), staged rollouts.
  • Notarization / code-signing during install. Binaries are expected to arrive already signed; the updater doesn’t try to be a notarization tool.

Testing your update flow

The crate ships smoke-test scripts that exercise verify → install → relaunch end-to-end against a real Perry binary:

  • Unix: scripts/smoke_updater.sh
  • Windows: scripts/smoke_updater.ps1

Both spin up a tiny HTTP server, build a v1.0.0 binary that drives the update flow, build a v1.0.1 binary that proves it ran, and verify the relaunch handed off correctly. Run them locally before shipping a release that depends on the updater wiring.

Next Steps

System APIs Overview

The perry/system module provides access to platform-native system features: preferences, secure storage, notifications, dark-mode detection, audio capture, and app introspection. Every snippet below is excerpted from docs/examples/system/snippets.ts — CI links the file on every PR.

// import {
//     openURL, isDarkMode,
//     preferencesGet, preferencesSet,
//     keychainSave, keychainGet, keychainDelete,
//     notificationSend,
//     audioStart, audioStop, audioGetLevel, audioGetPeak, audioGetWaveform,
// } from "perry/system"

Available APIs

FunctionDescriptionPlatforms
openURL(url)Open URL in default browser/appAll
isDarkMode()Check system dark modeAll
getDeviceIdiom()"phone", "pad", "mac", "tv", …All
getDeviceModel()Device model identifier (e.g. "iPhone13,4")All
preferencesSet(key, value)Store a preference (string or number)All
preferencesGet(key)Read a preference (returns `stringnumber
keychainSave(key, value)Secure storage writeAll
keychainGet(key)Secure storage readAll
keychainDelete(key)Secure storage removeAll
notificationSend(title, body)Local notificationAll
notificationCancel(id)Cancel a scheduled notificationApple
notificationOnTap(cb)Handle banner tapsApple
notificationRegisterRemote(cb) / notificationOnReceive(cb)Push (APNs)iOS, macOS
audioStart() / audioStop()Microphone captureAll
audioGetLevel() / audioGetPeak()RMS / peak amplitude (0..1)All
audioGetWaveform(n)Recent waveform samples for visualizationAll
audioSetOutputFilename(p) / audioStartRecording() / audioStopRecording()Capture mic to a WAV fileAll native
geolocationGetCurrent(ok, err)One-shot device positioniOS, Android, macOS
geolocationWatch(cb) / geolocationStopWatch(id)Subscribe to position updatesiOS, Android, macOS
geolocationRequestPermission(cb)Request location permissioniOS, Android, macOS
imagePickerPick(max, multi, cb)Native photo-library pickeriOS, Android, macOS
registerTask(id, fn) / schedule(id, …) / cancel(id)Deferred / periodic background work — see perry/backgroundiOS, Android, tvOS, visionOS, watchOS, macOS

Clipboard lives in perry/ui (not perry/system): import clipboardRead and clipboardWrite from there.

Quick Example

if (isDarkMode()) {
    console.log("Dark mode is active")
}
// Strings and numbers round-trip natively — no manual stringification needed.
preferencesSet("theme", "dark")
preferencesSet("font-size", 14)

const theme = preferencesGet("theme")        // string | number | undefined
const fontSize = preferencesGet("font-size") // → 14 (number)

if (typeof theme === "string") {
    console.log(`saved theme: ${theme}`)
}
if (typeof fontSize === "number") {
    console.log(`saved font-size: ${fontSize}`)
}
openURL("https://example.com")

Next Steps

Preferences

Store and retrieve user preferences using the platform’s native storage. Every snippet below is excerpted from docs/examples/system/snippets.ts — CI links it on every PR.

Usage

preferencesSet(key, value) accepts strings or numbers and round-trips them natively (NSUserDefaults / GSettings / Registry preserve the original type). preferencesGet(key) returns string | number | undefined:

// Strings and numbers round-trip natively — no manual stringification needed.
preferencesSet("theme", "dark")
preferencesSet("font-size", 14)

const theme = preferencesGet("theme")        // string | number | undefined
const fontSize = preferencesGet("font-size") // → 14 (number)

if (typeof theme === "string") {
    console.log(`saved theme: ${theme}`)
}
if (typeof fontSize === "number") {
    console.log(`saved font-size: ${fontSize}`)
}

Platform Storage

PlatformBackend
macOSNSUserDefaults
iOSNSUserDefaults
AndroidSharedPreferences
WindowsWindows Registry
LinuxGSettings / file-based
WeblocalStorage

Preferences persist across app launches. They are not encrypted — use Keychain for sensitive data.

Next Steps

Keychain

Securely store sensitive data like tokens, passwords, and API keys using the platform’s secure storage. Every snippet below is excerpted from docs/examples/system/snippets.ts — CI links it on every PR.

Usage

keychainSave("api_token", "sk-...")
const token = keychainGet("api_token")
keychainDelete("api_token")
console.log(`token length: ${token.length}`)

The free-function API is keychainSave(key, value), keychainGet(key) (returns the stored string, or an empty string if the key isn’t present), and keychainDelete(key).

Platform Storage

PlatformBackend
macOSSecurity.framework (Keychain)
iOSSecurity.framework (Keychain)
AndroidAndroid Keystore
WindowsWindows Credential Manager (CredWrite/CredRead/CredDelete)
Linuxlibsecret
WeblocalStorage (not truly secure)

Web: The web platform uses localStorage, which is not encrypted. For web apps handling sensitive data, consider server-side storage instead.

Next Steps

Notifications

Send local notifications using the platform’s notification system. Every snippet below is excerpted from docs/examples/system/snippets.ts — CI links it on every PR.

Sending a notification

notificationSend("Build complete", "All targets compiled in 4.2s.")

Reacting to a tap

notificationOnTap((id: string, action?: string) => {
    console.log(`tapped notification ${id}; action=${action ?? "(default)"}`)
})

action is the action-button identifier when the user picks a button, or undefined for the default banner tap.

Cancelling a scheduled notification

notificationCancel("daily-reminder")

notificationCancel(id) is a no-op if no scheduled notification with that id exists.

Push notifications (APNs / Firebase)

notificationRegisterRemote((token: string) => {
    console.log(`APNs device token: ${token}`)
})

notificationOnReceive((payload: object) => {
    console.log(`got remote payload: ${JSON.stringify(payload)}`)
})

notificationRegisterRemote(cb) fires once when the OS returns a device token — on Apple platforms the token is the canonical uppercase hex string APNs expects. notificationOnReceive(cb) runs whenever a remote payload arrives while the app is foregrounded; the payload is the APNs aps userInfo dictionary (or equivalent platform shape) converted to a plain object.

Requires the relevant platform capability (APNs entitlement on iOS/macOS, Firebase Messaging on Android — wired via JNI through PerryFirebaseMessagingService, see #98). No-op on platforms without a push pipeline (tvOS, visionOS, watchOS, GTK4, Windows, Web).

Platform Implementation

PlatformBackend
macOSUNUserNotificationCenter
iOSUNUserNotificationCenter
AndroidNotificationManager
WindowsToast notifications
LinuxGNotification
WebWeb Notification API

Permissions: On macOS, iOS, and Web, the user may need to grant notification permissions. On first use, the system will prompt automatically.

Next Steps

Audio Capture

The perry/system module provides real-time audio capture from the device microphone, with A-weighted dB(A) level metering and waveform sampling — everything needed to build a sound meter, audio visualizer, or voice-level indicator. Every snippet below is excerpted from docs/examples/system/snippets.ts — CI links it on every PR.

const ok = audioStart() // 1 on success, 0 on failure
if (ok === 1) {
    const level = audioGetLevel()           // 0..1
    const peak = audioGetPeak()             // 0..1
    const waveform = audioGetWaveform(64)   // sample-count
    console.log(`level=${level} peak=${peak} waveform=${waveform}`)
    audioStop()
}

API Reference

audioStart()

Start capturing audio from the device microphone. Returns 1 on success, 0 on failure (permission denied, no microphone, etc.).

On platforms that require permission (iOS, Android, Web), the system permission dialog is shown automatically.

audioStop()

Stop audio capture and release the microphone.

audioGetLevel()

Get the current A-weighted sound level (a smoothed value with a 125 ms time constant). Typical ranges:

  • ~30 dB — quiet room
  • ~50 dB — normal conversation
  • ~70 dB — busy street
  • ~90 dB — loud music
  • ~110+ dB — dangerously loud

audioGetPeak()

Get the current peak sample amplitude (0.01.0). Useful for simple level indicators without dB conversion.

audioGetWaveform(sampleCount)

Get recent waveform samples for visualization. Pass the number of samples you want; the runtime returns the most recent N readings from its internal ring buffer. Useful for drawing waveform displays or level history charts.

audioSetOutputFilename(filename)

Set the destination path for the next call to audioStartRecording. Pass an absolute path or a path relative to the app’s working directory. Must be called before audioStartRecording.

audioStartRecording()

Begin writing captured microphone audio to the file set by audioSetOutputFilename. The output is a WAV file (16-bit PCM, mono, 48 kHz on every platform). Calling without a destination set is a no-op.

audioStopRecording()

Finalize the in-progress recording — flushes pending samples, writes the RIFF/WAVE header sizes, and closes the file. Safe to call when no recording is in flight.

import {
  audioStart,
  audioStop,
  audioSetOutputFilename,
  audioStartRecording,
  audioStopRecording,
} from "perry/system";

audioStart();
audioSetOutputFilename("/tmp/captured.wav");
audioStartRecording();
// … capture for some duration …
audioStopRecording();
audioStop();

audioStartRecording does not imply audioStart — start the input first, then start the file writer.

Platform Implementations

PlatformAudio BackendPermissions
macOSAVAudioEngineMicrophone permission dialog
iOSAVAudioSession + AVAudioEngineSystem permission dialog
AndroidAudioRecord (JNI)RECORD_AUDIO permission
LinuxPulseAudio (libpulse-simple)None (system-level)
WindowsWASAPI (shared mode)None
WebgetUserMedia + AnalyserNodeBrowser permission dialog

All platforms capture at 48 kHz mono and apply the same A-weighting filter (IEC 61672 standard, 3 cascaded biquad sections).

Next Steps

Audio (perry/audio)

The perry/audio module is Perry’s low-latency, game-engine-style audio mixer. Three concepts:

  • Sound — a loaded asset. loadSound("click.wav") returns one handle; the PCM data lives in memory until you unload().
  • PlaybackId — one live voice. play(sound) returns a new PlaybackId every time it’s called, so the same sound can overlap with itself (think: multiple gunshots, multiple footsteps).
  • Bus — a mixer group. Sounds route through a Bus, Buses route through their parent (default: master). One setVolume(musicBus, 0.3) scales every voice on it.

Use perry/audio for SFX, music loops, voice prompts, and any UI feedback where you want overlap or sub-20ms latency. For long-form streaming with a seek bar, lock-screen controls, and Now Playing metadata, use perry/media instead.

Quick start

import {
  loadSound, play, stop, setVolume,
  createBus, setMasterVolume,
} from "perry/audio";

// Optional: organise sounds into buses
const sfx   = createBus("sfx");
const music = createBus("music");

// Load assets — decode happens in the background. The handle is
// returned immediately; play() before decode finishes just queues
// the playback.
const click = loadSound("assets/click.wav", sfx);
const bgm   = loadSound("assets/bgm.mp3",   music, /* stream */ true);

// Fire-and-forget — overlap is automatic, each play() returns a new
// PlaybackId you can stop / fade / tune independently.
const a = play(click);
const b = play(click, 0.7, false, 0.95);  // slightly lower pitch
const bgmId = play(bgm, 1.0, true);        // looping

// Mix
setVolume(music, 0.3);
setMasterVolume(0.8);

// Stop
stop(a);          // one voice
stop(click);      // every live voice of this sound

Game-engine patterns

Pitch variation on repeated SFX

The single biggest “doesn’t feel robotic” trick: randomise the rate (±5%) on every play of high-frequency SFX (footsteps, gunshots, hits).

const rate = 0.95 + Math.random() * 0.1;  // 0.95 – 1.05
play(footstep, 1.0, false, rate);

Crossfade music tracks

const calmId = play(calm, 0.0, true);   // start silent
crossfade(intenseId, calmId, 2000);     // 2s linear crossfade

Pause when backgrounded

// from your app lifecycle hook (perry/system / onAppDidEnterBackground)
suspend();                              // silences everything
// onAppDidBecomeActive:
resumeAll();

Three-bus mix template

const sfx   = createBus("sfx");
const music = createBus("music");
const voice = createBus("voice");

// User-facing sliders bind to these:
setVolume(sfx,   userPreferences.sfxVolume);
setVolume(music, userPreferences.musicVolume);
setVolume(voice, userPreferences.voiceVolume);

Format compatibility

WAV (PCM) and MP3 are portable across every platform. The rest depend on the platform decoder:

FormatmacOS / iOS / tvOS / visionOSLinux / Windows / AndroidWeb
WAV
MP3
AAC / M4A
OGG Vorbis✓ (most browsers)
FLAC✓ (10.13+)partial (no Safari)
Opus✓ (iOS 11+)

When in doubt, ship WAV for SFX (small, instant decode) and MP3 for music (good compression, universal).

Performance notes

  • Preload, decode once. loadSound decodes a file to a single shared PCM buffer. Every subsequent play() of that sound schedules the same buffer — no re-decode, no second allocation. 1MB WAV = 1MB in RAM no matter how many times you play it.
  • Voice pool. Voices are preallocated and recycled. The hot path through play() is one indexed table read plus a scheduleBuffer call. No malloc, no string lookup.
  • One shared audio graph. A single AVAudioEngine (Apple) / AudioContext (Web) drives every sound. Bus volume / mute / solo are O(1) on a mixer node, not a walk over voices.
  • Streaming for big files only. Pass stream: true to loadSound for music or files >2MB — Perry reads chunks from disk as the voice consumes them, so a 60-minute track doesn’t occupy 60MB of RAM.
  • Target latency. <10ms on Apple, <30ms on Web. On par with Unity / Godot.

Platform implementation

PlatformBackend
macOS / iOS / tvOS / visionOSAVAudioEngine + AVAudioPlayerNode + AVAudioPCMBuffer + AVAudioUnitVarispeed (per-voice rate).
watchOSSame AVAudioEngine stack as iOS. Background audio requires the host app to declare the audio background mode entitlement; foreground playback works out of the box.
Web (WASM)Web Audio API (AudioContext + AudioBufferSourceNode + GainNode)
Linux / Windows / Androidminiaudio v0.11.22 (perry-audio-miniaudio crate). PulseAudio / PipeWire / ALSA on Linux, WASAPI / DirectSound / WinMM on Windows, AAudio (API 26+) / OpenSL ES on Android — chosen at runtime.

Web autoplay policy

Browsers don’t allow audio playback before a user gesture. The AudioContext is lazily created on the first loadSound() / play() call; if that call happens before any user interaction, the context starts in a suspended state and your play() is queued. Trigger a user-interaction-bound resumeAll() (or just any other play() inside a click handler) to release it.

API reference

See the TypeScript declarations for full parameter documentation. Summary:

FunctionPurpose
loadSound(path, bus?, stream?) -> SoundDecode (or open for streaming) an audio file.
unload(sound)Free the PCM buffer / stream decoder.
play(sound, volume?, loop?, rate?, pan?, fadeInMs?) -> PlaybackIdStart a new voice.
stop(handle, fadeOutMs?)Stop one voice or every voice of a sound.
pause(playback) / resume(playback)Pause/resume a single voice.
setVolume(handle, volume, fadeMs?)Sound default / live voice / bus.
setRate(playback, rate) / setPan(playback, pan)Per-voice pitch and stereo position.
fadeIn(playback, ms, toVol?) / fadeOut(playback, ms) / crossfade(a, b, ms)Linear ramps.
createBus(name, parent?) -> Bus / destroyBus(bus) / muteBus(bus, muted) / soloBus(bus, soloed)Mixer tree.
setMasterVolume(volume, fadeMs?)Root-bus gain.
suspend() / resumeAll()Whole-graph pause for foreground/background transitions.
isPlaying(handle) / getDuration(sound) / getPosition(playback)Introspection.
onEnded(playback, cb) / onLoaded(sound, cb)Lifecycle callbacks.

Tracked in issue #1867.

Media Playback

The perry/media module provides streaming media playback — HTTP/HTTPS audio URLs (Subsonic, Icecast, plain MP3/AAC, HLS m3u8), file:// paths, lock-screen / Now Playing metadata, and remote-command (Siri Remote / Touch Bar / Control Center) integration.

Quick start

import {
  createPlayer,
  play,
  pause,
  setVolume,
  onStateChange,
  onTimeUpdate,
  setNowPlaying,
} from "perry/media";

const player = createPlayer("https://example.com/track.mp3");
if (player === 0) {
  console.error("createPlayer failed");
} else {
  setVolume(player, 0.8);
  setNowPlaying(player, "Track Title", "Artist", "Album", "");

  onStateChange(player, (state) => console.log("state:", state));
  onTimeUpdate(player, (cur, dur) => console.log(`${cur}/${dur}s`));

  play(player); // begins (or resumes) once buffered
}

API surface

FunctionReturnsNotes
createPlayer(url)handle (1+) or 0 on failureHTTP/HTTPS or file://
play(handle)voidResumes if paused
pause(handle)voidPosition preserved
stop(handle)voidResets position to 0
seek(handle, seconds)void
setVolume(handle, volume)void0.0–1.0, clamped
setRate(handle, rate)void1.0 = normal; Apple supports 0.5–2.0
getCurrentTime(handle)seconds
getDuration(handle)seconds0 if live / loading
getState(handle)MediaStateSee states below
isPlaying(handle)boolean
onStateChange(h, cb)voidFires on every transition
onTimeUpdate(h, cb)void~10 Hz while playing
setNowPlaying(h, title, artist, album, artworkUrl)voidAll strings; pass "" for unknown
destroy(handle)voidFrees resources

States

MediaState is one of:

  • idle — never started
  • loading — buffering / fetching headers
  • ready — first chunk decoded, ready to play()
  • playing — actively rendering
  • paused — paused (position preserved)
  • ended — reached end of stream
  • error — irrecoverable failure (network, codec, …)

ended reliability

ended is fired both from the platform’s native end-of-playback signal and from a currentTime ≈ duration fallback. Per issue #351 discussion, the native event has been historically flaky on the web / Chromecast — the same belt-and-braces is cheap to apply on every backend so a perry/media consumer can rely on ended firing once per track.

The fallback engages only after play() has been called and duration is known (live streams report +inf, which sanitises to 0 and disables the fallback). Window: 0.25s before duration. The native signal sets the flag first when it works; the fallback sets the same flag on the polling tick if the signal hasn’t arrived.

Platform implementations

PlatformBackendStatus
macOSAVPlayer + MPNowPlayingInfoCenter + MPRemoteCommandCenterImplemented + lock-screen
iOSAVPlayer + AVAudioSession Playback + UIImage artworkImplemented + lock-screen
tvOSAVPlayer + Siri Remote play/pause/skipImplemented + remote
visionOSAVPlayer + UIImage artworkImplemented + lock-screen
Androidandroid.media.MediaPlayer + MediaSessionCompat via JNIImplemented + lock-screen
GTK4 / LinuxGStreamer playbin element + MPRIS D-BusImplemented + lock-screen
WindowsWindows.Media.Playback.MediaPlayer (WinRT) + SystemMediaTransportControlsImplemented + Now Playing
watchOSAVPlayer + AVAudioSession Playback + UIImage artworkImplemented + Now Playing complication
HarmonyOS@ohos.multimedia.media.AVPlayer via napiImplemented (lock-screen via @ohos.multimedia.avsession is a follow-up)
Web<audio> element + Media Session APIImplemented (--target web; setNowPlaying populates navigator.mediaSession.metadata + wires play / pause / seekto / seekforward / seekbackward action handlers)

Stub platforms link cleanly against the same FFI surface — code that imports perry/media compiles on every target. createPlayer returns 0 on a stub backend so if (player === 0) is the canonical “feature not available here” check.

On Linux, setNowPlaying exposes the player to the desktop via MPRIS (org.mpris.MediaPlayer2.perry-<pid> on the session bus). GNOME Shell, KDE Plasma, playerctl, and any Bluetooth-headphone media-key bridge that speaks MPRIS will see the metadata and route Play / Pause / PlayPause / Stop / Seek / SetPosition back to the player. The MPRIS server is lazy-bootstrapped on the first setNowPlaying call so apps that don’t need lock-screen integration don’t pay the zbus startup cost. Next / Previous are no-ops (single-track playback model); playlists are an app-level concern.

Android — background playback

Perry’s Android backend wires MediaSessionCompat so the lock-screen tile, Bluetooth headset, Android Auto, and Wear OS see the metadata pushed by setNowPlaying and route headphone play/pause/stop/seek events back into the registered onStateChange closure. That covers foreground use. Apps that want playback to survive the activity being backgrounded (a podcast app, music player, etc.) need a foreground service of their own — Android will otherwise kill the audio when the process drops to the cached state. Add the following to your app’s AndroidManifest.xml and start the service when playback begins:

<service
    android:name=".PerryMediaService"
    android:foregroundServiceType="mediaPlayback"
    android:exported="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

The service implementation is app-specific — it should hold a MediaSessionCompat.Token (the same session Perry created), build a Notification.MediaStyle notification from it, and call startForeground(...) on play / stopForeground(false) on pause / stopSelf() on stop. We deliberately don’t ship a default service because the notification’s branding (small icon, tint, content intent) depends on the host app.

Threading notes

The onStateChange and onTimeUpdate callbacks fire from the platform’s main UI thread on every backend, so they share the same JS heap as the calling code. Implementation detail varies:

  • macOS / iOS / tvOS / visionOS — driven by an NSTimer scheduled on the main run loop at 10 Hz.
  • Android — driven from Java_com_perry_app_PerryBridge_nativePumpTick (the existing 125 Hz UI-thread pump), throttled internally to ~10 Hz. The prepare() call runs on a background worker thread to avoid blocking the UI on network buffering.
  • GTK4 — driven by a glib::timeout_add_local timer on the GLib main loop. EOS / error messages arrive on the GStreamer bus and get forwarded to per-player atomic flags via a bus.add_watch_local closure.
  • Windows — driven from the GetMessageW / PeekMessageW message loop after each dispatch, throttled to 100 ms by wall-clock comparison.
  • HarmonyOS — Perry’s .so cannot reach @ohos.multimedia.media directly, so perry/media calls record intents into Mutex-protected drain queues in perry-runtime::media_playback. The harvested pages/Index.ets (emitted by perry-codegen-arkts whenever the module uses perry/media) installs a 100 ms setInterval pump in aboutToAppear that drains the queues, dispatches each op against the matching media.AVPlayer instance (allocated lazily on the first createPlayer drain), and pushes state observations back into the runtime via the pushMediaState(handle, state, current, duration) NAPI export. AVPlayer’s own stateChange / timeUpdate / error / endOfStream events feed the same callback path. The pump runs on the ArkTS UI thread, so closures fired by media_playback::push_media_state share the same arena as Perry’s main(). Lock-screen integration (@ohos.multimedia.avsession) is a follow-up — the runtime queues now-playing metadata via drainNowPlaying but the ArkTS-side AVSession dispatch is a no-op beyond a hilog line for now (tracked under issue #369).

Now Playing on Apple platforms

Apple’s MPNowPlayingInfoCenter is a process-wide singleton — the most recent setNowPlaying call wins. For a single-player app (Subsonic client, podcast player) this matches user expectation. The MPRemoteCommandCenter handlers route play / pause / togglePlayPause events to the first live player handle — multi-player apps that need an explicit “active player” should manage that themselves.

artworkUrl accepts:

  • file:// paths — loaded synchronously via NSImage / UIImage
  • https:// URLs — fetched synchronously via NSData(contentsOf:) and wrapped in UIImage. The synchronous fetch is acceptable for a one-off artwork load (the MPNowPlayingInfoCenter dict is consumed synchronously when set).

watchOS Info.plist requirements

watchOS keeps the audio engine alive when the watch screen sleeps only if the app’s Info.plist declares the audio background mode under WKBackgroundModes (the WatchKit equivalent of iOS’s UIBackgroundModes):

<key>WKBackgroundModes</key>
<array>
    <string>audio</string>
</array>

Without this entry the OS suspends the watch app a few seconds after the wrist-down gesture or screen timeout, regardless of whether AVPlayer is actively rendering. The runtime also auto-activates an AVAudioSession with category Playback on the first createPlayer(...) call — combined with the Info.plist entry, this is what tells watchOS the app intends to keep playing audio in the background.

The Now Playing surface on the watch face is independent from the paired iPhone’s lock screen — they’re separate processes with separate MPNowPlayingInfoCenter instances. setNowPlaying on watchOS targets the watch’s Now Playing complication / glance screen.

Subsonic example

import { createPlayer, play, setNowPlaying, onStateChange } from "perry/media";

function streamUrl(serverUrl: string, user: string, pass: string, songId: string): string {
  const params = new URLSearchParams({
    u: user, p: pass, v: "1.16.1", c: "PerryClient", id: songId, format: "mp3",
  });
  return `${serverUrl}/rest/stream?${params.toString()}`;
}

const player = createPlayer(streamUrl("https://music.example.com", "alice", "secret", "12345"));
setNowPlaying(player, "All These Things That I've Done", "The Killers", "Hot Fuss",
              "https://music.example.com/rest/getCoverArt?id=12345&u=alice&p=secret&v=1.16.1&c=PerryClient");
onStateChange(player, (state) => {
  if (state === "ended") {
    // queue.next() ...
  }
});
play(player);

Next steps

Geolocation & Image Picker

Two perry/system capabilities that wrap the OS’s location and photo-library pickers across iOS, Android, macOS, and stub on every other platform.

Geolocation

Callback-based; wrap in new Promise(r => …) at the call site if a Promise-shaped API is preferred.

import {
  geolocationGetCurrent,
  geolocationWatch,
  geolocationStopWatch,
  geolocationRequestPermission,
} from "perry/system";

geolocationGetCurrent(
  (lat, lng, accuracy, timestampMs) => {
    console.log(`at ${lat},${lng} ±${accuracy}m`);
  },
  (errorMessage) => {
    console.error("location failed:", errorMessage);
  },
);

geolocationGetCurrent(onSuccess, onError)

Resolve the device’s current position. Exactly one of the two callbacks fires per invocation:

  • onSuccess(lat, lng, accuracy, timestampMs)accuracy in meters (horizontal); timestampMs is Unix epoch milliseconds.
  • onError(message) — fires on permission denial, timeout, or platform unavailability. Common messages: "permission-denied", "no-location", "no-provider-available", "unsupported-platform".

geolocationWatch(callback): number

Subscribe to position updates. Returns a numeric watch id; pass it to geolocationStopWatch to cancel. Updates fire whenever the platform reports movement greater than the OS’s default distance filter.

geolocationStopWatch(id)

Cancel a watch started by geolocationWatch. No-op on unknown ids.

geolocationRequestPermission(callback)

Request location permission. Calls callback(status) where status is one of "granted", "denied", "restricted", or "unsupported-platform". Safe to call repeatedly — already-granted permissions return immediately.

Required configuration

PlatformConfiguration
iOSNSLocationWhenInUseUsageDescription in Info.plist. Backed by CLLocationManager.
Android<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> (or ACCESS_COARSE_LOCATION) in AndroidManifest.xml. Backed by LocationManager.
macOSNSLocationWhenInUseUsageDescription in Info.plist for sandboxed apps. Backed by CLLocationManager.
tvOS / watchOS / visionOS / GTK4 / Windows / WebNo-op stub — geolocationGetCurrent invokes onError immediately with "unsupported-platform".

Promise wrapper

import { geolocationGetCurrent } from "perry/system";

function getPosition(): Promise<{
  lat: number;
  lng: number;
  accuracy: number;
  timestamp: number;
}> {
  return new Promise((resolve, reject) => {
    geolocationGetCurrent(
      (lat, lng, accuracy, timestamp) =>
        resolve({ lat, lng, accuracy, timestamp }),
      (msg) => reject(new Error(msg)),
    );
  });
}

Image picker

Present the native photo-library picker. The callback receives an array of absolute filesystem paths the user selected; read bytes via fs.readFileSync(path) if needed.

import { imagePickerPick } from "perry/system";

imagePickerPick(
  5,        // maxCount
  true,     // allowMultiple
  (paths) => {
    if (paths.length === 0) {
      console.log("user cancelled");
    } else {
      for (const p of paths) {
        console.log("picked:", p);
      }
    }
  },
);

imagePickerPick(maxCount, allowMultiple, callback)

  • maxCount: number — soft cap on selections. iOS Photo Picker enforces this when API supports; Android Photo Picker (API 33+) accepts a max in [1, 10].
  • allowMultiple: boolean — if false, only one image can be picked regardless of maxCount.
  • callback(paths: string[]) — fires once when the user dismisses the picker. paths is empty if the user cancelled.

Platform implementations

PlatformBackendPermissions
iOSPHPickerViewControllerNone — the system picker doesn’t require Photos permission
Android (API 33+)MediaStore.ACTION_PICK_IMAGES (Photo Picker)None — privacy-preserving
Android (API < 33)ACTION_GET_CONTENT fallbackREAD_MEDIA_IMAGES (used only by the fallback path)
macOSNSOpenPanel filtered to image UTIsNone
All other targetsNo-op stub — callback invoked with [] immediately

On Android, picked URIs are copied into the app’s cache dir (named perry_pick_<ms>_<idx>.<ext> with the extension inferred from the MIME type) so the absolute path returned is safe to read with fs.

Image compression

Pair the picker with the sharp package (compiled natively via Perry’s well-known bindings) to compress before upload:

import sharp from "sharp";

const buf = await sharp(pickedPath)
  .resize({ width: 1600 })
  .jpeg({ quality: 80 })
  .toBuffer();

See Other Modules for the full sharp surface.

Background Tasks

The perry/background module schedules deferred or periodic work that the operating system runs even when the app is in the background — refreshing data, polling for updates, or syncing state without keeping the app in the foreground.

import { registerTask, schedule, cancel } from "perry/background";

registerTask("com.example.refresh", async () => {
  await syncOrders();
});

schedule(
  "com.example.refresh",
  "appRefresh",
  Date.now() + 60_000,   // earliestStartMs
  true,                  // requiresNetwork
  false,                 // requiresCharging
);

API

registerTask(identifier, handler)

Register a handler for a background-task identifier. The OS calls this handler when it decides to wake the app for the matching schedule.

  • identifier: string — free-form, but on iOS / tvOS / visionOS it must also appear in Info.plist under BGTaskSchedulerPermittedIdentifiers. Apple rejects unregistered identifiers at submit time.
  • handler: () => Promise<void> | void — async or sync. The OS gives a fixed budget (~30 s for appRefresh, several minutes for processing); Perry awaits the returned promise before signalling completion.

On iOS / tvOS, registerTask must be called at module-init time (before the app loop starts). Perry’s app delegate flushes the registry during application:didFinishLaunchingWithOptions:. On Android, visionOS, watchOS, and macOS the call can happen any time.

schedule(identifier, kind, earliestStartMs, requiresNetwork, requiresCharging)

Submit a wake-up request for a registered identifier.

  • kind: "appRefresh" | "processing"
    • "appRefresh" — short (~30 s) wake to refresh data. iOS: BGAppRefreshTaskRequest. Android: OneTimeWorkRequest with no power constraint.
    • "processing" — longer-running work that requires the device to meet requiresNetwork / requiresCharging. iOS: BGProcessingTaskRequest. Android: OneTimeWorkRequest with a matching Constraints builder.
  • earliestStartMs: number — Unix-epoch milliseconds; pass 0 for “as soon as the OS allows”.
  • requiresNetwork: boolean — maps to setRequiresNetworkConnectivity (iOS/visionOS/tvOS), setRequiredNetworkType(CONNECTED) (Android), or setRequiresNetworkConnectivity on the macOS scheduler. Advisory on watchOS (the OS decides).
  • requiresCharging: boolean — maps to setRequiresExternalPower (iOS/tvOS/visionOS), setRequiresCharging(true) (Android). Advisory on watchOS / macOS.

Calling schedule for an identifier that already has a pending request replaces it — both iOS and Android enforce uniqueness per identifier.

cancel(identifier)

Cancel a previously scheduled task. No-op for unknown ids. On watchOS there is no native cancel API; cancel removes the handler from Perry’s registry so a fired refresh becomes a no-op.

Platform support

PlatformBackendWake while not running?
iOSBGTaskSchedulerYes (per Apple’s policy)
Androidandroidx.work (OneTimeWorkRequest + PerryBackgroundWorker)Yes
tvOSBGTaskScheduler (tvOS 13+)Only while the box is on (during screensaver / different app)
visionOSBGTaskScheduler (visionOS 1.0+)Yes
watchOSWKApplication.scheduleBackgroundRefresh (watchOS 7+)Yes; only appRefresh kind, no native cancel
macOSNSBackgroundActivitySchedulerOnly while app is running
GTK4 (Linux)No equivalent — silent no-op
WindowsNo equivalent without admin or MSIX — silent no-op
WebSilent no-op

For Linux desktop and Win32 Perry apps, deploy-time scheduling (systemd --user timer units, Windows Task Scheduler) is the only path; the app cannot register them at runtime. For periodic refresh while a desktop app is running, use setInterval() directly.

iOS Info.plist requirement

iOS / tvOS / visionOS reject any submitTaskRequest: whose identifier isn’t whitelisted at compile time. Add the identifiers your app registers to your Info.plist:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
  <string>com.example.refresh</string>
</array>

Without this entry the submit call fails silently and the OS never delivers the wake-up.

Android: Google’s WorkManager

The Android implementation requires androidx.work:work-runtime-ktx on the app’s classpath. Perry’s Android template already pulls it in — crates/perry-ui-android/template/app/build.gradle.kts. If you ship a custom Gradle setup, add:

implementation("androidx.work:work-runtime-ktx:2.9.0")

Branching by platform

Use getDeviceIdiom() from perry/system to skip background scheduling on platforms where it’s a no-op:

import { getDeviceIdiom } from "perry/system";
import { registerTask, schedule } from "perry/background";

const idiom = getDeviceIdiom();
if (idiom === "phone" || idiom === "pad" || idiom === "watch") {
  registerTask("refresh", refreshHandler);
  schedule("refresh", "appRefresh", 0, true, false);
} else {
  // Desktop fallback: poll while running
  setInterval(refreshHandler, 5 * 60 * 1000);
}

Notes & limitations

  • iOS budget is approximately 30 s for appRefresh and a few minutes for processing — design handlers around that.
  • Android WorkManager enforces a 15-minute minimum for PeriodicWorkRequest; Perry’s schedule always builds a OneTimeWorkRequest to avoid that constraint, but the OS may still delay the run based on doze mode and battery state.
  • Promise-based completion is synchronous-best-effort: Perry pumps microtasks before and after invoking the handler, so simple await chains run, but a handler that returns a long-lived Promise may miss the OS’s completion deadline.

Other System APIs

Additional platform-level APIs. Every snippet below is excerpted from a real file CI compiles on every PR — see docs/examples/system/snippets.ts for the perry/system pieces and docs/examples/ui/events/snippets.ts for clipboard.

Open URL

Open a URL in the default browser or application:

openURL("https://example.com")
PlatformImplementation
macOSNSWorkspace.open
iOSUIApplication.open
AndroidIntent.ACTION_VIEW
WindowsShellExecuteW
Linuxxdg-open
Webwindow.open

Dark Mode Detection

if (isDarkMode()) {
    console.log("Dark mode is active")
}
PlatformDetection
macOSNSApp.effectiveAppearance
iOSUITraitCollection
AndroidConfiguration.uiMode
WindowsRegistry (AppsUseLightTheme)
LinuxGTK settings
Webprefers-color-scheme media query

Clipboard

Clipboard helpers live in perry/ui (not perry/system):

// Copy to clipboard
clipboardWrite("Hello, clipboard!")

// Read from clipboard
const text = clipboardRead()
log.set(`clipboard length: ${text.length}`)

Device Identity

console.log(`device idiom: ${getDeviceIdiom()}`)
console.log(`device model: ${getDeviceModel()}`)

getDeviceIdiom() returns the broad form factor ("phone", "pad", "mac", "tv", …); getDeviceModel() returns the platform-specific model identifier ("iPhone15,2", "MacBookPro18,3", etc.).

Next Steps

Widgets (WidgetKit) Overview

Perry can compile TypeScript widget declarations to native widget extensions across 4 platforms: iOS (WidgetKit), Android (App Widgets), watchOS (Complications), and Wear OS (Tiles).

Status: the perry/widget API is wired in the HIR (crates/perry-hir/src/lower.rs:try_lower_widget_decl) and emits via dedicated codegen crates (perry-codegen-glance, perry-codegen-wear-tiles, the WidgetKit emitter). The snippets on the widget docs pages compile-link cleanly on the host LLVM target — Widget({...}) lowers to a no-op there — and CI verifies that via docs/examples/widgets/snippets.ts. What CI cannot do today is drive the actual cross-compile targets (--target ios-widget, --target android-widget, etc.) because each requires an --app-bundle-id not yet surfaced through the doc-tests harness — tracked in #194. For a working end-to-end reference see examples/widget_demo.ts.

What Are Widgets?

Home screen widgets display glanceable information outside your app. Perry’s perry/widget module lets you define widgets in TypeScript that compile to each platform’s native widget system.

Widget({
    kind: "MyWidget",
    displayName: "My Widget",
    description: "Shows a greeting",
    entryFields: { name: "string" },
    render: (entry) =>
        VStack([
            Text(`Hello, ${entry.name}!`),
        ]),
})

How It Works

TypeScript widget declaration
    ↓ Parse & Lower to WidgetDecl HIR
    ↓ Platform-specific codegen
    ↓
iOS/watchOS: SwiftUI WidgetKit extension (Entry, View, TimelineProvider, WidgetBundle, Info.plist)
Android:    AppWidgetProvider + layout XML + AppWidgetProviderInfo
Wear OS:    TileService + layout

The compiler generates a complete native widget extension for each platform — no platform-specific language knowledge required.

Building

perry widget.ts --target ios-widget              # iOS WidgetKit extension
perry widget.ts --target android-widget           # Android App Widget
perry widget.ts --target watchos-widget            # watchOS Complication
perry widget.ts --target watchos-widget-simulator   # watchOS Simulator
perry widget.ts --target wearos-tile               # Wear OS Tile

Each target produces the appropriate native widget extension for that platform.

Next Steps

Creating Widgets

Define home screen widgets using the Widget() function.

Status: the full Widget({...}) snippets on this page compile-link cleanly on the host LLVM target via docs/examples/widgets/snippets.ts, so the API shapes are verified against the codegen. The actual cross-compile targets (--target ios-widget/android-widget/watchos-widget/wearos-tile) still aren’t driven by the doc-tests harness — each requires --app-bundle-id and a platform SDK (#194). For the canonical end-to-end shape see examples/widget_demo.ts. Fragments below that show partial syntax (just the entryFields object, just a render: body, etc.) are rendered as plain text — the full declarations they appear inside are covered by the verified anchors.

Widget Declaration

Widget({
    kind: "WeatherWidget",
    displayName: "Weather",
    description: "Shows current weather",
    entryFields: {
        temperature: "number",
        condition: "string",
        location: "string",
    },
    render: (entry) =>
        VStack([
            HStack([
                Text(entry.location),
                Spacer(),
                Image("cloud.sun.fill"),
            ]),
            Text(`${entry.temperature}°`),
            Text(entry.condition),
        ]),
})

Widget Options

PropertyTypeDescription
kindstringUnique identifier for the widget
displayNamestringName shown in widget gallery
descriptionstringDescription in widget gallery
entryFieldsobjectData fields with types ("string", "number", "boolean", arrays, optionals, objects)
renderfunctionRender function receiving entry data, returns widget tree. Optional 2nd param for family.
configobjectConfigurable parameters the user can edit (see below)
providerfunctionTimeline provider function for dynamic data (see below)
appGroupstringApp group identifier for sharing data with the host app

Entry Fields

Entry fields define the data your widget displays. Each field has a name and type:

entryFields: {
  title: "string",
  count: "number",
  isActive: "boolean",
}

Array, Optional, and Object Fields

Entry fields support richer types beyond primitives:

entryFields: {
  items: [{ name: "string", value: "number" }],  // Array of objects
  subtitle: "string?",                             // Optional string
  stats: { wins: "number", losses: "number" },     // Nested object
}

These compile to a Swift TimelineEntry struct:

struct WeatherEntry: TimelineEntry {
    let date: Date
    let temperature: Double
    let condition: String
    let location: String
}

Conditionals in Render

Use ternary expressions for conditional rendering:

Widget({
    kind: "ConditionalWidget",
    displayName: "Conditional",
    description: "Renders based on entry data",
    entryFields: {
        isActive: "boolean",
        count: "number",
    },
    render: (entry) =>
        VStack([
            Text(entry.isActive ? "Active" : "Inactive"),
            entry.count > 0 ? Text(`${entry.count} items`) : Spacer(),
        ]),
})

Template Literals

Template literals in widget text are compiled to Swift string interpolation:

Widget({
    kind: "TemplateLiteralWidget",
    displayName: "Template Literal",
    description: "Template literals compile to Swift string interpolation",
    entryFields: {
        name: "string",
        score: "number",
    },
    render: (entry) =>
        // Template literal: `${entry.name}: ${entry.score} points`
        // Compiles to: Text("\(entry.name): \(entry.score) points")
        Text(`${entry.name}: ${entry.score} points`),
})

Configuration Parameters

The config field defines user-editable parameters that appear in the widget’s edit UI:

Widget({
    kind: "CityWeather",
    displayName: "City Weather",
    description: "Weather for a chosen city",
    config: {
        city: { type: "string", displayName: "City", default: "New York" },
        units: {
            type: "enum",
            displayName: "Units",
            values: ["Celsius", "Fahrenheit"],
            default: "Celsius",
        },
    },
    entryFields: { temperature: "number", condition: "string" },
    render: (entry) => Text(`${entry.temperature}° ${entry.condition}`),
})

Provider Function

The provider field defines a timeline provider that fetches data for the widget:

Widget({
    kind: "StockWidget",
    displayName: "Stock Price",
    description: "Shows current stock price",
    config: {
        symbol: { type: "string", displayName: "Symbol", default: "AAPL" },
    },
    entryFields: { price: "number", change: "string" },
    provider: async (config) => {
        const res = await fetch(`https://api.example.com/stock/${config.symbol}`)
        const data = await res.json()
        return { price: data.price, change: data.change }
    },
    // Inline-options form — the chain form `.font("title")` parses but is
    // dropped at HIR-lowering time (#195).
    render: (entry) =>
        VStack([
            Text(`$${entry.price}`, { font: "title" }),
            Text(entry.change, { color: "green" }),
        ]),
})

Note: the chain-style modifiers (.font("title").color("green")) parse but are dropped at HIR-lowering time — see #195. The verified extract above uses the inline-options form Text("...", { font: "title" }), which is what actually round-trips through the widget codegen.

Placeholder Data

When the widget has no data yet (e.g., first load), the provider can return placeholder data by providing a placeholder field:

Widget({
  kind: "NewsWidget",
  entryFields: { headline: "string", source: "string" },
  placeholder: { headline: "Loading...", source: "---" },
  // ...
});

Family-Specific Rendering

The render function accepts an optional second parameter for the widget family, allowing different layouts per size:

render: (entry, family) =>
  family === "systemLarge"
    ? VStack([
        Text(entry.title).font("title"),
        ForEach(entry.items, (item) => Text(item.name)),
      ])
    : HStack([
        Image("star.fill"),
        Text(entry.title).font("headline"),
      ]),

Supported families: "systemSmall", "systemMedium", "systemLarge", "accessoryCircular", "accessoryRectangular", "accessoryInline".

App Group

The appGroup field specifies a shared container for data exchange between the host app and the widget:

Widget({
  kind: "AppDataWidget",
  appGroup: "group.com.example.myapp",
  // ...
});

Multiple Widgets

Define multiple widgets in a single file. They’re bundled into a WidgetBundle:

Widget({
  kind: "SmallWidget",
  // ...
});

Widget({
  kind: "LargeWidget",
  // ...
});

Next Steps

  • Components — Available widget components and modifiers
  • Overview — Widget system overview

Widget Components & Modifiers

Available components and modifiers for widgets.

Status: this page mixes (a) tiny fragments showing component shape — rendered as plain text because they’re not standalone declarations and can’t compile — and (b) one full verified Widget at the bottom that compile-links via docs/examples/widgets/snippets.ts. The doc-tests harness can’t drive --target ios-widget/android-widget/ watchos-widget/wearos-tile directly (each needs --app-bundle-id and a platform SDK — #194). Note also that the modifier parser in crates/perry-hir/src/lower.rs (parse_modifiers_from_args / parse_single_modifier) only reads modifiers from inline option-object arguments — e.g. Text("hi", { font: "title", color: "red" }) and VStack([...], { padding: 16 }). Method-style chains like Text("hi").font("title") shown below parse without error but drop the modifier at HIR-lowering time (#195). The chain form is documented here because it reads naturally; the verified Complete Example at the bottom of the page uses the inline-options form that actually round-trips through the widget codegen path. The end-to-end reference is examples/widget_demo.ts.

Text

Text("Hello, World!")
Text(`${entry.name}: ${entry.value}`)

Text Modifiers

const t = Text("Styled");
t.font("title");       // .title, .headline, .body, .caption, etc.
t.color("blue");       // Named color or hex
t.bold();

Layout

VStack

VStack([
  Text("Top"),
  Text("Bottom"),
])

HStack

HStack([
  Text("Left"),
  Spacer(),
  Text("Right"),
])

ZStack

ZStack([
  Image("background"),
  Text("Overlay"),
])

Spacer

Flexible space that expands to fill available room:

HStack([
  Text("Left"),
  Spacer(),
  Text("Right"),
])

Image

Display SF Symbols or asset images:

Image("star.fill")           // SF Symbol
Image("cloud.sun.rain.fill") // SF Symbol

ForEach

Iterate over array entry fields to render a list of components:

ForEach(entry.items, (item) =>
  HStack([
    Text(item.name),
    Spacer(),
    Text(`${item.value}`),
  ])
)

Divider

A visual separator line:

VStack([
  Text("Above"),
  Divider(),
  Text("Below"),
])

Label

A label with text and an SF Symbol icon:

Label("Downloads", "arrow.down.circle")
Label(`${entry.count} items`, "folder.fill")

Gauge

A circular or linear progress indicator:

Gauge(entry.progress, 0, 100)       // value, min, max
Gauge(entry.battery, 0, 1.0)

Modifiers

Widget components support SwiftUI-style modifiers. The chain forms shown below parse but drop their modifiers at HIR-lowering time (#195) — use the inline option-object form (Text("hi", { font: "title" }), VStack([...], { padding: 16 })) for the form that actually reaches the codegen, as in the Complete Example at the bottom of this page.

Font

Text("Title").font("title")
Text("Body").font("body")
Text("Caption").font("caption")

Color

Text("Red text").color("red")
Text("Custom").color("#FF6600")

Padding

VStack([...]).padding(16)

Frame

widget.frame(width, height)

Max Width

widget.maxWidth("infinity")   // Expand to fill available width

Minimum Scale Factor

Allow text to shrink to fit:

Text("Long text").minimumScaleFactor(0.5)

Container Background

Set background color for the widget container:

VStack([...]).containerBackground("blue")

Widget URL

Make the widget tappable with a deep link:

VStack([...]).url("myapp://detail/123")

Edge-Specific Padding

Apply padding to specific edges:

VStack([...]).paddingEdge("top", 8)
VStack([...]).paddingEdge("horizontal", 16)

Conditionals

Render different components based on entry data:

render: (entry) =>
  VStack([
    entry.isOnline
      ? Text("Online").color("green")
      : Text("Offline").color("red"),
  ]),

Complete Example

The full Widget below is the verified extract — it compile-links on the host LLVM target and uses the inline-options modifier form that round-trips through the codegen.

Widget({
    kind: "StatsWidget",
    displayName: "Stats",
    description: "Shows daily stats",
    entryFields: {
        steps: "number",
        calories: "number",
        distance: "string",
    },
    // Inline-options modifier form — the `.font("title").bold()` chain form
    // parses but its modifiers don't reach the codegen (#195).
    render: (entry) =>
        VStack([
            HStack([
                Image("figure.walk"),
                Text("Daily Stats", { font: "headline" }),
            ]),
            Spacer(),
            HStack([
                VStack([
                    Text(`${entry.steps}`, { font: "title", fontWeight: "bold" }),
                    Text("steps", { font: "caption", color: "gray" }),
                ]),
                Spacer(),
                VStack([
                    Text(`${entry.calories}`, { font: "title", fontWeight: "bold" }),
                    Text("cal", { font: "caption", color: "gray" }),
                ]),
                Spacer(),
                VStack([
                    Text(entry.distance, { font: "title", fontWeight: "bold" }),
                    Text("km", { font: "caption", color: "gray" }),
                ]),
            ]),
        ], { padding: 16 }),
})

Next Steps

Widget Configuration

Perry widgets support user-configurable parameters. On iOS/watchOS, these compile to AppIntent configurations (the “Edit Widget” sheet). On Android/Wear OS, they compile to a Configuration Activity.

Status: the full TopSitesWidget declaration below compile-links cleanly on the host LLVM target via docs/examples/widgets/snippets.ts, so the config: { ... } shape is verified against parse_config_params in crates/perry-hir/src/lower.rs. The shorter fragments lower on the page (just a provider: body, just a config: object) are rendered as plain text — they’re not standalone declarations. The cross-compile targets themselves (--target ios-widget/ android-widget/watchos-widget/wearos-tile) still aren’t driven by the doc-tests harness — each needs --app-bundle-id and a platform SDK (#194).

Defining Config Fields

Add a config object to your Widget() declaration. Each field specifies a type, allowed values, a default, and a display title.

Widget({
    kind: "TopSitesWidget",
    displayName: "Top Sites",
    description: "Your top performing sites",
    supportedFamilies: ["systemSmall", "systemMedium"],
    appGroup: "group.com.example.shared",

    config: {
        sortBy: {
            type: "enum",
            values: ["clicks", "impressions", "ctr", "position"],
            default: "clicks",
            title: "Sort By",
        },
        dateRange: {
            type: "enum",
            values: ["7d", "28d", "90d"],
            default: "7d",
            title: "Date Range",
        },
    },

    entryFields: {
        total: "number",
        label: "string",
    },

    provider: async (config: { sortBy: string; dateRange: string }) => {
        const res = await fetch(
            `https://api.example.com/stats?sort=${config.sortBy}&range=${config.dateRange}`,
        )
        const data = await res.json()
        return {
            entries: [{ total: data.total, label: data.label }],
            reloadPolicy: { after: { minutes: 30 } },
        }
    },

    render: (entry) =>
        VStack([
            Text(`${entry.total}`, { font: "title", fontWeight: "bold" }),
            Text(entry.label, { font: "caption", color: "secondary" }),
        ]),
})

Supported Parameter Types

TypeTypeScriptDescription
Enum{ type: "enum", values: [...], default: "...", title: "..." }Picker with fixed choices
Boolean{ type: "bool", default: true, title: "..." }Toggle switch
String{ type: "string", default: "...", title: "..." }Free-text input

Accessing Config in the Provider

The provider function receives the current config values as its argument. The config object keys match the field names you defined:

provider: async (config: { sortBy: string; dateRange: string }) => {
  // config.sortBy === "clicks" | "impressions" | "ctr" | "position"
  // config.dateRange === "7d" | "28d" | "90d"
  const url = `https://api.example.com/data?sort=${config.sortBy}`;
  const res = await fetch(url);
  const data = await res.json();
  return { entries: [data] };
},

When the user changes a config value, the system calls your provider again with the updated config.

Boolean Config Example

config: {
  showDetails: {
    type: "bool",
    default: true,
    title: "Show Details",
  },
},

Platform Mapping

iOS / watchOS (AppIntent)

Perry generates a Swift WidgetConfigurationIntent struct with @Parameter properties and AppEnum types for each enum field. The widget uses AppIntentConfiguration instead of StaticConfiguration.

Generated output (auto-generated, not hand-written):

  • {Name}Intent.swift – contains the AppEnum cases and the intent struct
  • The provider conforms to AppIntentTimelineProvider instead of TimelineProvider
  • Config values are serialized to JSON and passed to the native provider function

Users configure the widget by long-pressing and selecting “Edit Widget”, which presents the system-generated AppIntent UI.

Android / Wear OS (Configuration Activity)

Perry generates a {Name}ConfigActivity.kt with Spinner controls for enum fields and Switch controls for boolean fields. Values are persisted in SharedPreferences keyed by widget ID.

Generated output:

  • {Name}ConfigActivity.kt – Activity with UI controls and a Save button
  • widget_info_{name}.xml – includes android:configure pointing to the config activity
  • AndroidManifest snippet includes an <activity> entry with APPWIDGET_CONFIGURE intent filter

The config activity launches automatically when the user first adds the widget.

Build Commands

# iOS
perry widget.ts --target ios-widget --app-bundle-id com.example.app -o widget_out

# Android
perry widget.ts --target android-widget --app-bundle-id com.example.app -o widget_out

Next Steps

Provider Function and Data Fetching

The provider function is the heart of a dynamic widget. It fetches data, transforms it, and returns timeline entries that the system renders on schedule.

Status: the basic WeatherWidget provider below compile-links cleanly on the host LLVM target via docs/examples/widgets/snippets.ts, so the provider/reloadPolicy/entryFields shapes are verified against the codegen. The shorter fragments lower on the page (a bare reloadPolicy:, a provider: body without surrounding Widget({...}), etc.) are rendered as plain text. The sharedStorage() and preferencesSet() examples are also rendered as plain text — those symbols are provided by the platform-specific glue (AppGroupBridge.swift, Bridge.kt) for --target ios-widget/android-widget/watchos-widget/ wearos-tile and don’t link on the host LLVM target. The cross-compile targets themselves still aren’t driven by the doc-tests harness — each needs --app-bundle-id and a platform SDK (#194).

Provider Lifecycle

  1. The system calls your provider when the widget is first added, when a snapshot is needed, and when the reload policy expires.
  2. Your provider runs as native LLVM-compiled code linked into the widget extension.
  3. The provider returns one or more timeline entries. The system renders each entry at its scheduled time.
  4. After the last entry, the reload policy determines when the provider runs again.

Basic Provider

Widget({
    kind: "WeatherProviderWidget",
    displayName: "Weather",
    description: "Current conditions",
    supportedFamilies: ["systemSmall"],

    entryFields: {
        temperature: "number",
        condition: "string",
    },

    provider: async () => {
        const res = await fetch("https://api.weather.example.com/current")
        const data = await res.json()
        return {
            entries: [
                { temperature: data.temp, condition: data.description },
            ],
            reloadPolicy: { after: { minutes: 15 } },
        }
    },

    render: (entry) =>
        VStack([
            Text(`${entry.temperature}°`, { font: "title" }),
            Text(entry.condition, { font: "caption" }),
        ]),
})

Authenticated Requests with Shared Storage

Widgets run in a separate process and cannot access your app’s memory. Use sharedStorage() to read values that your app has written to a shared container.

iOS / watchOS: App Groups

On Apple platforms, shared storage maps to UserDefaults(suiteName:) backed by an App Group container. Set the appGroup field in your widget declaration:

Widget({
  kind: "DashboardWidget",
  displayName: "Dashboard",
  description: "Account summary",
  appGroup: "group.com.example.shared",

  entryFields: {
    revenue: "number",
    users: "number",
  },

  provider: async () => {
    const token = sharedStorage("auth_token");
    const res = await fetch("https://api.example.com/dashboard", {
      headers: { Authorization: `Bearer ${token}` },
    });
    const data = await res.json();
    return {
      entries: [{ revenue: data.revenue, users: data.activeUsers }],
      reloadPolicy: { after: { minutes: 30 } },
    };
  },

  render: (entry) =>
    VStack([
      Text(`$${entry.revenue}`, { font: "title" }),
      Text(`${entry.users} active users`, { font: "caption" }),
    ]),
});

Your main app writes the token to the shared container:

import { preferencesSet } from "perry/system";
// In your app's login flow:
preferencesSet("auth_token", token);

Setup requirement (iOS): Add an App Group capability in Xcode to both the main app target and the widget extension target. The identifier must match the appGroup value.

Android / Wear OS: SharedPreferences

On Android, shared storage maps to SharedPreferences with the name perry_shared. The generated Bridge.kt reads values via context.getSharedPreferences("perry_shared", MODE_PRIVATE).

Reload Policies

The reloadPolicy field controls when the system next calls your provider:

return {
  entries: [{ ... }],
  reloadPolicy: { after: { minutes: 30 } },
};
PolicyBehavior
{ after: { minutes: N } }Re-fetch after N minutes. Compiles to .after(Date().addingTimeInterval(N*60)) on iOS and setFreshnessIntervalMillis(N*60000) on Wear OS.
(omitted)Defaults to 30 minutes on iOS, 30 minutes on Android/Wear OS.

Budget limits: iOS restricts widget refreshes. Typical budget is 40–70 refreshes per day. watchOS is stricter (see watchOS Complications). Request only what you need.

JSON Response Handling

The provider function receives the parsed JSON directly. Entry field types must match your entryFields declaration:

entryFields: {
  items: { type: "array", items: { type: "object", fields: { name: "string", count: "number" } } },
  total: "number",
},

provider: async () => {
  const res = await fetch("https://api.example.com/items");
  const data = await res.json();
  return {
    entries: [{
      items: data.results.map((r: any) => ({ name: r.name, count: r.count })),
      total: data.total,
    }],
  };
},

Error Handling

If the fetch fails or JSON parsing throws, the widget extension falls back to the placeholder data:

Widget({
  // ...
  placeholder: { temperature: 0, condition: "Loading..." },

  provider: async () => {
    const res = await fetch("https://api.example.com/weather");
    if (!res.ok) {
      // Return stale/fallback data with a short retry interval
      return {
        entries: [{ temperature: 0, condition: "Unavailable" }],
        reloadPolicy: { after: { minutes: 5 } },
      };
    }
    const data = await res.json();
    return {
      entries: [{ temperature: data.temp, condition: data.desc }],
      reloadPolicy: { after: { minutes: 15 } },
    };
  },
});

The placeholder field provides data shown in the widget gallery and during loading. If the provider throws an unhandled exception, the generated Swift/Kotlin code catches it and renders the placeholder instead.

Multiple Timeline Entries

Return multiple entries to schedule future content without re-fetching:

provider: async () => {
  const res = await fetch("https://api.example.com/hourly");
  const hours = await res.json();
  return {
    entries: hours.map((h: any) => ({
      temperature: h.temp,
      condition: h.condition,
    })),
    reloadPolicy: { after: { minutes: 60 } },
  };
},

Each entry is rendered at the corresponding date in the timeline. The system transitions between entries automatically.

Next Steps

Cross-Platform Reference

Perry widgets compile from a single TypeScript source to four platforms. The same Widget({...}) declaration produces native code for each target.

Status: this page has no TypeScript fences (only target-flag tables and shell build commands), so the doc-tests harness has nothing to run here. The --target flags listed below are all wired in crates/perry/src/commands/compile.rs, but the harness still can’t exercise them end-to-end — each requires --app-bundle-id and a platform SDK (Xcode, Android NDK).

Target Flags

PlatformTarget FlagOutput
iOS--target ios-widgetSwiftUI .swift + Info.plist
iOS Simulator--target ios-widget-simulatorSame, simulator SDK
Android--target android-widgetKotlin/Glance .kt + widget_info XML
watchOS--target watchos-widgetSwiftUI .swift (accessory families)
watchOS Simulator--target watchos-widget-simulatorSame, simulator SDK
Wear OS--target wearos-tileKotlin Tiles .kt + manifest

Feature Matrix

FeatureiOSAndroidwatchOSWear OS
TextYesYesYesYes
VStack/HStack/ZStackYesColumn/Row/BoxYesColumn/Row/Box
Image (SF Symbols)YesR.drawableYesR.drawable
SpacerYesYesYesYes
DividerYesSpacer+bgYesSpacer
ForEachYesforEachYesforEach
LabelYesRow compoundYesText fallback
GaugeN/AText fallbackYesCircularProgressIndicator
ConditionalYesifYesif
FamilySwitchYesLocalSizeYesrequestedSize
Config (AppIntent)YesConfig ActivityYes (10+)SharedPrefs
Native providerYesJNIYesJNI
sharedStorageUserDefaultsSharedPrefsUserDefaultsSharedPrefs
Deep linking (url)widgetURLclickable IntentwidgetURLN/A

Platform-Specific Notes

iOS

  • Minimum deployment: iOS 17.0
  • AppIntentConfiguration requires import AppIntents
  • Widget extension memory limit: ~30MB

Android

  • Requires Glance dependency: androidx.glance:glance-appwidget:1.1.0
  • Widget sizes mapped from iOS families: systemSmall=2x2, systemMedium=4x2, systemLarge=4x4
  • minimumScaleFactor not supported in Glance (skipped with warning)

watchOS

  • Minimum deployment: watchOS 9.0
  • Accessory families only (circular, rectangular, inline)
  • Tighter memory (~15-20MB) and refresh budgets (hourly)
  • AppIntent requires watchOS 10+; older versions get StaticConfiguration

Wear OS

  • Same native compilation as Android phone (Wear OS = Android)
  • Requires Horologist + Tiles Material 3 dependencies
  • Tiles are full-screen cards in the carousel
  • Gauge maps to CircularProgressIndicator

Build Instructions

iOS

perry widget.ts --target ios-widget --app-bundle-id com.example.app -o widget_out
xcrun --sdk iphoneos swiftc -target arm64-apple-ios17.0 \
  widget_out/*.swift -framework WidgetKit -framework SwiftUI \
  -o widget_out/WidgetExtension

Android

perry widget.ts --target android-widget --app-bundle-id com.example.app -o widget_out
# Copy .kt files to app/src/main/java/com/example/app/
# Copy xml/ to app/src/main/res/xml/
# Merge AndroidManifest_snippet.xml into AndroidManifest.xml

watchOS

perry widget.ts --target watchos-widget --app-bundle-id com.example.app -o widget_out
xcrun --sdk watchos swiftc -target arm64-apple-watchos9.0 \
  widget_out/*.swift -framework WidgetKit -framework SwiftUI \
  -o widget_out/WidgetExtension

Wear OS

perry widget.ts --target wearos-tile --app-bundle-id com.example.app -o widget_out
# Copy .kt files to Wear OS module
# Add Horologist + Tiles Material 3 dependencies to build.gradle
# Merge AndroidManifest_snippet.xml

watchOS Complications

Perry widgets can compile to watchOS WidgetKit complications using --target watchos-widget. The same Widget({...}) source produces both iOS and watchOS widgets — the supported families determine the rendering.

Status: the snippet on this page compile-links cleanly on the host LLVM target via docs/examples/widgets/snippets.ts, so the Widget({...}) shape is verified against the codegen. The actual --target watchos-widget / --target watchos-widget-simulator cross-compile is wired in crates/perry/src/commands/compile.rs (emits through the WidgetKit Swift emitter) but the doc-tests harness can’t drive it yet — each cross-target requires --app-bundle-id not yet surfaced through the harness (#194) plus a watchOS SDK from Xcode. Build with the perry CLI to validate end-to-end.

Accessory Families

watchOS complications use accessory families instead of system families:

FamilySizeBest For
accessoryCircular~76x76ptSingle icon, number, or Gauge
accessoryRectangular~160x76pt2-3 lines of text
accessoryInlineSingle lineShort text only

Gauge Component

The Gauge component is designed for watchOS circular complications:

Widget({
    kind: "QuickStats",
    displayName: "Quick Stats",
    supportedFamilies: ["accessoryCircular", "accessoryRectangular"],

    render(entry: { progress: number; label: string }, family) {
        if (family === "accessoryCircular") {
            return Gauge(entry.progress, 1.0)
        }
        return VStack([
            Text(entry.label),
            Gauge(entry.progress, 1.0),
        ])
    },
})

Gauge Styles

  • circular — Ring gauge, maps to .gaugeStyle(.accessoryCircularCapacity) in SwiftUI
  • linear / linearCapacity — Horizontal bar, maps to .gaugeStyle(.linearCapacity)

Refresh Budgets

watchOS has stricter refresh budgets than iOS:

  • Recommended: refresh every 60 minutes (reloadPolicy: { after: { minutes: 60 } })
  • Maximum: system may throttle more aggressively than iOS
  • Background refresh uses BackgroundTask framework

Compilation

# For Apple Watch device
perry widget.ts --target watchos-widget --app-bundle-id com.example.app -o widget_out

# For Apple Watch Simulator
perry widget.ts --target watchos-widget-simulator --app-bundle-id com.example.app -o widget_out

Build:

xcrun --sdk watchos swiftc -target arm64-apple-watchos9.0 \
  widget_out/*.swift \
  -framework WidgetKit -framework SwiftUI \
  -o widget_out/WidgetExtension

Configuration

  • watchOS 10+ supports AppIntent for widget configuration (same as iOS 17+)
  • Older watchOS versions automatically get StaticConfiguration fallback
  • config params work identically to iOS

Memory Considerations

watchOS widget extensions have tighter memory limits (~15-20MB) compared to iOS (~30MB). The provider-only compilation approach is critical — only the data-fetching code runs natively, keeping memory usage minimal.

Wear OS Tiles

Perry widgets can compile to Wear OS Tiles using --target wearos-tile. Tiles are glanceable surfaces in the Wear OS tile carousel and watch face complications.

Status: the snippet on this page compile-links cleanly on the host LLVM target via docs/examples/widgets/snippets.ts, so the Widget({...}) shape is verified against the codegen. --target wearos-tile itself is wired through crates/perry-codegen-wear-tiles but the doc-tests harness can’t drive that cross-target yet — --app-bundle-id plumbing is still pending (#194) and you’ll need an Android NDK + Wear OS Gradle deps. Build with the perry CLI to validate end-to-end.

Concepts

  • Tiles are full-screen cards users swipe through on their watch
  • Complications are small data displays on the watch face
  • Perry compiles Widget({...}) to a SuspendingTileService with layout builders

Supported Components

Widget APIWear OS Mapping
TextLayoutElementBuilders.Text
VStackLayoutElementBuilders.Column
HStackLayoutElementBuilders.Row
SpacerLayoutElementBuilders.Spacer
DividerSpacer with 1dp height
Gauge(circular)LayoutElementBuilders.Arc + ArcLine
Gauge(linear)Text fallback
ImageResource-based (provide drawable)

Example

Widget({
    kind: "StepsTile",
    displayName: "Steps",
    description: "Daily step count",
    supportedFamilies: ["accessoryCircular"],

    provider: async () => {
        return {
            entries: [{ steps: 7500, goal: 10000 }],
            reloadPolicy: { after: { minutes: 60 } },
        }
    },

    render(entry: { steps: number; goal: number }) {
        return VStack([
            Gauge(entry.steps / entry.goal, 1.0),
            Text(`${entry.steps}`),
        ])
    },
})

Compilation

perry widget.ts --target wearos-tile --app-bundle-id com.example.app -o tile_out

Output:

  • {Name}TileService.ktSuspendingTileService with tile layout
  • {Name}TileBridge.kt — JNI bridge for native provider (if provider exists)
  • AndroidManifest_snippet.xml — Service declaration

Gradle Integration

Add to your Wear OS module’s build.gradle:

dependencies {
    implementation "com.google.android.horologist:horologist-tiles:0.6.5"
    implementation "androidx.wear.tiles:tiles-material:1.4.0"
    implementation "androidx.wear.tiles:tiles:1.4.0"
}

Merge the manifest snippet into your AndroidManifest.xml:

<service
    android:name=".StepsTileService"
    android:exported="true"
    android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
    <intent-filter>
        <action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
    </intent-filter>
</service>

Native Provider

Same as Android phone widgets — Wear OS is Android:

  • Target triple: aarch64-linux-android
  • libwidget_provider.so loaded via System.loadLibrary
  • JNI bridge pattern identical to phone Glance widgets
  • sharedStorage() uses SharedPreferences

Refresh

Wear Tiles use freshnessIntervalMillis on the Tile builder. Set via reloadPolicy: { after: { minutes: N } } in the provider return value. Default: 60 minutes.

Plugin System Overview

Status: wired (#189 closed). Receiver-less calls (loadPlugin, listPlugins, emitHook, invokeTool, …) and PluginApi instance methods (api.registerHook, api.registerTool, …) dispatch through crates/perry-codegen/src/lower_call.rs::PERRY_PLUGIN_TABLE and PERRY_PLUGIN_INSTANCE_TABLE. TypeScript surface lives in types/perry/plugin/index.d.ts. Host-side snippets below are compile-link verified by the doc-tests harness against docs/examples/plugins/host_snippets.ts; plugin-side activate(api) snippets against docs/examples/plugins/plugin_snippets.ts.

Perry supports native plugins as shared libraries (.dylib/.so). Plugins extend Perry applications with custom hooks, tools, services, and routes.

How It Works

  1. A plugin is a Perry-compiled shared library with activate(api) and deactivate() entry points
  2. The host application loads plugins with loadPlugin(path)
  3. Plugins register hooks, tools, and services via the API handle
  4. The host dispatches events to plugins via emitHook(name, data)
Host Application
    ↓ loadPlugin("./my-plugin.dylib")
    ↓ calls plugin_activate(api_handle)
Plugin
    ↓ api.registerHook("beforeSave", callback)
    ↓ api.registerTool("format", callback)
Host
    ↓ emitHook("beforeSave", data) → plugin callback runs

Quick Example

Plugin (compiled with --output-type dylib)

let count = 0

export function activate(api: PluginApi) {
    api.setMetadata("counter", "1.0.0", "Counts hook invocations")

    api.registerHook("onRequest", (data) => {
        count++
        console.log(`Request #${count}`)
        return data
    })

    api.registerTool("getCount", "returns request count", () => count)
}

export function deactivate() {
    console.log(`Total requests processed: ${count}`)
}
perry my-plugin.ts --output-type dylib -o my-plugin.dylib

Host Application

import {
    loadPlugin, unloadPlugin,
    emitHook, emitEvent, invokeTool,
    setPluginConfig,
    discoverPlugins, listPlugins, listHooks, listTools,
    pluginCount, initPlugins,
} from "perry/plugin"

const id = loadPlugin("./counter-plugin.dylib")
console.log(`load returned: ${id !== 0 ? "ok" : "fail"}`)

const plugins = listPlugins()
const hooks = listHooks()
const tools = listTools()
console.log(`loaded: ${pluginCount()} plugin(s), ${hooks.length} hook(s), ${tools.length} tool(s)`)

const result = emitHook("beforeSave", { content: "hello world" })

const greeting = invokeTool("greet", { name: "Perry" })
const formatted = invokeTool("formatCode", {
    code: "const x=1",
    language: "typescript",
})

Plugin ABI

Plugins must export these symbols:

  • perry_plugin_abi_version() — Returns ABI version (for compatibility checking)
  • plugin_activate(api_handle) — Called when plugin is loaded
  • plugin_deactivate() — Called when plugin is unloaded

Perry generates these automatically from your activate/deactivate exports.

Native Extensions

Perry also supports native extensions — packages that bundle platform-specific Rust/Swift/JNI code and compile directly into your binary. These are used for accessing platform APIs like the App Store review prompt or StoreKit in-app purchases.

See Native Extensions for details.

Next Steps

Creating Plugins

Status: wired (#189 closed). See Plugin System Overview — Status for the full surface. Snippets below are compile-link verified by the doc-tests harness against docs/examples/plugins/plugin_snippets.ts and docs/examples/plugins/host_snippets.ts.

Build Perry plugins as shared libraries that extend host applications.

Step 1: Write the Plugin

let count = 0

export function activate(api: PluginApi) {
    api.setMetadata("counter", "1.0.0", "Counts hook invocations")

    api.registerHook("onRequest", (data) => {
        count++
        console.log(`Request #${count}`)
        return data
    })

    api.registerTool("getCount", "returns request count", () => count)
}

export function deactivate() {
    console.log(`Total requests processed: ${count}`)
}

Step 2: Compile as Shared Library

perry counter-plugin.ts --output-type dylib -o counter-plugin.dylib

The --output-type dylib flag tells Perry to produce a .dylib (macOS) or .so (Linux) instead of an executable.

Perry automatically:

  • Generates perry_plugin_abi_version() returning the current ABI version
  • Generates plugin_activate(api_handle) calling your activate() function
  • Generates plugin_deactivate() calling your deactivate() function
  • Exports symbols with -rdynamic for the host to find

Step 3: Load from Host

import {
    loadPlugin, unloadPlugin,
    emitHook, emitEvent, invokeTool,
    setPluginConfig,
    discoverPlugins, listPlugins, listHooks, listTools,
    pluginCount, initPlugins,
} from "perry/plugin"

const id = loadPlugin("./counter-plugin.dylib")
console.log(`load returned: ${id !== 0 ? "ok" : "fail"}`)

const found = discoverPlugins("./plugins/")
console.log(`discovered ${found.length} plugin(s)`)

const result = emitHook("beforeSave", { content: "hello world" })

const greeting = invokeTool("greet", { name: "Perry" })
const formatted = invokeTool("formatCode", {
    code: "const x=1",
    language: "typescript",
})

Plugin API Reference

The api: PluginApi passed to activate() provides:

Metadata

api.setMetadata(name: string, version: string, description: string): void

Hooks

api.registerHook(name: string, handler: (ctx: unknown) => unknown): void
api.registerHookEx(name: string, handler: (ctx: unknown) => unknown, priority: number, mode: number): void

registerHook defaults to priority 10 / mode 0 (filter). Use registerHookEx for explicit priority and mode (0=filter, 1=action, 2=waterfall). Lower priority numbers run first.

Tools

api.registerTool(name: string, description: string, handler: (args: unknown) => unknown): void

Tools are invoked by name from the host.

Configuration

const value = api.getConfig(key: string)  // Read host-provided config

Events

api.on(event: string, handler: (data: unknown) => void): void  // Listen for events
api.emit(event: string, data: unknown): void                    // Emit to other plugins

Next Steps

Hooks & Events

Status: wired (#189 closed). api.registerHook, api.on, emitHook, emitEvent, invokeTool all dispatch to crates/perry-runtime/src/plugin.rs. Snippets below are compile-link verified against docs/examples/plugins/{plugin,host}_snippets.ts.

Perry plugins communicate through hooks, events, and tools.

Hook Modes

Hooks support three execution modes:

Filter Mode (default)

Each plugin receives data and returns (possibly modified) data. The output of one plugin becomes the input of the next:

function registerFilter(api: PluginApi) {
    api.registerHook("transform", (data: any) => {
        data.content = data.content.toUpperCase()
        return data // Returned data goes to next plugin
    })
}

Action Mode

Plugins receive data but return value is ignored. Used for side effects. Pass mode = 1 to registerHookEx:

function registerAction(api: PluginApi) {
    api.registerHook("onSave", (data: any) => {
        console.log(`Saved: ${data.path}`)
        return data
    })
}

Waterfall Mode

Like filter mode, but specifically for accumulating/building up a result through the chain. Pass mode = 2 to registerHookEx:

function registerWaterfall(api: PluginApi) {
    api.registerHook("buildMenu", (items: any) => {
        items.push({ label: "My Plugin Action", action: () => {} })
        return items
    })
}

Hook Priority

Lower priority numbers run first. Use registerHookEx for explicit priority and mode:

function registerPriorities(api: PluginApi, validate: (d: any) => any, transform: (d: any) => any, log: (d: any) => any) {
    // Lower priority numbers run first; default 10. Mode 0=filter / 1=action / 2=waterfall.
    api.registerHookEx("beforeSave", validate, 10, 0)   // Runs first
    api.registerHookEx("beforeSave", transform, 20, 0)  // Runs second
    api.registerHookEx("beforeSave", log, 100, 1)        // Runs last (action mode)
}

Default priority is 10 (the value registerHook passes implicitly).

Event Bus

Plugins can communicate with each other through events:

Emitting Events

function emitFromPlugin(api: PluginApi) {
    api.emit("dataUpdated", { source: "my-plugin", records: 42 })
}
emitEvent("dataUpdated", { source: "host", records: 100 })

Listening for Events

function listenForEvent(api: PluginApi) {
    api.on("dataUpdated", (data: any) => {
        console.log(`${data.source} updated ${data.records} records`)
    })
}

Tools

Plugins register callable tools (note the 3-arg shape: name, description, handler):

function registerFormatter(api: PluginApi) {
    api.registerTool("formatCode", "format source code", (args: any) => {
        return `// formatted: ${args.code}`
    })
}
const greeting = invokeTool("greet", { name: "Perry" })
const formatted = invokeTool("formatCode", {
    code: "const x=1",
    language: "typescript",
})

Configuration

Hosts can pass configuration to plugins via setPluginConfig:

initPlugins()
setPluginConfig("api_key", "test-key")
setPluginConfig("max_retries", "3")
function readConfig(api: PluginApi) {
    const theme = api.getConfig("theme")     // "dark"
    const retries = api.getConfig("maxRetries") // "3"
    return { theme, retries }
}

Introspection

Query loaded plugins and their registrations:

const plugins = listPlugins()
const hooks = listHooks()
const tools = listTools()
console.log(`loaded: ${pluginCount()} plugin(s), ${hooks.length} hook(s), ${tools.length} tool(s)`)

Next Steps

Native Extensions

Status: partially wired. The --bundle-extensions flag, the perry.nativeLibrary package.json manifest, and declare function FFI imports are all wired into the compiler — see crates/perry/src/commands/compile.rs (the bundle_extensions argument, the parse_native_library_manifest/build_external_native_libraries helpers) and crates/perry-codegen/src/codegen.rs (the nativeLibrary.functions signature parser). The TypeScript snippets below assume a fully-populated extension directory exists on disk (e.g. perry-appstore-review cloned under ./extensions/). They are kept as ,no-test because the doc-tests harness doesn’t have those external extensions checked in — a real project that does have them will compile cleanly. Drift protection for the parts that don’t depend on external extensions (the FFI declare-function shape, etc.) lives in docs/examples/platforms/wasm_snippets.ts.

Perry supports native extensions — packages that bundle platform-specific code (Rust, Swift, JNI) alongside a TypeScript API. Unlike dynamic plugins loaded at runtime, native extensions are compiled directly into your binary.

Native extensions are how you access platform APIs that aren’t part of Perry’s built-in System APIs or Standard Library. Examples include App Store Review and StoreKit for in-app purchases.

Using a native extension

1. Add the extension to your project

Place the extension directory alongside your project, or in a shared extensions directory:

my-app/
├── package.json
├── src/
│   └── index.ts
└── extensions/
    └── perry-appstore-review/
        ├── package.json
        ├── src/
        │   └── index.ts
        ├── crate-ios/
        ├── crate-android/
        └── crate-stub/

2. Compile with --bundle-extensions

Pass the extensions directory when building:

perry src/index.ts -o app --target ios --bundle-extensions ./extensions

Perry discovers every subdirectory with a package.json, compiles its native crates for the target platform, and links them into your binary.

3. Import and use

import { requestReview } from "perry-appstore-review";

await requestReview();

The import resolves at compile time to the extension’s entry point. No runtime module loading is involved — the function compiles to a direct native call.

How native extensions work

A native extension is a directory with a package.json that declares a perry.nativeLibrary section. This tells Perry which native functions exist, their signatures, and which Rust crate to compile for each platform.

package.json manifest

{
  "name": "perry-appstore-review",
  "version": "0.1.0",
  "main": "src/index.ts",
  "perry": {
    "nativeLibrary": {
      "functions": [
        { "name": "sb_appreview_request", "params": [], "returns": "f64" }
      ],
      "targets": {
        "ios": {
          "crate": "crate-ios",
          "lib": "libperry_appreview.a",
          "frameworks": ["StoreKit"]
        },
        "android": {
          "crate": "crate-android",
          "lib": "libperry_appreview.a",
          "frameworks": []
        },
        "macos": {
          "crate": "crate-ios",
          "lib": "libperry_appreview.a",
          "frameworks": ["StoreKit"]
        }
      }
    }
  }
}

functions

Each entry declares a native function the extension exports:

FieldDescription
nameSymbol name — must match the #[no_mangle] Rust function exactly
paramsArray of LLVM types: "i64" for pointers/strings, "f64" for numbers, "i32" for integers
returnsReturn type — typically "f64" (NaN-boxed value or promise handle)

targets

Each target platform maps to a Rust crate that implements the native functions:

FieldDescription
crateRelative path to the Rust crate directory
libName of the static library produced by cargo build
frameworksSystem frameworks to link (iOS/macOS only)

Multiple targets can share the same crate (e.g., iOS and macOS often share an implementation). Platforms without an entry fall back to the stub.

Extension directory layout

perry-appstore-review/
├── package.json              # Manifest with perry.nativeLibrary
├── src/
│   └── index.ts              # TypeScript API (what users import)
├── crate-ios/                # iOS/macOS native implementation
│   ├── Cargo.toml            # [lib] crate-type = ["staticlib"]
│   ├── build.rs              # Compiles Swift if needed
│   ├── src/
│   │   └── lib.rs            # Rust FFI: #[no_mangle] pub extern "C" fn ...
│   └── swift/
│       └── bridge.swift      # Swift bridge for Apple APIs (@_cdecl)
├── crate-android/            # Android native implementation
│   ├── Cargo.toml
│   └── src/
│       └── lib.rs            # Rust FFI with JNI calls
└── crate-stub/               # Fallback for unsupported platforms
    ├── Cargo.toml
    └── src/
        └── lib.rs            # Returns error immediately

TypeScript side

The src/index.ts declares native functions and optionally wraps them in a friendlier API:

// Declare the native function (name must match package.json)
declare function sb_appreview_request(): number;

// Wrap it with a proper TypeScript signature
export async function requestReview(): Promise<void> {
  await (sb_appreview_request() as any);
}

declare function tells Perry the function is provided by native code. The raw return type is number because all values cross the FFI boundary as NaN-boxed f64 values. Promise handles are NaN-boxed pointers that Perry’s runtime knows how to await.

Rust side

Each platform crate is a staticlib that implements the declared functions using #[no_mangle] pub extern "C":

#![allow(unused)]
fn main() {
// Perry runtime FFI
extern "C" {
    fn js_promise_new() -> *mut u8;
    fn js_promise_resolve(promise: *mut u8, value: f64);
    fn js_nanbox_string(ptr: i64) -> f64;
    fn js_nanbox_pointer(ptr: i64) -> f64;
}

#[no_mangle]
pub extern "C" fn sb_appreview_request() -> f64 {
    unsafe {
        let promise = js_promise_new();
        // ... call platform API, resolve promise when done ...
        js_nanbox_pointer(promise as i64)
    }
}
}

Key runtime functions available to native code:

FunctionPurpose
js_promise_new()Create a new Perry promise, returns pointer
js_promise_resolve(promise, value)Resolve a promise with a NaN-boxed value
js_nanbox_string(ptr)Convert a C string pointer to a NaN-boxed string
js_nanbox_pointer(ptr)Convert a pointer to a NaN-boxed object reference
js_get_string_pointer_unified(val)Extract string pointer from a NaN-boxed value
js_string_from_bytes(ptr, len)Create a Perry string from bytes

Swift bridge (iOS/macOS)

Apple platform APIs are often easiest to call from Swift. The pattern is:

  1. Write a Swift file with @_cdecl("function_name") exports
  2. Compile it to a static library in build.rs
  3. Call the Swift functions from Rust via extern "C"
import StoreKit

typealias Callback = @convention(c) (UnsafeMutableRawPointer, UnsafePointer<CChar>) -> Void

@_cdecl("swift_appreview_request")
func swiftRequestReview(_ callback: @escaping Callback, _ context: UnsafeMutableRawPointer) {
    DispatchQueue.main.async {
        if let scene = UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
            SKStoreReviewController.requestReview(in: scene)
        }
        let result = "{\"success\":true}"
        result.withCString { callback(context, $0) }
    }
}

The build.rs compiles the Swift source into a static library using swiftc, targeting the correct platform SDK:

// build.rs (simplified)
fn main() {
    // Detect target: aarch64-apple-ios → arm64-apple-ios16.0, iphoneos SDK
    // Compile: swiftc -emit-library -static -target ... -sdk ... -framework StoreKit
    // Link:    cargo:rustc-link-lib=static=review_bridge
}

JNI bridge (Android)

Android platform APIs are accessed through JNI. The pattern:

  1. Get the JavaVM via JNI_GetCreatedJavaVMs()
  2. Attach the current thread to get a JNIEnv
  3. Call Java/Kotlin APIs through JNI method invocations
  4. Resolve the Perry promise with the result
#![allow(unused)]
fn main() {
use jni::JavaVM;
use jni::objects::JValue;

fn request_review_impl() -> Result<(), String> {
    let vm = get_java_vm()?;
    let mut env = vm.attach_current_thread_as_daemon().map_err(|e| e.to_string())?;

    // Get Activity from PerryBridge
    let bridge = env.find_class("com/perry/app/PerryBridge").map_err(|e| e.to_string())?;
    let activity = env.call_static_method(bridge, "getActivity", "()Landroid/app/Activity;", &[])
        .map_err(|e| e.to_string())?.l().map_err(|e| e.to_string())?;

    // Call platform APIs via JNI...
    Ok(())
}
}

If the Android implementation requires a Java library (e.g., Google Play In-App Review), the app’s build.gradle must include the dependency. Document this requirement clearly for your extension’s users.

Stub crate

For platforms without a native implementation, the stub immediately resolves the promise with an error:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn sb_appreview_request() -> f64 {
    unsafe {
        let promise = js_promise_new();
        let msg = "{\"error\":\"Not available on this platform\"}";
        let c_str = std::ffi::CString::new(msg).unwrap();
        let val = js_nanbox_string(c_str.as_ptr() as i64);
        std::mem::forget(c_str);
        js_promise_resolve(promise, val);
        js_nanbox_pointer(promise as i64)
    }
}
}

Build requirements

PlatformRequirements
iOSmacOS host, Xcode, rustup target add aarch64-apple-ios
iOS SimulatormacOS host, Xcode, rustup target add aarch64-apple-ios-sim
macOSmacOS host, Xcode Command Line Tools
AndroidAndroid NDK, rustup target add aarch64-linux-android

When Perry encounters a perry.nativeLibrary manifest during compilation, it:

  1. Selects the crate for the current --target platform
  2. Runs cargo build --release --target <triple> in the crate directory
  3. Links the resulting .a static library into the final binary
  4. Adds any declared frameworks (e.g., -framework StoreKit)

Creating your own native extension

  1. Create the directory structure shown above
  2. Define your functions in package.json under perry.nativeLibrary
  3. Implement each function in the platform crates with matching #[no_mangle] pub extern "C" signatures
  4. Write a TypeScript entry point that declares and optionally wraps the native functions
  5. Add a stub crate for unsupported platforms
  6. Test with --bundle-extensions:
    perry app.ts --target ios-simulator --bundle-extensions ./extensions
    

Next Steps

App Store Review

Status: extension-dependent. The compiler-side wiring (--bundle-extensions, perry.nativeLibrary, declare function) is in place — see Native Extensions — Status — but the snippets below assume the perry-appstore-review extension repo has been cloned into ./extensions/. The doc-tests harness doesn’t ship that repo, so these snippets are kept as ,no-test. Once the extension is on disk, they compile and run on iOS / iOS Simulator / macOS / Android.

Prompt users to rate your app using the native app store review dialog on iOS and Android.

The perry-appstore-review extension exposes a single function — requestReview() — that opens the platform’s native review prompt. It does nothing else: when and how often to ask is entirely up to you.

Repository: github.com/PerryTS/appstorereview

Quick start

1. Add the extension

Clone or copy the extension into your project’s extensions directory:

mkdir -p extensions
cd extensions
git clone https://github.com/PerryTS/appstorereview.git perry-appstore-review
cd ..

Your project structure:

my-app/
├── package.json
├── src/
│   └── index.ts
└── extensions/
    └── perry-appstore-review/

2. Use in your app

import { requestReview } from "perry-appstore-review";

// Show the review prompt when the user completes a meaningful action
async function onLevelComplete() {
  await requestReview();
}

3. Build

perry src/index.ts -o app --target ios --bundle-extensions ./extensions

The --bundle-extensions flag tells Perry to discover, compile, and link all native extensions in the given directory. The app store review native code is compiled and statically linked into your binary — no runtime dependencies.

API

requestReview(): Promise<void>

Opens the native app store review prompt. Returns a promise that resolves when the prompt has been presented (or skipped by the OS).

import { requestReview } from "perry-appstore-review";

await requestReview();

The function only triggers the prompt. It does not:

  • Track whether the user has already reviewed
  • Throttle how often the prompt appears (iOS does this automatically; Android does not)
  • Return whether the user actually left a review (neither platform provides this)

Platform behavior

iOS

Uses SKStoreReviewController.requestReview(in:) from StoreKit.

DetailValue
Native APISKStoreReviewController.requestReview(in: UIWindowScene)
Minimum iOS version14.0
FrameworkStoreKit
ThreadDispatched to main thread automatically
ThrottlingApple limits display to 3 times per 365-day period per app. The system may silently ignore the call.
Development buildsAlways shown in debug/TestFlight builds
User controlUsers can disable review prompts in Settings > App Store

Important: Apple’s throttling means the prompt is not guaranteed to appear every time requestReview() is called. Design your app flow so that not showing the prompt doesn’t break the user experience.

macOS

Uses the same StoreKit API. Shares the iOS native crate (both compile from crate-ios).

DetailValue
Native APISKStoreReviewController.requestReview()
Minimum macOS version13.0
FrameworkStoreKit
ThrottlingSame as iOS — system-controlled

Only works for apps distributed through the Mac App Store.

Android

Uses the Google Play In-App Review API.

DetailValue
Native APIReviewManager.requestReviewFlow() + launchReviewFlow()
Librarycom.google.android.play:review
Minimum API level21 (Android 5.0)
ThrottlingGoogle enforces a quota — the prompt may not appear every time
ExecutionRuns on a background thread to avoid blocking the UI

Required Gradle dependency: The Google Play In-App Review API is not part of the Android SDK. You must add it to your app’s build.gradle:

dependencies {
    implementation 'com.google.android.play:review:2.0.2'
}

Without this dependency, requestReview() will resolve with an error explaining the missing library.

Other platforms

On unsupported platforms (Linux, Windows, Web), requestReview() resolves immediately with an error. It will not throw — your app continues normally.

Best practices

Do ask at the right moment. Prompt after a positive experience — completing a level, finishing a task, achieving a goal. Don’t ask on first launch or during onboarding.

Don’t ask too often. Even though iOS throttles automatically, Android does not have the same strict limits. Implement your own logic to track when you last asked:

import { requestReview } from "perry-appstore-review";
import { preferencesGet, preferencesSet } from "perry/system";

async function maybeAskForReview() {
  const lastAsked = Number(preferencesGet("lastReviewAsk") || "0");
  const now = Date.now();
  const thirtyDays = 30 * 24 * 60 * 60 * 1000;

  if (now - lastAsked > thirtyDays) {
    preferencesSet("lastReviewAsk", String(now));
    await requestReview();
  }
}

Don’t condition app behavior on the review. Neither iOS nor Android tells you whether the user left a review, gave a rating, or dismissed the prompt. The promise resolving does not mean a review was submitted.

Don’t use custom review dialogs before the native one. Both Apple and Google discourage showing your own “Rate this app?” dialog before the native prompt. The native prompt is designed to be low-friction — adding a pre-prompt increases abandonment.

Extension structure

The extension follows the standard native extension layout:

perry-appstore-review/
├── package.json              # Declares sb_appreview_request function
├── src/
│   └── index.ts              # Exports requestReview()
├── crate-ios/                # iOS/macOS: Swift → SKStoreReviewController
│   ├── Cargo.toml
│   ├── build.rs              # Compiles Swift to static library
│   ├── src/lib.rs            # Rust FFI bridge
│   └── swift/review_bridge.swift
├── crate-android/            # Android: JNI → Play In-App Review API
│   ├── Cargo.toml
│   └── src/lib.rs
└── crate-stub/               # Other platforms: resolves with error
    ├── Cargo.toml
    └── src/lib.rs

One native function is declared in package.json:

{
  "perry": {
    "nativeLibrary": {
      "functions": [
        { "name": "sb_appreview_request", "params": [], "returns": "f64" }
      ]
    }
  }
}

The TypeScript layer wraps this into the public requestReview() function. The native layer creates a Perry promise, calls the platform API, and resolves the promise when done.

Next Steps

Geisterhand — In-Process UI Testing

Geisterhand (German for “ghost hand”) embeds a lightweight HTTP server inside your Perry app that lets you interact with every widget programmatically. Click buttons, type into text fields, drag sliders, toggle switches, capture screenshots, and run chaos-mode random fuzzing — all via simple HTTP calls.

It works on all 5 native platforms (macOS, iOS, Android, Linux/GTK4, Windows) with zero external dependencies. The server starts automatically when you compile with --enable-geisterhand.


Quick Start

# 1. Compile with geisterhand enabled (libs auto-build on first use)
perry app.ts -o app --enable-geisterhand

# 2. Run the app
./app
# [geisterhand] listening on http://127.0.0.1:7676

# 3. In another terminal — interact with the app
curl http://127.0.0.1:7676/widgets            # List all widgets
curl -X POST http://127.0.0.1:7676/click/3     # Click button with handle 3
curl http://127.0.0.1:7676/screenshot -o s.png # Capture window screenshot

Custom Port

The default port is 7676. Use --geisterhand-port to change it (this implies --enable-geisterhand, so you don’t need both flags):

perry app.ts -o app --geisterhand-port 9090
# or with perry run:
perry run --geisterhand-port 9090

With perry run

perry run --enable-geisterhand
perry run macos --geisterhand-port 8080
perry run ios --enable-geisterhand

API Reference

All endpoints return JSON unless noted otherwise. All responses include Access-Control-Allow-Origin: * for browser-based tools. OPTIONS requests are supported for CORS preflight.

Health Check

GET /health
→ {"status":"ok"}

Use this to wait for the app to be ready before running tests.

List Widgets

GET /widgets

Returns a JSON array of all registered widgets:

[
  {"handle": 3, "widget_type": 0, "callback_kind": 0, "label": "Click Me", "shortcut": ""},
  {"handle": 4, "widget_type": 1, "callback_kind": 1, "label": "Type here...", "shortcut": ""},
  {"handle": 5, "widget_type": 2, "callback_kind": 1, "label": "", "shortcut": ""},
  {"handle": 6, "widget_type": 3, "callback_kind": 1, "label": "Enable", "shortcut": ""},
  {"handle": 7, "widget_type": 5, "callback_kind": 0, "label": "Save", "shortcut": "s"},
  {"handle": 8, "widget_type": 8, "callback_kind": 0, "label": "", "shortcut": ""}
]

Supports query parameter filters:

  • GET /widgets?label=Save — filter by label substring (case-insensitive)
  • GET /widgets?type=button — filter by widget type name or code
  • GET /widgets?label=Save&type=5 — combine filters

Widget Types

CodeTypeDescription
0ButtonPush button with onClick
1TextFieldText input field
2SliderNumeric slider
3ToggleOn/off switch
4PickerDropdown selector
5MenuMenu item
6ShortcutKeyboard shortcut
7TableData table
8ScrollViewScrollable container

Callback Kinds

CodeKindDescription
0onClickTriggered on click/tap
1onChangeTriggered on value change
2onSubmitTriggered on submit (e.g., pressing Enter)
3onHoverTriggered on mouse hover
4onDoubleClickTriggered on double-click
5onFocusTriggered on focus

A single widget may appear multiple times in the list with different callback kinds. For example, a button with both onClick and onHover handlers produces two entries (same handle, different callback_kind).

Click a Widget

POST /click/:handle
→ {"ok":true}

Fires the widget’s onClick callback. Works with buttons, menu items, shortcuts, and table rows.

curl -X POST http://127.0.0.1:7676/click/3

Type into a TextField

POST /type/:handle
Content-Type: application/json

{"text": "hello world"}

Sets the text field’s content and fires its onChange callback with the new text as a NaN-boxed string.

curl -X POST http://127.0.0.1:7676/type/4 \
  -H 'Content-Type: application/json' \
  -d '{"text":"hello world"}'

Move a Slider

POST /slide/:handle
Content-Type: application/json

{"value": 0.75}

Sets the slider position and fires onChange with the numeric value.

curl -X POST http://127.0.0.1:7676/slide/5 \
  -H 'Content-Type: application/json' \
  -d '{"value":0.75}'

Toggle a Switch

POST /toggle/:handle
→ {"ok":true}

Fires the toggle’s onChange callback with a boolean value.

curl -X POST http://127.0.0.1:7676/toggle/6

Set State Directly

POST /state/:handle
Content-Type: application/json

{"value": 42}

Directly sets a State cell’s value, bypassing widget callbacks. This triggers any reactive bindings attached to the state (bound text labels, visibility, forEach loops, etc.).

curl -X POST http://127.0.0.1:7676/state/2 \
  -H 'Content-Type: application/json' \
  -d '{"value":42}'

Hover

POST /hover/:handle
→ {"ok":true}

Fires the widget’s onHover callback. Useful for testing hover-dependent UI (tooltips, color changes, etc.).

Double-Click

POST /doubleclick/:handle
→ {"ok":true}

Fires the widget’s onDoubleClick callback.

Trigger Keyboard Shortcut

POST /key
Content-Type: application/json

{"shortcut": "s"}

Finds a registered menu item whose shortcut matches and fires its callback. Shortcut strings are case-insensitive and match the key string passed to menuAddItem (e.g., "s" for Cmd+S, "S" for Cmd+Shift+S, "n" for Cmd+N).

curl -X POST http://127.0.0.1:7676/key \
  -H 'Content-Type: application/json' \
  -d '{"shortcut":"s"}'

Returns {"ok":true} if a matching shortcut was found, or 404 if no match.

Scroll a ScrollView

POST /scroll/:handle
Content-Type: application/json

{"x": 0, "y": 100}

Sets the scroll offset of a ScrollView widget. Both x and y are in points.

curl -X POST http://127.0.0.1:7676/scroll/8 \
  -H 'Content-Type: application/json' \
  -d '{"x":0,"y":200}'

Capture Screenshot

GET /screenshot
→ (binary PNG image, Content-Type: image/png)

Captures the app window as a PNG image. The response is raw binary data, not JSON.

curl http://127.0.0.1:7676/screenshot -o screenshot.png

Screenshot capture is synchronous from the caller’s perspective — the HTTP request blocks until the main thread completes the capture (timeout: 5 seconds).

Platform-specific capture methods:

PlatformMethodNotes
macOSCGWindowListCreateImageRetina resolution, reads from window ID
iOSUIGraphicsImageRendererDraws view hierarchy into image context
AndroidJNI View.draw() on CanvasCreates Bitmap, compresses to PNG
Linux (GTK4)WidgetPaintable + GskRendererRenders to texture, saves as PNG bytes
WindowsPrintWindow + GetDIBitsInline PNG encoder (stored zlib blocks)

Chaos Mode

Chaos mode randomly interacts with widgets at a configurable interval — useful for stress testing, finding edge cases, and crash hunting.

Start

POST /chaos/start
Content-Type: application/json

{"interval_ms": 200}
# Fire random inputs every 200ms
curl -X POST http://127.0.0.1:7676/chaos/start \
  -H 'Content-Type: application/json' \
  -d '{"interval_ms":200}'

If interval_ms is omitted, a default interval is used. The chaos thread randomly selects a registered widget and fires an appropriate input based on widget type:

Widget TypeRandom Input
ButtonFires onClick (no args)
TextFieldRandom alphanumeric string, 5-20 characters
SliderRandom float between 0.0 and 1.0
ToggleRandom true/false
PickerRandom index 0-9
MenuFires onClick (no args)
ShortcutFires onClick (no args)
TableFires onClick (no args)

Status

GET /chaos/status
→ {"running":true,"events_fired":247,"uptime_secs":12}

Returns whether chaos mode is active, how many random events have been fired, and uptime in seconds.

Stop

POST /chaos/stop
→ {"ok":true,"chaos":"stopped"}

Error Responses

All endpoints return errors as JSON with an appropriate HTTP status code:

{"error": "widget handle 99 not found"}

Common errors:

  • 404 — widget handle not found
  • 400 — malformed JSON body or missing required field
  • 405 — unsupported HTTP method

Platform Setup

macOS

No extra setup needed. The server binds to 0.0.0.0:7676 and is accessible on localhost.

perry app.ts -o app --enable-geisterhand
./app
curl http://127.0.0.1:7676/widgets

iOS Simulator

The iOS Simulator shares the host’s network stack — access the server directly on localhost:

perry app.ts -o app --target ios-simulator --enable-geisterhand
xcrun simctl install booted app.app
xcrun simctl launch booted com.perry.app
curl http://127.0.0.1:7676/widgets

iOS Device

For physical iOS devices, you need a network route to the device (same Wi-Fi network) or use iproxy from libimobiledevice:

perry app.ts -o app --target ios --enable-geisterhand
# Install and launch via Xcode/devicectl
# Then connect via the device's IP:
curl http://192.168.1.42:7676/widgets

Android (Emulator or Device)

Use adb forward to bridge the port. Ensure INTERNET permission is in your manifest (or add it to perry.toml):

[android]
permissions = ["INTERNET"]
perry app.ts -o app --target android --enable-geisterhand
# Package into APK and install
adb forward tcp:7676 tcp:7676
curl http://127.0.0.1:7676/widgets

Linux (GTK4)

Install GTK4 development libraries first:

# Ubuntu/Debian
sudo apt install libgtk-4-dev libcairo2-dev

perry app.ts -o app --target linux --enable-geisterhand
./app
curl http://127.0.0.1:7676/widgets

Windows

perry app.ts -o app --target windows --enable-geisterhand
./app.exe
curl http://127.0.0.1:7676/widgets

Test Automation

Geisterhand turns your Perry app into a testable HTTP service. Here are practical patterns for automated testing.

Shell Script Tests

A simple end-to-end test using bash:

#!/bin/bash
set -e

# Build with geisterhand
perry app.ts -o testapp --enable-geisterhand

# Start the app in background
./testapp &
APP_PID=$!
trap "kill $APP_PID 2>/dev/null" EXIT

# Wait for the app to be ready
for i in $(seq 1 30); do
  curl -sf http://127.0.0.1:7676/health && break
  sleep 0.1
done

# Get widgets
WIDGETS=$(curl -sf http://127.0.0.1:7676/widgets)
echo "Registered widgets: $WIDGETS"

# Find the button labeled "Submit"
SUBMIT_HANDLE=$(echo "$WIDGETS" | jq -r '.[] | select(.label == "Submit") | .handle')

# Click it
curl -sf -X POST "http://127.0.0.1:7676/click/$SUBMIT_HANDLE"

# Take a screenshot after interaction
curl -sf http://127.0.0.1:7676/screenshot -o after-click.png

echo "Test passed"

Python Test Example

import subprocess, time, requests, json

# Start the app
proc = subprocess.Popen(["./testapp"])
time.sleep(1)  # Wait for startup

try:
    # List widgets
    widgets = requests.get("http://127.0.0.1:7676/widgets").json()

    # Find widgets by label
    buttons = [w for w in widgets if w["widget_type"] == 0]
    fields = [w for w in widgets if w["widget_type"] == 1]

    # Type into the first text field
    if fields:
        requests.post(
            f"http://127.0.0.1:7676/type/{fields[0]['handle']}",
            json={"text": "test@example.com"}
        )

    # Click the first button
    if buttons:
        requests.post(f"http://127.0.0.1:7676/click/{buttons[0]['handle']}")

    # Capture screenshot for visual regression
    png = requests.get("http://127.0.0.1:7676/screenshot").content
    with open("test-result.png", "wb") as f:
        f.write(png)

    # Assert the app is still healthy
    assert requests.get("http://127.0.0.1:7676/health").json()["status"] == "ok"
    print("All tests passed")
finally:
    proc.terminate()

Stress Testing with Chaos Mode

Run chaos mode against your app to find crashes, freezes, or unexpected state:

# Build and launch
perry app.ts -o app --enable-geisterhand
./app &

# Wait for startup
sleep 1

# Start aggressive chaos (every 50ms)
curl -X POST http://127.0.0.1:7676/chaos/start \
  -H 'Content-Type: application/json' \
  -d '{"interval_ms":50}'

# Let it run for 30 seconds
sleep 30

# Check stats
curl -sf http://127.0.0.1:7676/chaos/status
# {"running":true,"events_fired":600,"uptime_secs":30}

# Take a screenshot to see final state
curl http://127.0.0.1:7676/screenshot -o chaos-result.png

# Stop chaos
curl -X POST http://127.0.0.1:7676/chaos/stop

# Check the app is still alive
curl -sf http://127.0.0.1:7676/health

Visual Regression Testing

Capture screenshots at key interaction points and compare against baselines:

# Initial state
curl http://127.0.0.1:7676/screenshot -o baseline.png

# Interact
curl -X POST http://127.0.0.1:7676/click/3
curl -X POST http://127.0.0.1:7676/type/4 -d '{"text":"Hello"}'

# Capture after interaction
curl http://127.0.0.1:7676/screenshot -o current.png

# Compare (using ImageMagick)
compare baseline.png current.png diff.png

CI Pipeline Integration

# GitHub Actions example
jobs:
  ui-test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build with geisterhand
        run: perry app.ts -o testapp --enable-geisterhand

      - name: Run UI tests
        run: |
          ./testapp &
          sleep 2
          # Run your test script
          ./tests/ui-test.sh
          kill %1

      - name: Upload screenshots
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: screenshots
          path: "*.png"

Example App

A complete Perry UI app demonstrating every widget type Geisterhand can interact with — verified by CI:

// demonstrates: Geisterhand-targetable Perry UI app — every common widget
// docs: docs/src/testing/geisterhand.md
// platforms: macos, linux, windows

// A complete Perry UI app exercising every widget type Geisterhand
// (the UI fuzzer) can interact with. The doc-tests harness compiles
// and runs this on every PR, so the snippet on the docs page can never
// drift from the real perry/ui API.

import {
    App, VStack, HStack,
    Text, Button, TextField, Slider, Toggle, Picker,
    State, stateOnChange,
    pickerAddItem,
    textSetString,
} from "perry/ui"

// State for reactive UI
const counterState = State(0)
const textState = State("")

// Labels
const title = Text("Geisterhand Demo")
const counterLabel = Text("Count: 0")

// Bind counter state to label via the free-function listener
stateOnChange(counterState, (val: number) => {
    textSetString(counterLabel, `Count: ${val}`)
})

// Button — widget_type = 0
const incrementBtn = Button("Increment", () => {
    counterState.set(counterState.value + 1)
})
const resetBtn = Button("Reset", () => {
    counterState.set(0)
})

// TextField(placeholder, onChange) — widget_type = 1
const nameField = TextField("Enter your name", (text: string) => {
    textState.set(text)
    console.log(`Name: ${text}`)
})

// Slider(min, max, onChange) — widget_type = 2
const volumeSlider = Slider(0, 100, (value: number) => {
    console.log(`Volume: ${value}`)
})

// Toggle(label, onChange) — widget_type = 3
const darkModeToggle = Toggle("Dark Mode", (on: boolean) => {
    console.log(`Dark mode: ${on}`)
})

// Picker(onChange); items added with pickerAddItem.
const sizePicker = Picker((index: number) => {
    console.log(`Size index: ${index}`)
})
pickerAddItem(sizePicker, "Small")
pickerAddItem(sizePicker, "Medium")
pickerAddItem(sizePicker, "Large")

// Layout
const buttonRow = HStack(8, [incrementBtn, resetBtn])
const stack = VStack(12, [
    title, counterLabel, buttonRow,
    nameField, volumeSlider, darkModeToggle, sizePicker,
])

App({
    title: "Geisterhand Demo",
    width: 400,
    height: 480,
    body: stack,
})

After compiling with --enable-geisterhand and running:

# See all interactive widgets
curl -s http://127.0.0.1:7676/widgets | jq .
# [
#   {"handle":3,"widget_type":0,"callback_kind":0,"label":"Increment"},
#   {"handle":4,"widget_type":0,"callback_kind":0,"label":"Reset"},
#   {"handle":5,"widget_type":1,"callback_kind":1,"label":"Enter your name"},
#   {"handle":6,"widget_type":2,"callback_kind":1,"label":""},
#   {"handle":7,"widget_type":3,"callback_kind":1,"label":"Dark Mode"}
# ]

# Click Increment 3 times
for i in 1 2 3; do curl -sX POST http://127.0.0.1:7676/click/3; done
# Counter label now shows "Count: 3"

# Type a name
curl -sX POST http://127.0.0.1:7676/type/5 -d '{"text":"Perry"}'

# Set slider to 80%
curl -sX POST http://127.0.0.1:7676/slide/6 -d '{"value":0.8}'

# Toggle dark mode on
curl -sX POST http://127.0.0.1:7676/toggle/7

# Screenshot
curl -s http://127.0.0.1:7676/screenshot -o demo.png

Architecture

Geisterhand operates as three cooperating components connected by thread-safe queues:

                    ┌──────────────────────────┐
                    │      HTTP Server         │
                    │   (background thread)    │
                    │   tiny-http on :7676     │
                    │                          │
                    │  GET /widgets            │
                    │  POST /click/:h          │
                    │  POST /type/:h           │
                    │  ...                     │
                    └────────┬─────────────────┘
                             │
                    queue actions via
                    Mutex<Vec<PendingAction>>
                             │
                             ▼
┌────────────────────────────────────────────────┐
│                 Main Thread                     │
│                                                 │
│  perry_geisterhand_pump() ← called every 8ms   │
│  by platform timer (NSTimer / glib / WM_TIMER)  │
│                                                 │
│  Drains PendingAction queue:                    │
│  • InvokeCallback → js_closure_call0/1          │
│  • SetState → perry_ui_state_set                │
│  • CaptureScreenshot → perry_ui_screenshot_*    │
└────────────────────────────────────────────────┘
                             │
                    widget callbacks registered
                    at creation time via
                    perry_geisterhand_register()
                             │
                             ▼
┌────────────────────────────────────────────────┐
│            Global Widget Registry              │
│         Mutex<Vec<RegisteredWidget>>           │
│                                                │
│  { handle, widget_type, callback_kind,         │
│    closure_f64, label }                        │
└────────────────────────────────────────────────┘

Lifecycle

  1. Startup: When --enable-geisterhand is used, the compiled binary calls perry_geisterhand_start(port) during initialization. This spawns a background thread running a tiny-http server.

  2. Widget Registration: As UI widgets are created (Button, TextField, Slider, etc.), each one calls perry_geisterhand_register(handle, widget_type, callback_kind, closure_f64, label) to register its callback in the global registry. This is gated behind #[cfg(feature = "geisterhand")] so normal builds have zero overhead.

  3. HTTP Requests: When a request arrives (e.g., POST /click/3), the server looks up handle 3 in the registry, finds the associated closure, and pushes a PendingAction::InvokeCallback onto the pending actions queue.

  4. Main-Thread Dispatch: The platform’s timer (NSTimer on macOS, glib timeout on GTK4, WM_TIMER on Windows, etc.) calls perry_geisterhand_pump() every ~8ms. This drains the pending actions queue and executes callbacks on the main thread, which is required for UI safety.

  5. Screenshot Capture: Screenshots use Condvar synchronization — the HTTP thread queues a CaptureScreenshot action, then blocks waiting on a condition variable. The main thread’s pump executes the platform-specific capture, stores the PNG data, and signals the condvar. Timeout: 5 seconds.

Thread Safety

  • Widget Registry: Protected by Mutex. Read by the HTTP server (to list widgets and look up handles), written by the main thread (during widget creation).
  • Pending Actions Queue: Protected by Mutex. Written by HTTP server thread, drained by main thread in pump().
  • Screenshot Result: Protected by Mutex + Condvar. HTTP thread waits, main thread signals.
  • Chaos Mode State: Uses AtomicBool (running flag) and AtomicU64 (event counter) for lock-free status checks.

NaN-Boxing Bridge

When geisterhand needs to pass values to widget callbacks, it must create properly NaN-boxed values:

  • Strings (for TextField): Calls js_string_from_bytes(ptr, len) to allocate a runtime string, then js_nanbox_string(ptr) to wrap it with STRING_TAG (0x7FFF).
  • Numbers (for Slider): Passes the raw f64 value directly (numbers are their own NaN-boxed representation).
  • Booleans (for Toggle/chaos): Uses TAG_TRUE (0x7FFC000000000004) or TAG_FALSE (0x7FFC000000000003).

Build Details

Auto-Build

When you pass --enable-geisterhand (or --geisterhand-port), Perry automatically builds the required libraries on first use if they’re not already cached:

cargo build --release \
  -p perry-runtime --features perry-runtime/geisterhand \
  -p perry-ui-{platform} --features perry-ui-{platform}/geisterhand \
  -p perry-ui-geisterhand

Platform crate selection is automatic based on --target:

TargetUI Crate
(default/macOS)perry-ui-macos
ios / ios-simulatorperry-ui-ios
androidperry-ui-android
linuxperry-ui-gtk4
windowsperry-ui-windows

Separate Target Directory

Geisterhand libraries are built into target/geisterhand/ (via CARGO_TARGET_DIR) to avoid interfering with normal builds. This means your first geisterhand build takes a moment, but subsequent builds reuse the cached libraries.

Feature Flags

All geisterhand code is behind #[cfg(feature = "geisterhand")] feature gates:

  • perry-runtime/geisterhand: Compiles the geisterhand_registry module — widget registry, action queue, pump function, screenshot coordination.
  • perry-ui-{platform}/geisterhand: Adds perry_geisterhand_register() calls to widget constructors and perry_geisterhand_pump() to the platform timer.

When the feature is not enabled, no geisterhand code is compiled — zero binary size overhead and zero runtime cost.

Linking

The compiled binary links three additional static libraries:

  1. libperry_runtime.a (geisterhand-featured build, replaces the normal runtime)
  2. libperry_ui_{platform}.a (geisterhand-featured build, replaces the normal UI lib)
  3. libperry_ui_geisterhand.a (HTTP server + chaos mode)

Manual Build

If auto-build fails or you want to cross-compile manually:

# Build geisterhand libs for macOS
CARGO_TARGET_DIR=target/geisterhand cargo build --release \
  -p perry-runtime --features perry-runtime/geisterhand \
  -p perry-ui-macos --features perry-ui-macos/geisterhand \
  -p perry-ui-geisterhand

# Build for iOS (cross-compile)
CARGO_TARGET_DIR=target/geisterhand cargo build --release \
  --target aarch64-apple-ios \
  -p perry-runtime --features perry-runtime/geisterhand \
  -p perry-ui-ios --features perry-ui-ios/geisterhand \
  -p perry-ui-geisterhand

Security

Geisterhand binds to 0.0.0.0 on the configured port (default 7676). This means it is accessible from the local network — any device on the same network can interact with your app, capture screenshots, or trigger chaos mode.

Do not ship geisterhand-enabled binaries to production or to end users.

Geisterhand is a development and testing tool only. The feature-gate system ensures it cannot accidentally be included in normal builds — you must explicitly pass --enable-geisterhand or --geisterhand-port.


Troubleshooting

“Connection refused” on port 7676

  • Ensure you compiled with --enable-geisterhand or --geisterhand-port
  • Check that the app has fully started (look for [geisterhand] listening on... in stderr)
  • Verify the port isn’t in use by another process: lsof -i :7676

Widget handles not found

  • Handles are assigned at widget creation time. If you query /widgets before the UI is fully constructed, some widgets may not be registered yet.
  • Wait for GET /health to return {"status":"ok"} before interacting.

Screenshot returns empty data

  • Screenshot capture has a 5-second timeout. If the main thread is blocked (e.g., by a long-running synchronous operation), the screenshot will time out and return empty data.
  • On macOS, ensure the app has a visible window (minimized windows may not capture correctly).

Auto-build fails

  • Ensure you have a working Rust toolchain (rustup show)
  • For cross-compilation targets, install the appropriate target: rustup target add aarch64-apple-ios
  • Check that the Perry source tree is accessible (auto-build searches upward from the perry executable for the workspace root)

Chaos mode crashes the app

That’s the point — chaos mode found a bug. Check the app’s stderr output for panic messages or stack traces. Common causes:

  • Callback handlers that assume valid state but receive unexpected values
  • Missing null checks on state values
  • Race conditions in state updates

CLI Commands

Perry provides 11 commands for compiling, checking, running, publishing, and managing your projects.

See also: perry.toml Reference for project configuration.

compile

Compile TypeScript to a native executable.

perry compile main.ts -o app
# Or shorthand (auto-detects compile):
perry main.ts -o app
FlagDescription
-o, --output <PATH>Output file path
--target <TARGET>Platform target (see Compiler Flags)
--output-type <TYPE>executable (default) or dylib (plugin)
--print-hirPrint HIR intermediate representation
--no-linkProduce object file only, skip linking
--keep-intermediatesKeep .o and .asm files
--enable-js-runtimeEnable V8 JavaScript runtime fallback
--enable-wasm-runtimeForce-link the wasmi WebAssembly host runtime (auto-detected on WebAssembly.* use)
--type-checkEnable type checking via tsgo
--minifyMinify and obfuscate output (auto-enabled for --target web)
--app-bundle-id <ID>Bundle ID (required for widget targets)
--bundle-extensions <DIR>Bundle TypeScript extensions from directory
# Basic compilation
perry compile app.ts -o app

# Cross-compile for iOS Simulator
perry compile app.ts -o app --target ios-simulator

# Build a plugin
perry compile plugin.ts --output-type dylib -o plugin.dylib

# Debug: view intermediate representation
perry compile app.ts --print-hir

# Build an iOS widget
perry compile widget.ts --target ios-widget --app-bundle-id com.myapp.widget

run

Compile and launch your app in one step.

perry run                          # Auto-detect entry file
perry run ios                      # Run on iOS device/simulator
perry run visionos                 # Run on Apple Vision Pro simulator/device
perry run android                  # Run on Android device
perry run -- --port 3000           # Forward args to your program
Argument / FlagDescription
iosTarget iOS (device or simulator)
visionosTarget visionOS (device or simulator)
macosTarget macOS (default on macOS host)
webTarget web (opens in browser)
androidTarget Android device
--simulator <UDID>Specify iOS simulator by UDID
--device <UDID>Specify iOS physical device by UDID
--localForce local compilation (no remote fallback)
--remoteForce remote build via Perry Hub
--enable-js-runtimeEnable V8 JavaScript runtime
--enable-wasm-runtimeForce-link the wasmi WebAssembly host runtime
--type-checkEnable type checking via tsgo
--Separator for program arguments

Entry file detection (checked in order):

  1. perry.toml[project] entry field
  2. src/main.ts
  3. main.ts

Device detection: When targeting iOS, Perry auto-discovers available simulators (via simctl) and physical devices (via devicectl). For Android, it uses adb. When multiple targets are found, an interactive prompt lets you choose.

Remote build fallback: If cross-compilation toolchains aren’t installed locally (e.g., Apple mobile targets on a machine without Xcode), perry run ios and perry run visionos can fall back to Perry Hub’s build server when the backend supports the target. Use --local or --remote to force either path.

# Run a CLI program
perry run

# Run on a specific simulator
perry run ios --simulator 12345-ABCDE

# Force remote build
perry run ios --remote

# Run web target
perry run web

dev

Watch your TypeScript source tree and auto-recompile + relaunch on every save.

perry dev src/main.ts                        # watch + rebuild + relaunch on save
perry dev src/server.ts -- --port 3000       # forward args to the child
perry dev src/app.ts --watch shared/         # watch an extra directory
perry dev src/app.ts -o build/dev-app        # override output path
FlagDescription
-o, --output <PATH>Output binary path (default: .perry-dev/<entry-stem>)
--watch <DIR>Extra directories to watch (comma-separated or repeated)
--Separator — everything after is forwarded to the compiled binary

How it works:

  1. Resolves the entry, computes the project root (walks up until it finds a package.json or perry.toml; falls back to the entry’s parent directory).
  2. Does an initial perry compile, then spawns the resulting binary with stdio inherited.
  3. Watches the project root (plus any --watch dirs) recursively using the notify crate. A 300 ms debounce window collapses editor “save storms” into one rebuild.
  4. On each relevant change: kill the running child, recompile, relaunch. A failed build leaves the old child dead and waits for the next change; no crash loop.

What counts as a “relevant” change:

  • Trigger extensions: .ts, .tsx, .mts, .cts, .json, .toml
  • Ignored directories (not watched, never retrigger): node_modules, target, .git, dist, build, .perry-dev, .perry-cache

Benchmarks (trivial single-file program, macOS):

PhaseTime
Initial build (cold — runtime + stdlib rebuilt by auto-optimize)~15 s
Post-edit rebuild (hot libs cached on disk)~330 ms

The speedup on hot rebuilds comes from Perry’s existing auto-optimize library cache. Multi-module projects will still recompile every changed module on each save — see the V2 note below for planned incremental work.

Not yet in scope (V2+):

  • In-memory AST cache (reuse SWC parses across rebuilds).
  • Per-module .o cache on disk (only re-codegen the changed module).
  • State preservation across rebuilds / HMR — “fast restart” is the honest target.

check

Validate TypeScript for Perry compatibility without compiling.

perry check src/
FlagDescription
--check-depsCheck node_modules for compatibility
--deep-depsScan all transitive dependencies
--allShow all issues including hints
--strictTreat warnings as errors
--fixAutomatically apply fixes
--fix-dry-runPreview fixes without modifying files
--fix-unsafeInclude medium-confidence fixes
# Check a single file
perry check src/index.ts

# Check with dependency analysis
perry check . --check-deps

# Auto-fix issues
perry check . --fix

# Preview fixes without applying
perry check . --fix-dry-run

init

Create a new Perry project.

perry init my-project
cd my-project
FlagDescription
--name <NAME>Project name (defaults to directory name)

Creates perry.toml, src/main.ts, and .gitignore.

doctor

Check your Perry installation and environment.

perry doctor
FlagDescription
--quietOnly report failures

Checks:

  • Perry version
  • System linker availability (cc/MSVC)
  • Runtime library
  • Project configuration
  • Available updates

explain

Get detailed explanations for error codes.

perry explain U001

Error code families:

  • P — Parse errors
  • T — Type errors
  • U — Unsupported features
  • D — Dependency issues

Each explanation includes the error description, example code, and suggested fix.

publish

Build, sign, and distribute your app.

perry publish macos
perry publish ios
perry publish visionos
perry publish android
Argument / FlagDescription
macosBuild for macOS (App Store/notarization)
iosBuild for iOS (App Store/TestFlight)
visionosBuild for visionOS
androidBuild for Android (Google Play)
linuxBuild for Linux (AppImage/deb/rpm)
--server <URL>Build server (default: https://hub.perryts.com)
--license-key <KEY>Perry Hub license key
--project <PATH>Project directory
-o, --output <PATH>Artifact output directory (default: dist)
--no-downloadSkip artifact download

Apple-specific flags:

FlagDescription
--apple-team-id <ID>Developer Team ID
--apple-identity <NAME>Signing identity
--apple-p8-key <PATH>App Store Connect .p8 key
--apple-key-id <ID>App Store Connect API Key ID
--apple-issuer-id <ID>App Store Connect Issuer ID
--certificate <PATH>.p12 certificate bundle
--provisioning-profile <PATH>.mobileprovision file (iOS)

Android-specific flags:

FlagDescription
--android-keystore <PATH>.jks/.keystore file
--android-keystore-password <PASS>Keystore password
--android-key-alias <ALIAS>Key alias
--android-key-password <PASS>Key password
--google-play-key <PATH>Google Play service account JSON

On first use, publish auto-registers a free license key.

setup

Interactive credential wizard for app distribution, plus toolchain setup for Windows.

perry setup          # Show platform menu
perry setup macos    # macOS setup (signing credentials)
perry setup ios      # iOS setup (signing credentials)
perry setup visionos # visionOS setup (signing credentials)
perry setup android  # Android setup (signing credentials)
perry setup windows  # Windows toolchain (downloads MS CRT + Windows SDK via xwin)

perry setup windows downloads the Microsoft CRT + Windows SDK libraries (~1.5 GB) so Perry can link without Visual Studio Build Tools. Requires LLVM (winget install LLVM.LLVM) and prompts to accept the Microsoft redistributable license — pass --accept-license to skip the prompt for CI. Output lands at %LOCALAPPDATA%\perry\windows-sdk. See the Windows platform guide for the full toolchain comparison.

Credential wizards store their output in ~/.perry/config.toml.

update

Check for and install Perry updates.

perry update             # Update to latest
perry update --check-only  # Check without installing
perry update --force       # Ignore 24h cache

Update sources (checked in order):

  1. Custom server (env/config)
  2. Perry Hub
  3. GitHub API

Opt out of automatic update checks with PERRY_NO_UPDATE_CHECK=1 or CI=true.

i18n

Internationalization tools for managing locale files and extracting localizable strings.

perry i18n extract

Scan source files and generate/update locale JSON scaffolds:

perry i18n extract src/main.ts

Detects string literals in UI component calls (Button, Text, Label, etc.) and t() calls. Creates locales/*.json files based on the [i18n] config in perry.toml.

See the i18n documentation for full details.

native

Tooling for native-bindings packages — Rust crates exporting extern "C" symbols that Perry’s compiler links into your TypeScript program. See Native Bindings — Overview for the architecture this fits into.

perry native init <name>

Scaffold a new native-bindings package:

perry native init my-bindings \
  --description "Native bindings for libfoo" \
  --upstream-dep 'libfoo = "1.0"' \
  --github-owner my-handle

Creates a directory with:

  • package.json (perry.nativeLibrary block: abiVersion + functions[] + per-target build config)
  • Cargo.toml (depends on perry-ffi via git URL until v0.6.0 publishes it to crates.io)
  • src/lib.rs (one example #[no_mangle] pub extern "C" fn js_<name>_hello)
  • src/index.ts (TypeScript surface user code imports)
  • README.md, LICENSE, .gitignore
  • .github/workflows/release.yml — multi-target prebuild matrix (x86_64 + aarch64 macOS / Linux + Windows) on tag, attaches staticlibs to the GitHub release

Pass --force to overwrite an existing directory.

See the Authoring Guide for the full walkthrough.

perry native validate

Run from a wrapper’s root:

cd my-bindings
perry native validate

Parses package.json, runs cargo build --release, locates the resulting .a / .lib / .dylib, walks nm -gP over its symbols, and diffs against the manifest’s functions[].name array. Reports:

  • declared functions with no matching symbol — broken bindings (typo in name field, missing #[no_mangle], etc.); exits 1.
  • js_* symbols not in the manifest — unreachable from user code (forgot to declare them, or named something internal js_* accidentally).

Pass --no-build to skip the cargo build step when you’re iterating on the manifest only.

perry native list

Enumerates the well-known bindings shipped with this Perry build:

perry native list

Output:

30 bindings ship with this Perry build:

  argon2                        → perry-ext-argon2                  (#466)
  axios                         → perry-ext-axios                   (#466)
  bcrypt                        → perry-ext-bcrypt                  (#466)
  better-sqlite3                → perry-ext-better-sqlite3          (#466)
  …

Pass --format json for machine-readable output. Resolution order printed at the bottom — bindings discovered via node_modules/<pkg>/package.json perry.nativeLibrary always win over the well-known set.

Next Steps

Compiler Flags

Complete reference for all Perry CLI flags.

Global Flags

Available on all commands:

FlagDescription
--format text|jsonOutput format (default: text)
-v, --verboseIncrease verbosity (repeatable: -v, -vv, -vvv)
-q, --quietSuppress non-error output
--no-colorDisable ANSI color codes

Compilation Targets

Use --target to cross-compile:

TargetPlatformNotes
(none)Current platformDefault behavior
ios-simulatoriOS SimulatorARM64 simulator binary
iosiOS DeviceARM64 device binary
visionos-simulatorvisionOS SimulatorApple Vision Pro simulator build
visionosvisionOS DeviceApple Vision Pro device build
androidAndroidARM64/ARMv7
ios-widgetiOS WidgetWidgetKit extension (requires --app-bundle-id)
ios-widget-simulatoriOS Widget (Sim)Widget for simulator
watchos-widgetwatchOS ComplicationWidgetKit extension for Apple Watch
watchos-widget-simulatorwatchOS Widget (Sim)Widget for watchOS simulator
android-widgetAndroid WidgetAndroid App Widget (AppWidgetProvider)
wearos-tileWear OS TileWear OS Tile (TileService)
wasmWebAssemblySelf-contained HTML with WASM or raw .wasm binary
webWebOutputs HTML file with JS
windowsWindowsWin32/GDI executable (default Windows backend)
windows-winuiWindows (Fluent)Opt-in WinUI 3 / Fluent backend (#4680). Scaffold: currently renders via Win32 while the XAML widget mapping lands incrementally; selects the perry-ui-windows-winui static library. Build that lib first: cargo build --release -p perry-ui-windows-winui.
linuxLinuxGTK4 executable

Output Types

Use --output-type to change what’s produced:

TypeDescription
executableStandalone binary (default)
dylibShared library (.dylib/.so) for plugins

Debug Flags

FlagDescription
--print-hirPrint HIR (intermediate representation) to stdout
--trace <STAGES>Dump IR at one or more pipeline stages. Comma-separated: hir (post-transform HIR), llvm (per-module .ll into .perry-trace/llvm/), or all
--focus <NAME>Restrict --trace hir to functions/methods/classes whose name contains NAME, suppressing import/export/init noise. Implies --trace hir if no stage is given
--no-linkProduce .o object file only, skip linking
--no-codegenSkip the package.json perry.codegen build-time steps (also PERRY_SKIP_CODEGEN=1). See Project Configuration
--keep-intermediatesKeep .o and .asm intermediate files

The --trace/--focus pair localizes “compiled to the wrong thing” bugs: perry compile foo.ts --trace hir,llvm --focus parseRow dumps just the parseRow function’s lowered HIR and the module’s LLVM IR, so you can see which stage corrupted it without scrolling a full-module dump. --trace llvm forces a full recompile (the object cache otherwise skips codegen for unchanged modules, leaving the trace dir empty).

Output Optimization

FlagDescription
--minifyMinify and obfuscate output (auto-enabled for --target web)

Minification strips comments, collapses whitespace, and mangles local variable/parameter/non-exported function names for smaller output.

Testing Flags

FlagDescription
--enable-geisterhandEmbed the Geisterhand HTTP server for programmatic UI testing (default port 7676)
--geisterhand-port <PORT>Set a custom port for the Geisterhand server (implies --enable-geisterhand)

Runtime Flags

FlagDescription
--enable-js-runtimeEnable V8 JavaScript runtime for unsupported npm packages
--enable-wasm-runtimeForce-link the wasmi WebAssembly host runtime (auto-detected when WebAssembly.* is referenced; needed only when loading via dlopen / FFI without a static reference)
--type-checkEnable type checking via tsgo IPC

Environment Variables

VariableDescription
PERRY_LICENSE_KEYPerry Hub license key for perry publish
PERRY_APPLE_CERTIFICATE_PASSWORDPassword for .p12 certificate
PERRY_NO_UPDATE_CHECK=1Disable automatic update checks
PERRY_UPDATE_SERVERCustom update server URL
CI=trueAuto-skip update checks (set by most CI systems)
RUST_LOGDebug logging level (debug, info, trace)

Configuration Files

perry.toml (project)

[project]
name = "my-app"
entry = "src/main.ts"
version = "1.0.0"

[build]
out_dir = "build"

[app]
name = "My App"
description = "A Perry application"

[macos]
bundle_id = "com.example.myapp"
category = "public.app-category.developer-tools"
minimum_os = "13.0"
distribute = "notarize"  # "appstore", "notarize", or "both"

[ios]
bundle_id = "com.example.myapp"
deployment_target = "16.0"
device_family = ["iphone", "ipad"]

[android]
package_name = "com.example.myapp"
min_sdk = 26
target_sdk = 34

[linux]
format = "appimage"  # "appimage", "deb", "rpm"
category = "Development"

~/.perry/config.toml (global)

[apple]
team_id = "XXXXXXXXXX"
signing_identity = "Developer ID Application: Your Name"

[android]
keystore_path = "/path/to/keystore.jks"
key_alias = "my-key"

Examples

# Simple CLI program
perry main.ts -o app

# iOS app for simulator
perry app.ts -o app --target ios-simulator

# visionOS app for simulator
perry app.ts -o app --target visionos-simulator

# Web app (WASM with DOM bridge — alias: --target wasm)
perry app.ts -o app --target web

# Plugin shared library
perry plugin.ts --output-type dylib -o plugin.dylib

# iOS widget with bundle ID
perry widget.ts --target ios-widget --app-bundle-id com.example.app

# Debug compilation
perry app.ts --print-hir 2>&1 | less

# Verbose compilation
perry compile app.ts -o app -vvv

# Type-checked compilation
perry app.ts -o app --type-check

# Raw WASM binary (no HTML wrapper)
perry app.ts -o app.wasm --target wasm

# Minified web output (compresses embedded JS bridge)
perry app.ts -o app --target web --minify

Next Steps

Fast-math and FP contraction

Off by default. Opt in to permit LLVM optimizations on f64 arithmetic that produce observably different results from Node’s V8 in exchange for faster code on a narrow class of numeric workloads.

TL;DR

ModeBit-exact with NodeSpeed
DefaultYes (~94% of random FP programs match Node bit-for-bit; the residual ~6% comes from the LLVM SLP vectorizer at -O3, not from fast-math)Same as Node within noise on realistic FP code
--fp-contract=on or fastNo where FMA fusion changes roundingCan emit FMA for multiply-add shapes without enabling reassociation
--fast-mathNo (~70%; ~30% of random FP programs diverge by 1 ULP). Implies --fp-contract=fast unless explicitly overridden.~7x faster on tight sum += constant loops; ~0% difference on dot products, array reductions, or any data-dependent FP-heavy code (M-series ARM64 numbers; x86_64 may differ)

If your program does scientific computing, signal processing, or any hand-tuned numeric kernel that benefits from autovectorization or FMA fusion, --fast-math may help. For everything else (UI, business logic, crypto, networking, framework code), it changes nothing observable except correctness — leave it off.

Three ways to enable it

CLI flag wins over env var, env var wins over package.json:

# 1. Per-build CLI flag
perry --fast-math myapp.ts

# 2. Per-shell environment
PERRY_FAST_MATH=1 perry myapp.ts

# 3. Per-project package.json (most common)
{
  "perry": {
    "fastMath": true
  }
}

Floating-point contraction

Contraction is separate from reassociation:

# Permit FMA contraction only.
perry --fp-contract=on myapp.ts

# Permit the frontend's most aggressive contraction mode without reassociation.
perry --fp-contract=fast myapp.ts

# Keep reassociation from --fast-math but block FMA contraction.
perry --fast-math --fp-contract=off myapp.ts

The same setting is available through PERRY_FP_CONTRACT=off|on|fast or "perry": { "fpContract": "on" } in package.json. Explicit package, env, or CLI fpContract values override the --fast-math implied default.

What it actually changes

Two LLVM per-instruction fast-math flags can be emitted on every fadd / fsub / fmul / fdiv / frem / fneg:

  • reassoc — permits the optimizer to reorder associative chains. (a + b) + c may become a + (b + c). This is what the loop-vectorizer needs to break a serial accumulator dependency chain into 4 parallel accumulators. Worst-case observable behavior: tiny ULP-level differences in long sum chains over operands of widely-different magnitudes; rewrites like (a / b) * b → (a * b) / b (algebraically equal, IEEE-different).

  • contract — controlled by --fp-contract; permits fused multiply-add. a * b + c may become a single FMA instruction with one rounding step instead of two. ARM and modern x86 both have hardware FMA. Worst-case observable behavior: intermediate a * b no longer rounds independently, so code that depends on the rounding structure (Kahan summation, compensated arithmetic) sees different bits.

What it deliberately does NOT enable

The full clang -ffast-math is off even with --fast-math. In particular, these flags stay clear:

  • nnan / ninf — these tell LLVM to assume no NaN/Inf inputs, which is catastrophic for Perry: NaN-boxing uses NaN bit patterns for every non-number value (strings, objects, null, undefined, booleans). Enabling them caused LLVM to replace TAG_NULL / TAG_UNDEFINED constants with 0.0 at codegen time. Tried at v0.2.x commit 083ce16, reverted two days later in b5a8c83f. Will not return.
  • nsz (no signed zeros) — would make (a + 0) → a a valid rewrite even when a is -0. Object.is(-0, 0) is observable in JS.
  • arcp (allow reciprocal) — would rewrite a / b → a * (1 / b), which loses precision when b is far from a power of two.
  • afn (approximate functions) — would let LLVM substitute lower- precision math intrinsics.

For reference, Rust nightly’s #![feature(float_algebraic)] enables reassoc + contract + nsz + arcp + afn. Perry’s --fast-math is strictly more conservative than that.

Performance numbers

Benchmarks on Apple Silicon (M-series, ARM64), min of 3 runs each, LLVM 19, perry 0.5.569. Run scripts/perf_bench.sh to reproduce.

BenchmarkDefault--fast-mathRatioNode
sum_loop (100M sum += 1)96 ms13 ms7.4× faster53 ms
dot_product (10M sum += a[i]*b[i])13 ms13 ms1.00×12 ms
array_sum (10M sum += xs[i])10 ms10 ms1.00×11 ms

Read these together: --fast-math produces a large speedup ONLY on loops where the accumulator step is constant or trivially-redundant enough that LLVM can split it into parallel partial sums. Real FP workloads rarely look like sum += 1 and so rarely benefit. The default mode beats Node on array_sum and matches it on dot_product without giving up bit-exact parity.

Correctness numbers

scripts/fp_fuzz.mjs — randomly generates TS programs exercising the six patterns most likely to trip per-instruction FMFs (left-fold, tree-fold, right-fold reductions; FMA-shaped chains; algebraic identities like (a/b)*b; cancellation predicates). Each program is compiled with both Node and Perry, and stdout is diffed byte-for-byte.

ModePass rate (100 random programs, seed=200)
Default94/100
--fast-math~70/100

The 6/100 default-mode failures are residual divergences from sources not gated by per-instruction FMFs — most originate in the LLVM SLP vectorizer at -O3, which can apply pairwise reduction even without the reassoc permission. Tracked separately; out of scope for this flag.

Object-cache interaction

Perry’s per-module .o cache (in .perry-cache/objects/) keys on the fast_math and fp_contract settings alongside source hash and other compile options. Toggling either invalidates affected cache entries — perry --fast-math or perry --fp-contract=on right after perry does a clean recompile of every module that contains f64 arithmetic. No --no-cache necessary.

(This is a deliberate fix. During the original investigation, an early version of the flag forgot to enter the cache key, and the result was that toggling the flag appeared to do nothing because all .o files came from the cache. If you ever see fast-math defaults that seem not to take effect, suspect the cache key first.)

Migration notes

  • For library authors: if your TS library publishes benchmark numbers, document which mode you measured under. The 7× sum-loop case is the only place the gap is large; if your benchmark doesn’t look like that, the numbers are mode-independent and you can publish one set.
  • For app authors: there is no migration. Default behavior is the pre-flag behavior with --fast-math removed; bit-exact results are more compatible with Node, not less.
  • For determinism-critical code (lockstep simulations, financial reconciliation, hash function correctness): leave the default. Even with --fast-math off there’s a residual ~6% divergence rate on random FP code, which is too high for true determinism work — but it’s an order of magnitude better than the ~30% with the flag on.

Dynamic Stdlib Dispatch (@perry-allow-dynamic)

Perry refuses compile-time dynamic dispatch on Node-core stdlib namespaces. A call site like

const m = "exit";
(process as any)[m](0);

fails to compile. The check exists to catch the standard string-based obfuscation pattern used by malicious npm packages: process["bind" + "ing"]("dns"), globalThis[atob("ZXZhbA==")](), fs[methodName]() where methodName is computed at runtime.

The pass is purely compile-time — zero runtime cost — and is on by default. Issue #503 tracks the design.

What’s checked

Dynamic dispatch is refused when all of the following hold:

  1. The receiver resolves to a known Node-core stdlib namespace: process, fs, crypto, child_process, net, os, path, http, https, http2, stream, url, util, events, dns, tls, querystring, zlib, async_hooks, readline, string_decoder, tty, worker_threads.
  2. The index expression is not a string literal — fs["readFileSync"] is treated identically to fs.readFileSync and always passes.
  3. The user has not opted out (see below).

User-code reflection on user-defined objects is unaffected:

const me = { greet: (n: string) => "hi " + n };
const k = "greet";
me[k]("world"); // ✓ user object, not a stdlib namespace

Opt-outs

The error message lists the available opt-outs in priority order:

1. Replace with a static call

The preferred fix. The check exists precisely because static calls are auditable.

process.exit(0);                // ✓
fs.readFileSync("/tmp/x");       // ✓

2. // @perry-allow-dynamic annotation (host code only)

For legitimate one-off dispatch in your own code, drop a line comment on or immediately above the offending site:

const k = pickHandler();
// @perry-allow-dynamic
(process as any)[k](0);

Contiguous comment lines above the call also count, so the annotation can sit alongside an // @ts-ignore or similar.

The annotation is honored only in host source files (anything not under node_modules/). A dependency cannot grant itself the opt-out by writing // @perry-allow-dynamic next to its own call — that would defeat the supply-chain defense the check exists for. Dependencies opt in via the host’s per-package allow list (below) or the global flag. Tracked in #996.

3. Per-package allow list in package.json

To opt one or more npm dependencies out, list them under perry.allowDynamicStdlibDispatch in the host application’s package.json:

{
  "perry": {
    "allowDynamicStdlibDispatch": ["legacy-dep", "@scope/other-dep"]
  }
}

Modules whose source path lives under node_modules/<pkg>/… are matched against this list. Host code is not covered — opting host code out requires the global flag below or the site annotation.

4. Global opt-out

To disable the check across the entire build, set the boolean form:

{ "perry": { "allowDynamicStdlibDispatch": true } }

…or set the env var for a one-off build:

PERRY_ALLOW_DYNAMIC_STDLIB=1 perry build src/main.ts

CI can enforce the check by setting PERRY_ALLOW_DYNAMIC_STDLIB=0, which beats any package.json opt-out.

Why on by default

The check is the cheapest possible defense against the dispatch-by-string class of supply-chain evasion. The cost to legitimate code is essentially zero — static calls and literal-keyed access compile unchanged. Code that genuinely needs the indirection has four ways to say so explicitly, and the failure mode is a build error rather than a silent miss in detection.

See #503 for design discussion and the broader supply-chain hardening series ([#495#506] (https://github.com/PerryTS/perry/issues?q=is%3Aissue+label%3Aenhancement+security)).

JS Runtime Opt-In (perry.allowJsRuntime)

Perry refuses to link perry-jsruntime — the QuickJS-based runtime that executes interpreted .js files from node_modules/ — unless the host application has explicitly opted in. This protects Perry’s primary structural advantage over Node: a Perry binary normally contains no JS evaluator at all.

The check fires at compile time. Zero runtime cost.

How a build hits this

The Perry compiler routes any .js/.cjs/.mjs file from node_modules/ through perry-jsruntime’s QuickJS sandbox instead of the native LLVM backend. Most npm packages are pure-JS, so transitive deps can pull the runtime in without the host author noticing — a silent regression of Perry’s main hardening pitch.

When that happens without an opt-in, the build fails with:

Error: build pulled in `perry-jsruntime` (QuickJS-based eval-equivalent
runtime) via the following file(s):
  - /path/to/node_modules/evilpkg/index.js [evilpkg]

`perry-jsruntime` is treated as a privileged dependency on par with
adding a JIT to the binary — it re-introduces arbitrary runtime code
execution and defeats Perry's structural advantage over Node. Refusing
to link by default. (#499)

The diagnostic lists every file that triggered the pull-in, capped at the first eight, with the owning npm package (when the path resolves through node_modules/<pkg>/).

Opt-in mechanisms

Three equivalent ways, listed in priority order:

1. perry.allowJsRuntime in package.json (persistent)

{
  "perry": {
    "allowJsRuntime": true
  }
}

Recommended for production builds where you’ve reviewed the JS deps and decided to ship them. The setting lives in source control next to the dependency list it affects.

2. --enable-js-runtime CLI flag (per-invocation)

perry build src/main.ts --enable-js-runtime

Treated as an explicit per-build opt-in. Useful for local development or one-off builds against a host that intentionally doesn’t set allowJsRuntime: true.

3. PERRY_ALLOW_JS_RUNTIME=1 env var (CI-friendly)

PERRY_ALLOW_JS_RUNTIME=1 perry build src/main.ts

=1/true opts in; =0/false keeps the refusal on even if package.json opted in — useful as a CI gate that fails closed when someone tries to merge an opt-in by accident.

Lockdown mode

This refusal will be part of the deny-set for the upcoming --lockdown compile flag (issue #496). In lockdown mode, no opt-in is honored — the build always refuses perry-jsruntime linkage.

See also

  • #499 — design discussion.
  • The wider supply-chain hardening series (#495#506).

PERRY_SANDBOX_BUILDRS

Wraps cargo build invocations triggered by perry.nativeLibrary resolution in macOS sandbox-exec, so build.rs scripts shipped by third-party crates can’t reach the network or write outside the build output directory. Build-time only — zero runtime cost in the produced binary. (#505)

Why

perry.nativeLibrary resolution kicks off cargo build for any source-distributed crate. A crate’s build.rs runs with full developer privileges, so a typical bun add @vendor/native-thing silently grants the new dependency the ability to exfiltrate environment variables, read SSH keys, or modify files outside the build tree. The flag flips that to opt-out via an explicit allow-list rather than opt-in via review.

Opt-in

Off by default for backwards compatibility. Enable per build via env var:

PERRY_SANDBOX_BUILDRS=1 perry compile main.ts -o myapp

CI typically sets the env var on every job; local development keeps the legacy flow until ready.

Profile contents

The generated sandbox-exec profile:

  • deny default + deny network*build.rs cannot phone home.
  • allow file-read* everywhere (cargo / rustc need to read system libraries, source, dependency crates).
  • allow file-write* scoped to target/, ~/.cargo, ~/.rustup, /tmp, and the per-build TempDir.
  • allow process-fork + process-exec so rustc, cc, ld, and the build.rs binaries themselves can run.
  • allow sysctl-read / mach-lookup / iokit-open for the platform queries cargo and rustc routinely issue.

Pre-fetch workflow

The sandbox denies network, so cargo cannot reach crates.io from inside it. Pre-fetch once outside the sandbox before the sandboxed build:

cargo fetch --manifest-path node_modules/@foo/native-bar/Cargo.toml
PERRY_SANDBOX_BUILDRS=1 perry compile main.ts -o myapp

CI runners typically cache ~/.cargo across jobs, so the pre-fetch is free on subsequent builds.

Per-package escape hatch

Some legitimate crates need network during build.rs (e.g. fetching prebuilt artifacts from a CDN). Opt them out per-package in the host package.json:

{
  "perry": {
    "allowUnsandboxedBuild": ["@some-vendor/builds-with-network"]
  }
}

Host-controlled — transitive deps cannot opt themselves out. The exemption lives in the host repository’s package.json and shows up in code review.

Cross-platform scope

MVP is macOS-only (the sandbox-exec profile). Linux landlock support is tracked separately; until that lands, PERRY_SANDBOX_BUILDRS=1 on Linux is a no-op (the build runs normally). Windows: out of scope.

See also

  • #505 — design discussion.
  • #504 — companion binary attestation.
  • #506 — companion runtime sandbox profile.

--emit-attest (binary attestation sidecar)

perry compile --emit-attest main.ts -o myapp writes myapp.attest.json next to the executable. The sidecar holds the SHA-256 of the post-strip / post-codesign binary plus provenance metadata (perry version, git commit, build timestamp) so downstream consumers can verify that the artifact they downloaded matches the one the publisher built. (#504)

Why

Publishing a Perry binary to a CDN, a release page, or an internal artifact registry creates a window between “publisher built it” and “user runs it.” --emit-attest produces a JSON sidecar that anyone can recompute on the downloaded artifact and compare. A tampered or swapped binary fails verification with a verbose diagnostic that reproduces both hashes.

Emit

perry compile --emit-attest main.ts -o myapp
# → myapp
# → myapp.attest.json

Equivalent settings, last wins:

  1. perry.emitAttest: true in host package.json.
  2. PERRY_EMIT_ATTEST=1 in the environment.
  3. --emit-attest on the CLI.

=0 / false explicitly disables (so a CI matrix can override a host-level opt-in).

Verify

perry verify --attest ./myapp

Streams SHA-256 of the binary on disk and compares against myapp.attest.json. Output:

  • match — prints ✓ attestation matches plus the captured provenance (perry version, commit SHA, build timestamp). Exit 0.
  • mismatch — prints both hashes, the sidecar’s provenance, and exit 1.
  • missing sidecar — prints actionable guidance pointing at --emit-attest. Exit 1.

The verifier runs offline (no tokio runtime, no network, no beta consent prompt) — distinct from the existing perry verify which goes through verify.perryts.com for runtime verification.

Manifest shape

{
  "version": 1,
  "sha256": "abcd1234...",
  "size": 1048576,
  "perry_version": "0.5.999",
  "commit_sha": "0a1b2c3...",
  "built_at_unix": 1715990400,
  "binary_filename": "myapp"
}

version: 1 reserves room for future top-level keys (CI signature blob, sigstore bundle, reproducible-builds flags log) without breaking existing parsers.

When the hash is captured

The hash is computed after every post-link rewrite the platform applies — strip, codesign, install_name_tool retag, ad-hoc extended-attribute scrubs. That’s the same byte sequence users download, so the recomputed hash matches when the artifact is intact.

Cross-platform

The hook lives in the platform-agnostic compile_command driver, so every backend (LLVM, WASM, ArkTS, HarmonyOS, Glance, SwiftUI, JS) emits the sidecar consistently.

Follow-ups (MVP scope)

The MVP captures hash + provenance. Full reproducible-builds and sigstore-style remote signature publication are tracked separately under the same issue.

See also

  • #504 — design discussion.
  • #505 — companion build-time sandbox.
  • #506 — companion runtime sandbox profile.

--emit-sandbox — Kernel-Enforced Sandbox Profile

When a Perry binary is built with --emit-sandbox, the compiler writes a sandbox profile alongside the executable that the host can apply at runtime. The profile is derived from the build’s reachable stdlib surface — a program that never imports child_process gets a profile denying fork/execve; one that never imports http/fetch/net gets a profile denying outbound network; etc.

Zero per-call overhead in Perry’s emitted code. The kernel does the syscall-entry check, which it already does for every syscall regardless of sandbox state.

Today: macOS only (MVP)

perry compile --emit-sandbox main.ts -o myapp writes:

  • myapp — the executable.
  • myapp.sandbox — a sandbox-exec profile derived from the build.

Apply at run time:

sandbox-exec -f myapp.sandbox myapp

Linux seccomp BPF + landlock, Windows AppContainer, and per-API HIR-driven refinement are tracked as #506 follow-ups.

Enabling

Priority order, last wins (mirrors --fast-math / --lockdown):

  1. CLI flag: perry compile --emit-sandbox ...
  2. Env var: PERRY_EMIT_SANDBOX=1 (and =0 explicitly disables).
  3. package.json: { "perry": { "emitSandbox": true } }.

What’s derived from the build

Build signalEffect on profile
import "child_process"Allow process-fork + process-exec
Anything in http / https / net / tls / dns / ws / axios / node-fetch / redis / ioredisAllow network*
fetch(...) reachableSame as above
import "fs"Allow file-write* under /tmp, /private/tmp, /private/var/folders
perry-jsruntime linkedAllow dynamic-code-generation (QuickJS JIT)
AlwaysDeny default. Allow file-read* on system locations + /dev/null + /dev/urandom so the dynamic linker reaches main().

The generated profile is a starting point — review and tighten manually for production builds. Per-API HIR-driven refinement (which would distinguish fs.readFileSync-only deps from fs.writeFileSync deps, or fetch("https://api.example.com/...") from fetch(url)) lands as a follow-up under the same flag.

Header documents itself

The emitted profile starts with a documentation header that shows the sandbox-exec -f ... ... invocation and cites #506 for context — so downstream operators can see immediately how to apply it without hunting through Perry docs.

Composition with --lockdown

When the --lockdown mode (#496) lands, it will default --emit-sandbox on. For now they’re orthogonal.

What’s NOT covered (MVP)

  • Linux seccomp BPF filter + landlock FS scoping — follow-up.
  • Windows AppContainer manifest — follow-up.
  • Per-API HIR-driven refinement (fs.readFileSyncfs.writeFileSync, literal-host extraction for fetch).
  • Auto-loading the profile at process start via sandbox_init instead of the sandbox-exec wrapper.
  • iOS / Android — already sandboxed by the platform at process launch; out of scope for this flag.

See also

  • #506 — design discussion + tracker.
  • The wider supply-chain hardening series (#495#506).

--lockdown — Refuse Arbitrary-Code-Execution Surfaces

A single flag that fails the build if any of the standard arbitrary- code-execution vectors are reachable from the module graph. Most apps need none of them; lockdown is a one-line opt-in to “this app is provably free of arbitrary-code-execution vectors.”

Zero runtime cost. The check runs at compile time, after collect_modules, before any codegen work begins.

Cross-platform. Runs in the platform-agnostic compile_command driver, so every backend (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) inherits the protection from one choke point.

What lockdown refuses

SurfaceDetected via
perry-jsruntime (QuickJS) in graphctx.needs_js_runtime flipped during collection.
perry.nativeLibrary archive referencectx.native_libraries non-empty after resolution.
child_process.* call sitesHIR walker covers every ChildProcess* variant + the general-shape NativeMethodCall { module: "child_process", … } fallback.

All three checks run together; the failure lists every offending surface in one combined diagnostic so the reviewer can address the whole surface at once.

Enabling lockdown (priority order)

  1. CLI flag: perry compile --lockdown src/main.ts. Per-build.

  2. Env var: PERRY_LOCKDOWN=1. CI-friendly. =0 explicitly disables.

  3. package.json: persistent.

    {
      "perry": {
        "lockdown": true
      }
    }
    

Precedence: package.json → env → CLI (last wins, mirrors --fast-math).

Diagnostic example

Error: `--lockdown` refused the build because the following
arbitrary-code-execution surfaces are reachable:
  - perry-jsruntime (QuickJS-based eval-equivalent) is reachable
    from the module graph — see #499 docs for the matching opt-in
    gate
  - `perry.nativeLibrary` archives referenced by: @bloomengine/engine
  - `child_process.*` reached from 2 call site(s):
      - /repo/src/main.ts: child_process.execSync
      - /repo/lib/foo.ts: child_process.spawn

The child_process site list is capped at 12 entries; trailing sites are summarised as ... and N more.

Composing with the rest of the security series

Lockdown is the umbrella mode for the wider supply-chain hardening series (#495#506):

  • #503 — refuses dynamic stdlib dispatch (obj[runtimeVar]()). On by default regardless of lockdown.
  • #499 — gates perry-jsruntime behind explicit host opt-in. Lockdown forces the gate to its strict default.
  • #497 — host allowlist for perry.nativeLibrary / compilePackages. Lockdown refuses any nativeLibrary reference, no allow-list needed.

See also

  • #496 — design discussion.

Compile-Time Egress Allowlist (perry.allowedHosts)

Perry can verify, at compile time, that every outbound network call in your binary targets a host you’ve explicitly approved. When the host application opts in via perry.allowedHosts in package.json, every literal URL/host in a fetch(...), net.connect(...), or net.createConnection(...) call must match one of the listed patterns — otherwise the build fails before producing a binary.

Zero runtime cost. The check runs at compile time over the lowered HIR. The resulting binary is the same size and shape as a build without the gate.

Why a compile-time check

Runtime allowlists are foot-shoots — a misconfiguration or a malicious dep can bypass them. A compile-time check gives a stronger property: grep-ing the binary’s egress is reliable. If a dep tries to add a new outbound host through a literal URL, the build fails and the review catches it; if it tries to hide the host behind a variable, the build still fails unless you’ve explicitly opted into dynamic hosts.

Configuration

In your host package.json:

{
  "perry": {
    "allowedHosts": [
      "api.example.com",
      "*.cdn.example.com",
      "https://api.acme.com/v1/*"
    ]
  }
}

Pattern syntax

  • Exact host"api.example.com" matches that hostname on any scheme/port/path.
  • Subdomain wildcard"*.cdn.example.com" matches every direct or transitive subdomain. The bare suffix does NOT match — *.foo.com does not match foo.com.
  • URL prefix"https://api.acme.com/v1/*" matches any URL starting with that literal prefix. Path-bound entries only gate path-bearing call sites — net.connect("api.acme.com") against a URL-prefix entry does NOT match (use a host-style entry for that).
  • Universal"*" matches everything (escape hatch for incremental migration; defeats the static guarantee).

Dynamic URLs / hosts

Non-literal arguments — fetch(someVar), net.connect(port, hostVar), template strings with substitutions — defeat the static grep-the-binary guarantee. They’re refused by default:

const url = "https://api.example.com/x";
const resp = await fetch(url); // refused unless allowDynamicHosts: true

To allow them, set perry.allowDynamicHosts: true:

{
  "perry": {
    "allowedHosts": ["api.example.com"],
    "allowDynamicHosts": true
  }
}

The code reviewer then has to trust the value of every variable that reaches fetch(...) — explicit acknowledgment that the static guarantee is being weakened.

Opt-in semantics

If perry.allowedHosts is not set, the entire pass is disabled and existing builds compile unchanged. The host opts in by setting the array; once set, the gate is strict.

This is intentionally not “default-deny on greenfield” — that would break every existing build that calls fetch(...). Migration path:

  1. Run the build once without the allowlist.
  2. Inspect .perry-cache/audit.json (the behavioral SBOM (#495)) and see what egress the binary currently performs.
  3. Populate allowedHosts with the surface you actually use.
  4. Re-build. The gate now catches future regressions.

Diagnostic shape

The build fails with one combined diagnostic naming every offending site at once (better UX than failing on the first one and asking the user to re-run):

Error: egress allowlist refused 2 call site(s):
  - /repo/main.ts: fetch → "https://evil.com/leak" (literal host not in `perry.allowedHosts`)
  - /repo/lib/foo.ts: net.connect → "x.evil.com" (literal host not in `perry.allowedHosts`)

`perry.allowedHosts` provides a static guarantee that this binary's
outbound network surface matches the declared list. Refusing the build. (#502)

Options:
- Add the offending host(s) to `perry.allowedHosts` ...
- Set `"*"` in `allowedHosts` to disable host gating ...
- For non-literal URLs, set `perry.allowDynamicHosts: true` ...

The list is capped at 12 entries so pathological builds don’t produce 60-line errors; trailing sites are summarised as ... and N more.

What’s covered now

This first cut covers the highest-volume egress shape: fetch(...) + net.connect(...) / net.createConnection(...). Other shapes — http.get(...), https.request(...), WebSocket(...) — lower through the general-shape NativeMethodCall HIR variant and will graft onto the same pass in a follow-up.

See also

  • #502 — design discussion.
  • perry audit --sbom (#495) — discover what egress your binary currently performs before populating the allowlist.
  • The wider supply-chain hardening series (#495#506).

Per-Package Capabilities (perry.permissions)

A compile-time HIR pass walks every imported dependency’s source modules, derives the capability tokens its stdlib call sites would need, and refuses the build for any call site whose required token isn’t in the dependency’s allow-list (or the * default). Host code is always granted * unconditionally — gating host code is the --lockdown mode (#496), not per-package policy. (#501)

Zero runtime cost — purely a compile-time refusal. Cross-platform: runs in the platform-agnostic compile_command driver before any backend (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) is invoked.

Why

Most npm packages will never declare their own capabilities. The prior art around runtime permission prompts (Deno, Bun) ships a prompt; that doesn’t help when an install-time bun add lands a hostile dep that hides its egress until production. perry.permissions moves the gate to compile time and to the host’s package.json, so the supply chain is static from the consumer’s perspective.

Host config

{
  "perry": {
    "permissions": {
      "lodash": [],
      "axios": ["net:fetch"],
      "@scope/utils": ["crypto"],
      "*": []
    }
  }
}
  • Keys are exact npm package names (@scope/pkg accepted) or the universal "*" default.
  • Values are arrays of capability tokens (see below). Empty array means “this dep is only allowed to compute — no I/O”.
  • Absent map → pass is disabled and existing builds compile unchanged. Set any entry to enable.

Capability tokens (MVP)

TokenStdlib surface
fs:readfs.readFile, fs.readFileSync, fs.stat, fs.readdir, …
fs:writefs.writeFile, fs.appendFile, fs.mkdir, fs.unlink, fs.rm, …
cryptocrypto.*, crypto.subtle.*
proc:envprocess.env.* reads
proc:argvprocess.argv reads
proc:execchild_process.*
net:fetchfetch, Request, Response, Headers
net:listennet.createServer, http.createServer, https.createServer
net:connectnet.connect, net.createConnection, raw socket clients
*Grants every token above. Escape hatch — use sparingly.

Diagnostic

A failing build prints a combined diagnostic across every refused call site (capped at the first 12 entries to keep output reasonable):

Error: per-package capability policy refused 3 stdlib call site(s):
  - `axios` net:fetch at node_modules/axios/lib/http.js:42 requires `net:fetch`
  - `axios` fs:read at node_modules/axios/lib/cookies.js:11 requires `fs:read`
  - `mysterydep` proc:exec at node_modules/mysterydep/cli.js:7 requires `proc:exec`

`perry.permissions` provides a static guarantee that each
dependency only reaches the stdlib surfaces you've explicitly
granted it. Refusing the build. (#501)

The output names the owning package, the call kind, the source span, and the missing token — enough to either (a) extend the allow-list, (b) set "*": ["<token>"] for a wider default, or (c) replace the dep with one that doesn’t need the capability.

  1. Start empty. Set "permissions": {} to confirm your build is currently passing without the pass active.
  2. Flip the default to deny. Add "*": [] and rebuild. The diagnostic enumerates every capability your dep tree currently reaches.
  3. Grant minimum tokens per dep. Use the diagnostic to populate permissions with the smallest token set each package needs.
  4. Lock in CI. Once the build is green with the explicit permissions, leave it that way — new deps that want new tokens show up as build failures, surfacing in the PR review.

Relationship to other security flags

  • --lockdown (#496) — gates host code itself against the arbitrary-code-execution surfaces (perry-jsruntime, perry.nativeLibrary archives, child_process.*). Orthogonal: perry.permissions is per-dep, --lockdown is whole-binary.
  • allowedHosts (#502) — narrows net:fetch from “any URL” to “URLs matching this allow-list.” A dep with net:fetch permission still has to clear the egress allow-list at every call site.
  • PERRY_SANDBOX_BUILDRS (#505) — sandboxes the build-time build.rs scripts. perry.permissions controls what the runtime binary can do.

See also

Behavioral SBOM (perry audit --sbom)

Every Perry compile writes a behavioral SBOM to <project>/.perry-cache/audit.json — a per-module manifest of the stdlib symbols the build actually calls. The manifest is the foundation for the rest of the supply-chain hardening series and gives reviewers a way to see exactly what surface a dependency touches without rebuilding the binary.

Zero runtime cost. The walk runs at compile time over the lowered HIR; the file is written observationally and a missing-directory error never fails the build.

What’s recorded

For each source module:

  • source — canonical path the module was lowered from.
  • package — owning npm package name when the source lives under node_modules/<pkg>/... (scope-aware: @scope/pkg). null for host source.
  • stdlib — map of <namespace> → sorted unique method names. Captures both the general-shape NativeMethodCall lowering (mysql2.createConnection, child_process.execSync, …) and the dedicated specialized variants Perry uses for hot paths (fs.readFileSync, path.join, process.env, tty.isatty, url.fileURLToPath, …).

Example

A main.ts like:

import * as fs from "fs";
import * as path from "path";

const data = fs.readFileSync("/etc/hostname", "utf8");
const p = path.join("/tmp", "x");
console.log(data, p);

produces:

{
  "version": 1,
  "modules": [
    {
      "source": "/repo/main.ts",
      "package": null,
      "stdlib": {
        "fs": ["readFileSync"],
        "path": ["join"]
      }
    }
  ]
}

The JSON output is byte-deterministic across builds (BTreeMap keys + sorted method lists), so perry audit --sbom > before.txt + a package.json change + a re-build + perry audit --sbom > after.txt

  • diff before.txt after.txt is a meaningful review tool — any new capability a dependency reaches surfaces as added lines.

CLI

perry audit --sbom [PATH]

  • Reads the manifest from <PATH>/.perry-cache/audit.json, walking up the directory tree if needed (same shape perry compile walks up to find package.json).
  • Default PATH: current directory.
  • In --format json mode dumps the raw manifest pretty-printed.
  • In text mode groups modules by owning npm package; host source is reported under <host source>.
  • Returns a clear error if the manifest doesn’t exist yet — perry compile or perry run writes it on every successful build.

What’s NOT yet recorded

Scope of this first cut (MVP):

  • Literal fetch / http.get URLs — covered separately by #502 which the manifest will graft onto under a literal_hosts key.
  • Native-library symbol references (FFI registry) — tracked in the perry-codegen FFI registry and will graft onto the manifest under a native_symbols key.
  • perry audit --sbom --diff — the bytes-deterministic JSON shape already enables the diff workflow via plain diff / git diff; a built-in --diff is a follow-up that picks a baseline (.perry-cache/audit.last.json) and pretty-prints the change set.

The manifest shape is versioned (version: 1) so consumers can detect when new top-level keys land.

See also

  • #495 — design discussion.
  • The wider supply-chain hardening series (#495#506).

Host Allowlist for nativeLibrary and compilePackages

Perry refuses to honor two privileged dependency features — the two attack surfaces Perry itself introduced over Node — unless the host application has explicitly opted in to each consumer:

  • perry.nativeLibrary — a transitive dep linking arbitrary native code into the binary.
  • perry.compilePackages — compiling untrusted TS source from an npm package into the binary as if it were first-party code.

Both checks fire at compile time. Zero runtime cost.

How a build hits this

nativeLibrary (transitive dep declares it)

A package shipped with perry.nativeLibrary in its own package.json is detected during dependency collection. Without an entry in the host’s perry.allow.nativeLibrary, the build fails:

Error: package `@bloomengine/engine` declares `perry.nativeLibrary`
(links arbitrary native code into the binary) but is not in your host
`perry.allow.nativeLibrary`. Review the package, then add it to your
host `package.json`:

  {
    "perry": {
      "allow": { "nativeLibrary": ["@bloomengine/engine"] }
    }
  }

compilePackages (host or workspace root declares it)

Every entry in perry.compilePackages must also be matched by an entry in perry.allow.compilePackages — a two-key opt-in:

Error: package `hono` is in `perry.compilePackages` but not in
`perry.allow.compilePackages` — compiling untrusted TS into the binary
is a privileged operation and requires explicit host opt-in. (#497)

Opt-in mechanisms

{
  "perry": {
    "compilePackages": ["hono"],
    "nativeLibrary": "...",
    "allow": {
      "compilePackages": ["hono"],
      "nativeLibrary": ["@bloomengine/engine"]
    }
  }
}

2. Scope wildcard

"@scope/*" matches any package under @scope/:

{
  "perry": {
    "allow": {
      "compilePackages": ["@nestjs/*", "reflect-metadata", "rxjs"]
    }
  }
}

3. Universal escape hatch

"*" matches every name. Use sparingly — defeats the purpose of the allowlist.

{ "perry": { "allow": { "compilePackages": ["*"] } } }

4. Environment variable

PERRY_ALLOW_PERRY_FEATURES=1 opts every package into both allowlists for the current build — emergency knob for one-off builds where editing package.json isn’t an option. =0 enforces refusal even when package.json opted in (fail-closed CI gate).

Default-deny rationale

Both features escape Perry’s structural guarantees:

  • nativeLibrary lets a transitive dep ship arbitrary machine code that runs at the same trust level as the host application.
  • compilePackages runs the dep’s TypeScript through Perry’s full native pipeline (HIR / codegen / linker) instead of routing it through QuickJS, eliminating the runtime sandbox.

Both are useful features, but they’re privileged operations. The allowlist makes that privilege explicit and auditable: a reviewer diffing a PR can see exactly which deps have been granted native access, and git blame records who approved each one.

See also

  • #497 — design discussion.
  • The wider supply-chain hardening series (#495#506).

perry.toml Reference

perry.toml is the project-level configuration file for Perry. It controls project metadata, build settings, platform-specific options, code signing, distribution, auditing, and verification.

Created automatically by perry init, it lives at the root of your project alongside package.json.

Minimal Example

[project]
name = "my-app"
entry = "src/main.ts"

[build]
out_dir = "dist"

Full Example

[project]
name = "my-app"
version = "1.2.0"
build_number = 42
bundle_id = "com.example.myapp"
description = "A cross-platform Perry application"
entry = "src/main.ts"

[project.icons]
source = "assets/icon.png"

[build]
out_dir = "dist"

[macos]
bundle_id = "com.example.myapp.macos"
category = "public.app-category.developer-tools"
minimum_os = "13.0"
entitlements = ["com.apple.security.network.client"]
distribute = "both"
signing_identity = "Developer ID Application: My Company (TEAMID)"
certificate = "certs/mac-appstore.p12"
notarize_certificate = "certs/mac-devid.p12"
notarize_signing_identity = "Developer ID Application: My Company (TEAMID)"
installer_certificate = "certs/mac-installer.p12"
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "certs/AuthKey.p8"
encryption_exempt = true

[ios]
bundle_id = "com.example.myapp.ios"
deployment_target = "16.0"
device_family = ["iphone", "ipad"]
orientations = ["portrait", "landscape-left", "landscape-right"]
capabilities = ["push-notification"]
distribute = "appstore"
entry = "src/main-ios.ts"
provisioning_profile = "certs/MyApp.mobileprovision"
certificate = "certs/ios-distribution.p12"
signing_identity = "iPhone Distribution: My Company (TEAMID)"
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "certs/AuthKey.p8"
encryption_exempt = true

[android]
package_name = "com.example.myapp"
min_sdk = "26"
target_sdk = "34"
permissions = ["INTERNET", "CAMERA"]
distribute = "playstore"
keystore = "certs/release.keystore"
key_alias = "my-key"
google_play_key = "certs/play-service-account.json"
entry = "src/main-android.ts"

[linux]
format = "appimage"
category = "Development"
description = "A cross-platform Perry application"

[i18n]
locales = ["en", "de", "fr"]
default_locale = "en"

[i18n.currencies]
en = "USD"
de = "EUR"
fr = "EUR"

[publish]
server = "https://hub.perryts.com"

[audit]
fail_on = "B"
severity = "high"
ignore = ["RULE-001", "RULE-002"]

[verify]
url = "https://verify.perryts.com"

Sections

[project]

Core project metadata. This is the primary section for identifying your application.

FieldTypeDefaultDescription
namestringDirectory nameProject name, used for binary output name and default bundle ID
versionstring"1.0.0"Semantic version string (e.g., "1.2.3")
build_numberinteger1Numeric build number; auto-incremented on perry publish for iOS, Android, and macOS App Store builds
descriptionstringHuman-readable project description
entrystringTypeScript entry file (e.g., "src/main.ts"). Used by perry run and perry publish when no input file is specified
bundle_idstringcom.perry.<name>Default bundle identifier, used as fallback when platform-specific sections don’t define one

[project.icons]

FieldTypeDefaultDescription
sourcestringPath to a source icon image (PNG or JPG). Perry auto-resizes this to all required sizes for each platform

[app]

Alternative to [project] with identical fields. Useful for organizational clarity — [app] takes precedence over [project] when both are present:

# These two are equivalent:
[project]
name = "my-app"

# or:
[app]
name = "my-app"

When both exist, resolution order is: [app] field -> [project] field -> default.

[app] supports the same fields as [project]: name, version, build_number, bundle_id, description, entry, and icons.


[build]

Build output settings.

FieldTypeDefaultDescription
out_dirstring"dist"Directory for build artifacts

[macos]

macOS-specific configuration for perry publish macos and perry compile --target macos.

App Metadata

FieldTypeDefaultDescription
bundle_idstringFalls back to [app]/[project]macOS-specific bundle identifier (e.g., "com.example.myapp")
categorystringMac App Store category. Uses Apple’s UTI format (see valid values below)
minimum_osstringMinimum macOS version required (e.g., "13.0")
entitlementsstring[]macOS entitlements to include in the code signature (e.g., ["com.apple.security.network.client"])
encryption_exemptboolfalseIf true, adds ITSAppUsesNonExemptEncryption = false to Info.plist, skipping the export compliance prompt in App Store Connect

Distribution

FieldTypeDefaultDescription
distributestringDistribution method: "appstore", "notarize", or "both" (see Distribution Modes)

Code Signing

FieldTypeDefaultDescription
signing_identitystringAuto-detected from KeychainCode signing identity name (e.g., "3rd Party Mac Developer Application: Company (TEAMID)")
certificatestringAuto-exported from KeychainPath to .p12 certificate file for App Store distribution
notarize_certificatestringSeparate .p12 certificate for notarization (only used with distribute = "both")
notarize_signing_identitystringSigning identity for notarization (only used with distribute = "both")
installer_certificatestring.p12 certificate for Mac Installer Distribution (.pkg signing)

App Store Connect

FieldTypeDefaultDescription
team_idstringFrom ~/.perry/config.tomlApple Developer Team ID
key_idstringFrom ~/.perry/config.tomlApp Store Connect API key ID
issuer_idstringFrom ~/.perry/config.tomlApp Store Connect issuer ID
p8_key_pathstringFrom ~/.perry/config.tomlPath to App Store Connect .p8 API key file

macOS Distribution Modes

The distribute field controls how your macOS app is signed and distributed:

  • "appstore" — Signs with an App Store distribution certificate and uploads to App Store Connect. Requires team_id, key_id, issuer_id, and p8_key_path.

  • "notarize" — Signs with a Developer ID certificate and notarizes with Apple. For direct distribution outside the App Store.

  • "both" — Produces two signed builds: one for the App Store and one notarized for direct distribution. Requires two separate certificates:

    • certificate + signing_identity for the App Store build
    • notarize_certificate + notarize_signing_identity for the notarized build
    • Optionally installer_certificate for .pkg signing

macOS App Store Categories

Common values for the category field (Apple UTI format):

CategoryValue
Businesspublic.app-category.business
Developer Toolspublic.app-category.developer-tools
Educationpublic.app-category.education
Entertainmentpublic.app-category.entertainment
Financepublic.app-category.finance
Gamespublic.app-category.games
Graphics & Designpublic.app-category.graphics-design
Health & Fitnesspublic.app-category.healthcare-fitness
Lifestylepublic.app-category.lifestyle
Musicpublic.app-category.music
Newspublic.app-category.news
Photographypublic.app-category.photography
Productivitypublic.app-category.productivity
Social Networkingpublic.app-category.social-networking
Utilitiespublic.app-category.utilities

[ios]

iOS-specific configuration for perry publish ios, perry run ios, and perry compile --target ios/--target ios-simulator.

App Metadata

FieldTypeDefaultDescription
bundle_idstringFalls back to [app]/[project]iOS-specific bundle identifier
deployment_targetstring"17.0"Minimum iOS version required (e.g., "16.0")
minimum_versionstringAlias for deployment_target
device_familystring[]["iphone", "ipad"]Supported device families
orientationsstring[]["portrait"]Supported interface orientations
capabilitiesstring[]App capabilities (e.g., ["push-notification"])
entrystringFalls back to [project]/[app]iOS-specific entry file (useful when iOS needs a different entry point)
encryption_exemptboolfalseIf true, adds ITSAppUsesNonExemptEncryption = false to Info.plist

Distribution

FieldTypeDefaultDescription
distributestringDistribution method: "appstore", "testflight", or "development"

Code Signing

FieldTypeDefaultDescription
signing_identitystringAuto-detected from KeychainCode signing identity (e.g., "iPhone Distribution: Company (TEAMID)")
certificatestringAuto-exported from KeychainPath to .p12 distribution certificate
provisioning_profilestringPath to .mobileprovision file. Stored as {bundle_id}.mobileprovision in ~/.perry/ by perry setup ios

App Store Connect

FieldTypeDefaultDescription
team_idstringFrom ~/.perry/config.tomlApple Developer Team ID
key_idstringFrom ~/.perry/config.tomlApp Store Connect API key ID
issuer_idstringFrom ~/.perry/config.tomlApp Store Connect issuer ID
p8_key_pathstringFrom ~/.perry/config.tomlPath to .p8 API key file

Device Family Values

ValueDescription
"iphone"iPhone devices
"ipad"iPad devices

Orientation Values

ValueDescription
"portrait"Device upright
"portrait-upside-down"Device upside down
"landscape-left"Device rotated left
"landscape-right"Device rotated right

[visionos]

visionOS-specific configuration for perry publish visionos, perry run visionos, and perry compile --target visionos/--target visionos-simulator.

App Metadata

FieldTypeDefaultDescription
bundle_idstringFalls back to [app]/[project]/[ios]visionOS-specific bundle identifier
deployment_targetstring"1.0"Minimum visionOS version required
minimum_versionstringAlias for deployment_target
entrystringFalls back to [project]/[app]visionOS-specific entry file
encryption_exemptboolfalseIf true, adds ITSAppUsesNonExemptEncryption = false to Info.plist
info_plisttableCustom key-value pairs merged into the generated Info.plist

Distribution / Signing

FieldTypeDefaultDescription
distributestringDistribution method for visionOS builds
signing_identitystringAuto-detected from KeychainCode signing identity
certificatestringAuto-exported from KeychainPath to .p12 distribution certificate
provisioning_profilestringPath to .mobileprovision file
team_idstringFrom ~/.perry/config.tomlApple Developer Team ID
key_idstringFrom ~/.perry/config.tomlApp Store Connect API key ID
issuer_idstringFrom ~/.perry/config.tomlApp Store Connect issuer ID
p8_key_pathstringFrom ~/.perry/config.tomlPath to .p8 API key file

[android]

Android-specific configuration for perry publish android, perry run android, and perry compile --target android.

FieldTypeDefaultDescription
package_namestringFalls back to bundle_id chainJava package name (e.g., "com.example.myapp")
min_sdkstringMinimum Android SDK version (e.g., "26" for Android 8.0)
target_sdkstringTarget Android SDK version (e.g., "34" for Android 14)
version_codeintegerDerived from build_numberExplicit Play versionCode. Overrides the derived value; must be strictly greater than any code already uploaded to Play (max 2100000000). Use it to keep versionCode monotonic across CI/build-number changes without touching the marketing version.
permissionsstring[]Android permissions (e.g., ["INTERNET", "CAMERA", "ACCESS_FINE_LOCATION"])
distributestringDistribution method: "playstore"
keystorestringPath to .jks or .keystore signing keystore
key_aliasstringAlias of the signing key within the keystore
google_play_keystringPath to Google Play service account JSON file for automated uploads
entrystringFalls back to [project]/[app]Android-specific entry file

[linux]

Linux-specific configuration for perry publish linux.

FieldTypeDefaultDescription
formatstringPackage format: "appimage", "deb", or "rpm"
categorystringDesktop application category (e.g., "Development", "Utility", "Game")
descriptionstringFalls back to [project]/[app]Application description for package metadata

[i18n]

Internationalization configuration. See the i18n documentation for full details.

FieldTypeDefaultDescription
localesstring[]Supported locale codes (e.g., ["en", "de", "fr"]). Locale files must exist in /locales
default_localestring"en"Fallback locale. Used when a key is missing in another locale
dynamicbooleanfalsefalse: locale set at launch, strings inlined. true: locale switchable at runtime

[i18n.currencies]

Maps locale codes to default ISO 4217 currency codes. Used by the Currency() format wrapper.

KeyTypeDescription
{locale}stringCurrency code for the locale (e.g., en = "USD", de = "EUR")

[publish]

Publishing configuration.

FieldTypeDefaultDescription
serverstringhttps://hub.perryts.comCustom Perry Hub build server URL. Useful for self-hosted or enterprise deployments

[audit]

Security audit configuration for perry audit and pre-publish audits.

FieldTypeDefaultDescription
fail_onstring"C"Minimum acceptable audit grade. Build fails if the actual grade is below this threshold. Values: "A", "A-", "B", "C", "D", "F"
severitystring"all"Filter findings by severity: "all", "critical", "high", "medium", "low"
ignorestring[]List of audit rule IDs to suppress (e.g., ["RULE-001", "RULE-042"])

Audit Grade Scale

Grades are ranked from highest to lowest:

GradeRankDescription
A6Excellent — no significant findings
A-5Very good — minor findings only
B4Good — some findings
C3Acceptable — moderate findings
D2Poor — significant findings
F1Fail — critical findings

Setting fail_on = "B" means any grade below B (i.e., C, D, or F) will cause the build to fail.


[verify]

Runtime verification configuration for perry verify.

FieldTypeDefaultDescription
urlstringhttps://verify.perryts.comVerification service endpoint URL

Bundle ID Resolution

Perry resolves the bundle identifier using a cascading priority system. The first non-empty value wins:

For iOS builds:

  1. [ios].bundle_id
  2. [app].bundle_id
  3. [project].bundle_id
  4. [macos].bundle_id
  5. package.json bundleId field
  6. com.perry.<app_name> (generated default)

For macOS builds:

  1. [macos].bundle_id
  2. [app].bundle_id
  3. [project].bundle_id
  4. package.json bundleId field
  5. com.perry.<app_name> (generated default)

For Android builds:

  1. [android].package_name
  2. [ios].bundle_id
  3. [macos].bundle_id
  4. [app].bundle_id
  5. [project].bundle_id
  6. com.perry.<app_name> (generated default)

Entry File Resolution

When no input file is specified on the command line, Perry resolves the entry file in this order:

  1. [ios].entry / [android].entry (when targeting that platform)
  2. [project].entry or [app].entry
  3. src/main.ts (if it exists)
  4. main.ts (if it exists)

Build Number Auto-Increment

The build_number field is automatically incremented by perry publish for:

  • iOS builds
  • Android builds
  • macOS App Store builds (distribute = "appstore" or "both")

The updated value is written back to perry.toml after a successful publish. This ensures each submission to the App Store / Play Store has a unique, monotonically increasing build number.

macOS builds with distribute = "notarize" (direct distribution) do not auto-increment the build number.


Configuration Priority

Perry resolves configuration values using a layered priority system (highest to lowest):

  1. CLI flags — e.g., --target, --output
  2. Environment variables — e.g., PERRY_LICENSE_KEY
  3. perry.toml — project-level config (platform-specific sections first, then [app]/[project])
  4. ~/.perry/config.toml — user-level global config
  5. Built-in defaults

Environment Variables

These environment variables override perry.toml and global config values:

Apple / iOS / macOS

VariableDescription
PERRY_LICENSE_KEYPerry Hub license key
PERRY_APPLE_CERTIFICATE.p12 certificate file contents (base64)
PERRY_APPLE_CERTIFICATE_PASSWORDPassword for the .p12 certificate
PERRY_APPLE_P8_KEY.p8 API key file contents
PERRY_APPLE_KEY_IDApp Store Connect API key ID
PERRY_APPLE_NOTARIZE_CERTIFICATE_PASSWORDPassword for the notarization .p12 certificate
PERRY_APPLE_INSTALLER_CERTIFICATE_PASSWORDPassword for the installer .p12 certificate

Android

VariableDescription
PERRY_ANDROID_KEYSTOREPath to .jks/.keystore file
PERRY_ANDROID_KEY_ALIASKeystore key alias
PERRY_ANDROID_KEYSTORE_PASSWORDKeystore password
PERRY_ANDROID_KEY_PASSWORDKey password (within the keystore)
PERRY_GOOGLE_PLAY_KEY_PATHPath to Google Play service account JSON

General

VariableDescription
PERRY_NO_TELEMETRYSet to 1 to disable anonymous telemetry
PERRY_NO_UPDATE_CHECKSet to 1 to disable background update checks

Global Config: ~/.perry/config.toml

Separate from the project-level perry.toml, Perry maintains a user-level global config at ~/.perry/config.toml. This stores credentials and preferences shared across all projects.

license_key = "perry-xxxxxxxx"
server = "https://hub.perryts.com"
default_target = "macos"

[apple]
team_id = "ABCDE12345"
key_id = "KEYID123"
issuer_id = "issuer-uuid-here"
p8_key_path = "/Users/me/.perry/AuthKey.p8"

[android]
keystore_path = "/Users/me/.perry/release.keystore"
key_alias = "my-key"
google_play_key_path = "/Users/me/.perry/play-service-account.json"

Fields in perry.toml (project-level) override ~/.perry/config.toml (global-level). For example, [ios].team_id in perry.toml overrides [apple].team_id in the global config.

The global config is managed by perry setup commands:

  • perry setup ios — configures Apple signing credentials
  • perry setup android — configures Android signing credentials
  • perry setup macos — configures macOS distribution settings

perry.toml vs package.json

Perry reads configuration from both files. Here’s what goes where:

SettingFileSection
Compile packages nativelypackage.jsonperry.compilePackages
Splash screenpackage.jsonperry.splash
Project name, version, entryperry.toml[project]
Platform-specific settingsperry.toml[ios], [macos], [android], [linux]
Code signing & distributionperry.tomlPlatform sections
Build output directoryperry.toml[build]
Audit & verificationperry.toml[audit], [verify]

When both files define the same value (e.g., project name), perry.toml takes precedence.


Setup Wizard

Running perry setup <platform> interactively configures signing credentials and writes them back to both perry.toml and ~/.perry/config.toml:

perry setup ios       # Configure iOS signing (certificate, provisioning profile)
perry setup android   # Configure Android signing (keystore, Play Store key)
perry setup macos     # Configure macOS distribution (App Store, notarization)

The wizard automatically:

  • Sets [ios].distribute = "testflight" if not already configured
  • Sets [android].distribute = "playstore" if not already configured
  • Stores provisioning profiles as ~/.perry/{bundle_id}.mobileprovision
  • Auto-exports .p12 certificates from macOS Keychain when possible

CI/CD Example

For CI environments, use environment variables instead of storing credentials in perry.toml:

# GitHub Actions example
env:
  PERRY_LICENSE_KEY: ${{ secrets.PERRY_LICENSE_KEY }}
  PERRY_APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
  PERRY_APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
  PERRY_APPLE_P8_KEY: ${{ secrets.APPLE_P8_KEY }}
  PERRY_APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
  PERRY_ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
  PERRY_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
  PERRY_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
  PERRY_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}

steps:
  - run: perry publish ios
  - run: perry publish android
  - run: perry publish macos

Keep perry.toml in version control with non-sensitive fields only:

[project]
name = "my-app"
version = "2.1.0"
build_number = 47
bundle_id = "com.example.myapp"
entry = "src/main.ts"

[ios]
deployment_target = "16.0"
device_family = ["iphone", "ipad"]
distribute = "appstore"
encryption_exempt = true

[android]
package_name = "com.example.myapp"
min_sdk = "26"
target_sdk = "34"
distribute = "playstore"

[macos]
distribute = "both"
category = "public.app-category.productivity"
minimum_os = "13.0"

[audit]
fail_on = "B"

Memory Model

Perry compiles TypeScript directly to native code via LLVM, but JavaScript is a managed language: closures escape, objects outlive scopes, cycles exist. This page explains how Perry reconciles “native binary” with “garbage-collected language” — the value representation, the heap layout, how the GC finds roots, and how LLVM-generated code cooperates with the collector.

If you’ve ever wondered “does Perry use reference counting?” — no. There is no Rc at runtime. Perry has a real tracing GC, described below.

Value representation: NaN-boxing

Every JavaScript value in Perry is a single 64-bit word. The encoding piggy-backs on IEEE 754: any f64 whose exponent is all-ones and whose mantissa is non-zero is a NaN, and there are ~2⁵² distinct NaN bit patterns. Perry uses the high 16 bits as a type tag and the low 48 (or 32) bits as the payload.

Tag (high 16 bits)TypePayload
0x7FFC…0001undefined— (singleton)
0x7FFC…0002null— (singleton)
0x7FFC…0003false— (singleton)
0x7FFC…0004true— (singleton)
0x7FFABigIntlow 48 bits = heap pointer
0x7FFDObject / Array / Closurelow 48 bits = heap pointer
0x7FFEInt32low 32 bits = signed int
0x7FFFStringlow 48 bits = heap pointer
anything elsef64the full 64 bits are the number

Source: crates/perry-runtime/src/value.rs.

Three consequences worth noting:

  1. Numbers are free. A plain f64 value is its own representation — no boxing, no header, no allocation. Numeric hot loops cost nothing in memory traffic.
  2. The GC can identify pointer values from the tag alone. When tracing a value, the collector masks the high bits, checks for 0x7FFA/0x7FFD/0x7FFF, and either follows the low-48-bit pointer or skips. There is no per-value runtime type lookup.
  3. Type checks are bitwise. typeof and many fast paths in the runtime are register-level mask-and-compare operations.

Heap layout: per-thread arena, nursery + old-gen

Perry is single-threaded by default, and each thread owns its own heap. Sharing across threads happens via deep copy (SerializedValue), not shared memory, so the GC never has to synchronize across threads.

Within a thread, the heap is two arenas:

  • ARENA — the nursery. New allocations land here. Carved into 1 MB blocks (since v0.5.196).
  • OLD_ARENA — the old generation. Holds objects that have survived enough minor GCs to be tenured.

Every allocation, in either arena, is prefixed by an 8-byte GcHeader (crates/perry-runtime/src/gc.rs:14):

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct GcHeader {
    pub obj_type: u8,    // GC_TYPE_ARRAY, GC_TYPE_STRING, …
    pub gc_flags: u8,    // MARKED | ARENA | PINNED | TENURED | HAS_SURVIVED | …
    pub _reserved: u16,
    pub size: u32,       // total alloc size, used for arena block walking
}
}

Callers receive a pointer after the header (ptr + 8), so from TypeScript code’s perspective the header is invisible. The collector finds the header by subtracting 8.

Allocation goes through gc_malloc(size, obj_type) (gc.rs:606). LLVM-generated code emits calls to this for every object literal, array literal, closure capture, string concat, BigInt operation, etc. There is no allocation primitive in the IR that bypasses this — going through gc_malloc is how the GC accounts for live memory and decides when to collect.

How the GC finds roots

This is the part most people are surprised by: if Perry compiles through LLVM, the optimizer is free to keep values in registers, spill them to stack slots, rematerialize them — none of which the collector can introspect. So how does the collector know which JS values are live?

Three mechanisms, used together:

1. Precise shadow stack (codegen-emitted)

Codegen emits, at function entry, a call to js_shadow_frame_push(slot_count) (gc.rs:493). This reserves a frame in a thread-local shadow stack. Every JS-level local variable in the function gets a slot, and every assignment to that local emits a paired js_shadow_slot_set(idx, value) call. On function exit, codegen emits js_shadow_frame_pop.

The result: at any GC safepoint, the collector can walk the shadow stack and see the live NaN-boxed value of every TS-level local in every active frame, regardless of what LLVM did with registers. This is the “precise” half of the root scan — shadow_stack_root_scanner (gc.rs:3860).

2. Conservative native-stack scan

Some values are not on the shadow stack — most importantly, anything currently in a CPU register or in a Rust runtime frame at the moment GC fires. For these, the collector scans the native stack word-by-word and, for each word, checks whether it looks like a pointer into one of the arenas. Anything that does is conservatively pinned for that cycle (is_conservatively_pinned, gc.rs:3747).

Pinning means: the object isn’t freed, and isn’t moved (the evacuation pass skips it). False positives are acceptable — they just keep a dead object alive for one more cycle. False negatives would be catastrophic — they’d free a live object — and the shadow stack + scanner registration below ensure they don’t happen for known roots.

3. Registered runtime root scanners

Some roots live in the runtime itself, not in user code: pending Promises, timer callbacks, exception state, async-context stacks, async-hooks state, shape caches, transition caches, overflow fields, JSON-parse scratch tables, the string intern table. Each is registered with the collector via gc_register_root_scanner(scanner_fn) (gc.rs:807), and the collector invokes each scanner during the mark phase. There are 9 such scanners currently registered (gc.rs:32323940).

Generational behaviour

Most JS allocations die young — object literals in a loop body, short-lived closures, intermediate strings. A generational collector exploits this by collecting the nursery frequently and the old gen rarely.

Perry uses two-bit aging encoded in gc_flags (gc.rs:64):

  • First minor GC an object survives: GC_FLAG_HAS_SURVIVED is set.
  • Second minor GC it survives: GC_FLAG_TENURED is set, and the object is logically promoted to old-gen.

PROMOTION_AGE = 2. The two-bit scheme avoids needing a counter field in the header.

Tenured objects initially stay physically where they are in the nursery — promotion is a flag flip, not a copy. A telemetry-driven evacuation policy copies tenured non-pinned objects into OLD_ARENA and rewrites all references to point at the new locations only when generated write barriers are active and nursery/RSS pressure plus measured movable candidates justify the extra work. Low-pressure cycles, cycles with no movable candidates, and cycles without generated write barriers skip evacuation and reference rewriting.

Write barriers and the remembered set

Generational collectors have one fundamental problem: if an old-gen object points to a young-gen object, a minor GC (which only traces the nursery) needs to know about that pointer or it will free a live object.

The fix is a write barrier: every time a pointer field is written, the runtime checks “is this old → young?” and, if so, records the parent in a remembered set. Minor GCs treat remembered-set entries as additional roots.

In Perry, the runtime barrier is always present: js_write_barrier(parent, child) (gc.rs:3773). Codegen emits write-barrier calls by default so copied minor GC and evacuation can rely on exact dirty-page data. Set PERRY_WRITE_BARRIERS=0/off/false during compile to suppress generated barrier calls for benchmark/debug bisection; at runtime, the same setting disables runtime exact helper barriers. Copied-minor and evacuation then treat barrier data as inactive and fall back to conservative paths.

Triggers and tuning

gc_check_trigger (gc.rs:919) fires on three signals:

  1. Arena block allocation — every time a new 1 MB block is allocated for the nursery.
  2. Malloc count threshold — too many malloc-tracked objects (strings, closures, …) outstanding.
  3. Explicit gc() call from user code.

The next-trigger calculation steps up after each cycle but is hard-capped at the initial threshold (64 MB) so that a workload which frees >90% of the nursery on each cycle can’t drift peak occupancy upward through step-doubling (C4b-δ-tune, v0.5.236).

Idle nursery blocks observed empty for 2 GC cycles are dealloc’d back to the OS (C4b-δ, v0.5.235), so a workload’s RSS shrinks once the burst is over.

Escape hatches and diagnostics

Env varEffect
PERRY_GEN_GC=0 / off / falseDisable generational mode; fall back to full mark-sweep (intended for bisection only).
PERRY_GEN_GC_EVACUATE=0 / off / falseDisable policy evacuation. =1 / on / true is accepted as “allow the auto-policy”, not as unconditional evacuation.
PERRY_GC_FORCE_EVACUATE=1With generated write barriers active and policy evacuation allowed, stress-copy every marked non-pinned nursery object instead of only tenured survivors.
PERRY_GC_VERIFY_EVACUATION=1After an evacuation that actually forwards objects, panic if any mutable live slot still points at a forwarded nursery object after rewrite.
PERRY_WRITE_BARRIERS=0 / off / falseDisable codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection. Unset, =1, =on, and =true keep barriers enabled.
PERRY_GC_DIAG=1Print per-cycle diagnostics, including one evacuation-policy line for cycles where evacuation was considered and for barriers_inactive skips.

Why this design

The combination — NaN-boxing for cheap value representation, per-thread arenas to avoid cross-thread sync, precise shadow stack + conservative stack scan for safe root discovery under an opaque optimizer (LLVM), generational aging for nursery-friendly workloads — is what lets Perry both go through LLVM and run a managed language without a fight.

Going to native code does not preclude having a GC. It just means the GC’s relationship with the compiled code is mediated by an ABI: codegen emits calls to gc_malloc, js_shadow_frame_push/pop/js_shadow_slot_set, and js_write_barrier, and the runtime crate (linked in as native code) is a real generational mark-sweep collector. There is nothing reference-counted at runtime.

Source map

TopicFile
NaN-boxing constants and helperscrates/perry-runtime/src/value.rs
GcHeader, type/flag constantscrates/perry-runtime/src/gc.rs:14
gc_malloccrates/perry-runtime/src/gc.rs:606
Shadow stackcrates/perry-runtime/src/gc.rs:493583
Minor GCcrates/perry-runtime/src/gc.rs:1192
Write barriercrates/perry-runtime/src/gc.rs:3773
Registered root scannerscrates/perry-runtime/src/gc.rs:32323940
Conservative pin setcrates/perry-runtime/src/gc.rs:3747
Design plan (pre-implementation)docs/generational-gc-plan.md

Architecture

This is a brief overview for contributors. For detailed implementation notes, see the project’s CLAUDE.md.

Compilation Pipeline

TypeScript (.ts)
    ↓ Parse (SWC)
    ↓ AST
    ↓ Lower (perry-hir)
    ↓ HIR (High-level IR)
    ↓ Transform (inline, closure conversion, async lowering)
    ↓ Codegen (LLVM)
    ↓ Object file (.o)
    ↓ Link (system cc)
    ↓
Native Executable

Crate Map

CratePurpose
perryCLI driver, command parsing, compilation orchestration
perry-parserSWC wrapper for TypeScript parsing
perry-typesType system definitions
perry-hirHIR data structures (ir.rs) and AST→HIR lowering (lower.rs)
perry-transformIR passes: function inlining, closure conversion, async lowering
perry-codegen-llvmLLVM-based native code generation
perry-codegen-wasmWebAssembly code generation for --target web / --target wasm (HIR → WASM bytecode + JS bridge)
perry-codegen-jsLegacy JavaScript code generator (still present for the JS minifier; the JS-emit --target web path was consolidated into perry-codegen-wasm)
perry-codegen-swiftuiSwiftUI code generation for WidgetKit extensions
perry-runtimeRuntime library: NaN-boxed values, GC, arena allocator, objects, arrays, strings
perry-stdlibNode.js API implementations: mysql2, redis, fastify, bcrypt, etc.
perry-uiShared UI types
perry-ui-macosmacOS UI (AppKit)
perry-ui-iosiOS UI (UIKit)
perry-jsruntimeJavaScript interop via QuickJS

Key Concepts

NaN-Boxing

All JavaScript values are represented as 64-bit NaN-boxed values. The upper 16 bits encode the type tag:

TagType
0x7FFFString (lower 48 bits = pointer)
0x7FFDPointer/Object (lower 48 bits = pointer)
0x7FFEInt32 (lower 32 bits = integer)
0x7FFABigInt (lower 48 bits = pointer)
Special constantsundefined, null, true, false
Any otherFloat64 (the full 64 bits)

Garbage Collection

Generational mark-sweep GC, per-thread arena split into nursery + old-gen. Roots come from a precise shadow stack (emitted by codegen), a conservative native-stack scan, and 9 registered runtime scanners. Two-bit aging tenures objects after surviving 2 minor cycles; a write barrier maintains a remembered set for old → young pointers.

See Internals → Memory Model for the full picture (NaN-boxing, heap layout, root discovery, generational behaviour, env-var escape hatches).

Handle-Based UI

UI widgets are represented as small integer handles NaN-boxed with POINTER_TAG. Each handle maps to a native platform widget (NSButton, UILabel, GtkButton, etc.). Two dispatch tables route method calls and property accesses to the correct FFI function.

Source Code Organization

The codegen crate is organized into focused modules:

perry-codegen-llvm/src/
  codegen.rs       # Main entry, module compilation
  types.rs         # Type definitions, context structs
  util.rs          # Helper functions
  stubs.rs         # Stub generation for unresolved deps
  runtime_decls.rs # Runtime function declarations
  classes.rs       # Class compilation
  functions.rs     # Function compilation
  closures.rs      # Closure compilation
  module_init.rs   # Module initialization
  stmt.rs          # Statement compilation
  expr.rs          # Expression compilation

The HIR lowering was split into 8 modules:

perry-hir/src/
  lower.rs           # Main lowering entry
  analysis.rs        # Code analysis passes
  enums.rs           # Enum lowering
  jsx.rs             # JSX lowering
  lower_types.rs     # Type lowering
  lower_patterns.rs  # Pattern lowering
  destructuring.rs   # Destructuring lowering
  lower_decl.rs      # Declaration lowering

Next Steps

Building from Source

Prerequisites

  • Rust toolchain (stable): rustup.rs
  • System C compiler (cc on macOS/Linux, MSVC on Windows)

Build

git clone https://github.com/skelpo/perry.git
cd perry

# Build all crates (release mode recommended)
cargo build --release

The binary is at target/release/perry.

Build Specific Crates

# Runtime only (must rebuild stdlib too!)
cargo build --release -p perry-runtime -p perry-stdlib

# Codegen only
cargo build --release -p perry-codegen-llvm

Important: When rebuilding perry-runtime, you must also rebuild perry-stdlib because libperry_stdlib.a embeds perry-runtime as a static dependency.

Run Tests

# All tests (exclude iOS crate on non-iOS host)
cargo test --workspace --exclude perry-ui-ios

# Specific crate
cargo test -p perry-hir
cargo test -p perry-codegen-llvm

Compile and Run TypeScript

# Compile a TypeScript file
cargo run --release -- hello.ts -o hello
./hello

# Debug: print HIR
cargo run --release -- hello.ts --print-hir

Development Workflow

  1. Make changes to the relevant crate
  2. cargo build --release to build
  3. cargo test --workspace --exclude perry-ui-ios to verify
  4. Test with a real TypeScript file: cargo run --release -- test.ts -o test && ./test

Project Structure

perry/
├── crates/
│   ├── perry/              # CLI driver
│   ├── perry-parser/       # SWC TypeScript parser
│   ├── perry-types/        # Type definitions
│   ├── perry-hir/          # HIR and lowering
│   ├── perry-transform/    # IR passes
│   ├── perry-codegen-llvm/ # LLVM native codegen
│   ├── perry-codegen-wasm/ # WebAssembly codegen (--target web / --target wasm)
│   ├── perry-codegen-js/   # JS minifier (formerly the web target's codegen)
│   ├── perry-codegen-swiftui/ # Widget codegen
│   ├── perry-runtime/      # Runtime library
│   ├── perry-stdlib/       # npm package implementations
│   ├── perry-ui/           # Shared UI types
│   ├── perry-ui-macos/     # macOS AppKit UI
│   ├── perry-ui-ios/       # iOS UIKit UI
│   └── perry-jsruntime/    # QuickJS integration
├── docs/                   # This documentation (mdBook)
├── CLAUDE.md               # Detailed implementation notes
└── CHANGELOG.md            # Version history

Next Steps

  • Architecture — Crate map and pipeline overview
  • See CLAUDE.md for detailed implementation notes and pitfalls