diff --git a/.changeset/native-node-backend.md b/.changeset/native-node-backend.md new file mode 100644 index 0000000..2d58adb --- /dev/null +++ b/.changeset/native-node-backend.md @@ -0,0 +1,7 @@ +--- +"@bomb.sh/tty": minor +--- + +Add a C Node-API backend for Node.js, with WebAssembly fallback when no matching +native addon is packaged. Preserve the TypeScript API and add local native build, +worker and buffer lifetime checks, and a native-versus-Wasm benchmark. diff --git a/.gitignore b/.gitignore index 99b6cdf..148b766 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /wasm.ts /build/ /node_modules/ + +/native/ diff --git a/Makefile b/Makefile index c3e7b24..65d002e 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ CFLAGS = --target=wasm32 -nostdlib -O2 \ -mbulk-memory \ -DCLAY_IMPLEMENTATION -DCLAY_WASM \ -DCLAY_DEBUG_MODE_ENABLED=0 \ - -Isrc -I. + -Isrc -Ibuild/clay -I. EXPORTS = \ -Wl,--export=__heap_base \ @@ -58,23 +58,23 @@ all: $(TARGET) wasm.ts DEPS = $(wildcard src/*.c src/*.h) -# Apply every patch in patches/ to the clay submodule before compiling. clay.h -# is reset to pristine first, so the applied set always matches patches/*.patch -# exactly (idempotent, applied in sorted order). Reverted by `make clean`. -# Opt-outs like CLAY_DEBUG_MODE_ENABLED=0 are selected via CFLAGS above. -# Drop individual patches as their changes ship in upstream clay. -$(TARGET): $(DEPS) $(CLAY_PATCHES) - @git -C clay checkout -- clay.h - @for p in $(CLAY_PATCHES); do \ - git -C clay apply ../$$p || { echo "ERROR: failed to apply $$p to clay/clay.h" >&2; exit 1; }; \ - done +# Patch a generated copy; never reset or edit the upstream submodule checkout. +build/clay/clay.h: clay/clay.h $(CLAY_PATCHES) + @mkdir -p build/clay + git -C clay show HEAD:clay.h > $@ + @for p in $(CLAY_PATCHES); do patch -s -d build/clay -p1 < $$p || exit 1; done + +$(TARGET): $(DEPS) build/clay/clay.h $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) +native: build/clay/clay.h + deno run --allow-read --allow-write --allow-env --allow-run tasks/build-native.ts + wasm.ts: $(TARGET) deno run --allow-read --allow-write tasks/bundle-wasm.ts clean: rm -f $(TARGET) wasm.ts - -git -C clay checkout -- clay.h + rm -rf build/clay build/native-clay native -.PHONY: all clean +.PHONY: all clean native diff --git a/README.md b/README.md index 983ba05..b58635b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,47 @@ bytes and get bytes back. This makes it trivially embeddable in any framework, any runtime, any event loop. There are no opinions about how you do I/O, just pure computation. -**Runs everywhere** β€” The entire engine is compiled to WebAssembly, so -`@bomb.sh/tty` will run anywhere JavaScript runs with no native dependencies, -and no build step for consumers. +**Portable engine** β€” WebAssembly provides a portable backend without native +dependencies. Node.js uses a matching packaged `.node` addon when available, +with the same TypeScript API. When no matching addon is packaged, it uses +WebAssembly. + +### Local native build and benchmarks + +Initialize the pinned Clay dependency, then build both backends: + +```sh +git submodule update --init --recursive +make +make native +node bench/native.bench.ts +``` + +The Wasm build requires Clang with a wasm32 target and `wasm-ld`. The native +build supports macOS and Linux and requires a C11 compiler, Deno, Node.js, and +Node development headers. Set `NODE_INCLUDE_DIR` if `node_api.h` is not in +`../include/node` relative to the Node executable; `CC` selects the compiler. + +`make native` writes a platform-specific addon to `native/`. Node.js selects +that addon automatically; Deno and Bun use Wasm. A present addon that fails to +load reports the error. `deno task build:npm ` includes the locally +built addon in the npm artifact. Builds for other platforms require their own +native artifacts; cross-platform binary distribution is not automated. + +The benchmark uses isolated copies of the same TypeScript code, with an addon +present only in the native copy. It measures the rendering workloads from +`bench/render.bench.ts`, mixed input throughput, and startup in fresh Node +processes. Raw results and methodology are written to +`build/native-benchmark.json`. + +Run the existing suite in Wasm and native Node.js, plus native renderer lifetime +and worker checks: + +```sh +deno task test +deno run -A tasks/build-npm.ts 0.0.0-test --test +node --expose-gc --test test/native-renderer.node.ts +``` ### Examples @@ -49,15 +87,15 @@ it converts a stream of raw bytes into structured events. ### Output With every frame, the entire UI tree is packed into a flat byte array and sent -to WASM in a single call. On the C side, Clay runs layout, render commands are -walked into a cell buffer, and the buffer is diffed against the previous frame. -Only the cells that actually changed produce output. The result is an ANSI -escape sequence that can be written directly to stdout. One trip to WASM per -frame, double buffered, and only the bytes that need to change hit the output -stream. +to the C engine in a single call. On the C side, Clay runs layout, render +commands are walked into a cell buffer, and the buffer is diffed against the +previous frame. Only the cells that actually changed produce output. The result +is an ANSI escape sequence that can be written directly to stdout. One engine +render call per frame, double buffered, and only the bytes that need to change +hit the output stream. -Because the WASM module is pure computation with no I/O, it runs anywhere -WebAssembly does: Deno, Node, Bun, browsers, or any other runtime. +The engine performs pure computation with no terminal I/O. Its Wasm backend runs +in Deno, Node, Bun, browsers, or any other runtime. ``` TypeScript WASM (C) @@ -77,10 +115,10 @@ WebAssembly does: Deno, Node, Bun, browsers, or any other runtime. ### Input -Raw bytes from stdin are fed into a WASM-based parser that recognizes VT/ANSI -escape sequences, UTF-8 codepoints, and mouse protocols (VT200, SGR, urxvt). The -parser maintains its own internal buffer so partial sequences that arrive across -read boundaries are reassembled automatically. A lone ESC byte is held for a +Raw bytes from stdin are fed into a C parser that recognizes VT/ANSI escape +sequences, UTF-8 codepoints, and mouse protocols (VT200, SGR, urxvt). The parser +maintains its own internal buffer so partial sequences that arrive across read +boundaries are reassembled automatically. A lone ESC byte is held for a configurable latency window (default 25ms) before being emitted, giving multi-byte sequences time to arrive. diff --git a/bench/fixtures/backend-startup.ts b/bench/fixtures/backend-startup.ts new file mode 100644 index 0000000..32ab4a0 --- /dev/null +++ b/bench/fixtures/backend-startup.ts @@ -0,0 +1,17 @@ +import { performance } from "node:perf_hooks"; +import { pathToFileURL } from "node:url"; + +let [directory, operation] = process.argv.slice(2); +let start = performance.now(); +let { createTerm, createInput, open, text, close } = await import( + pathToFileURL(`${directory}/mod.ts`).href +); +if (operation === "createInput") { + await createInput(); +} else { + let term = await createTerm({ width: 80, height: 24 }); + if (operation === "firstRender") { + term.render([open("root"), text("hello"), close()]); + } +} +console.log(JSON.stringify({ ms: performance.now() - start })); diff --git a/bench/fixtures/render-scenes.ts b/bench/fixtures/render-scenes.ts new file mode 100644 index 0000000..a289e02 --- /dev/null +++ b/bench/fixtures/render-scenes.ts @@ -0,0 +1,95 @@ +import { close, fixed, grow, type Op, open, rgba, text } from "../../ops.ts"; + +export let dashboardOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("header", { + layout: { + width: grow(), + height: fixed(3), + padding: { left: 1 }, + direction: "ltr", + }, + bg: rgba(30, 30, 40), + border: { color: rgba(80, 80, 100), bottom: 1 }, + }), + text("Dashboard", { color: rgba(255, 255, 255) }), + close(), + open("body", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("sidebar", { + layout: { + width: fixed(20), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + bg: rgba(25, 25, 35), + border: { color: rgba(60, 60, 80), right: 1 }, + }), + text("Nav 1"), + text("Nav 2"), + text("Nav 3"), + text("Nav 4"), + close(), + open("main", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, top: 1 }, + }, + }), + ...Array.from({ length: 10 }, (_, i) => [ + open(`row-${i}`, { + layout: { + width: grow(), + height: fixed(1), + direction: "ltr", + }, + bg: i % 2 === 0 ? rgba(35, 35, 45) : undefined, + }), + text(`Row ${i}: data value ${i * 42}`), + close(), + ]).flat(), + close(), + close(), + open("footer", { + layout: { + width: grow(), + height: fixed(1), + padding: { left: 1 }, + }, + bg: rgba(30, 30, 40), + }), + text("Ready"), + close(), + close(), +]; + +export let uiOps: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("button", { + layout: { + width: fixed(20), + height: fixed(3), + padding: { left: 1, right: 1 }, + }, + bg: rgba(50, 50, 200), + border: { + color: rgba(100, 100, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text("Click me"), + close(), + close(), +]; diff --git a/bench/native.bench.ts b/bench/native.bench.ts new file mode 100644 index 0000000..81fdf56 --- /dev/null +++ b/bench/native.bench.ts @@ -0,0 +1,218 @@ +/** Compare identical public-API workloads in isolated Node.js processes. + * Build with `make && make native`, then `node bench/native.bench.ts`. + */ +import { spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { cpus } from "node:os"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; +import { dashboardOps, uiOps } from "./fixtures/render-scenes.ts"; +import { nativeFilename } from "../native-platform.ts"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const self = fileURLToPath(import.meta.url); +const median = (values: number[]) => + [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; + +async function worker(directory: string, startup?: string) { + let start = performance.now(); + let { createTerm, createInput } = await import( + pathToFileURL(`${directory}/mod.ts`).href + ); + if (startup) { + if (startup === "createInput") await createInput(); + else { + let term = await createTerm({ width: 80, height: 24 }); + if (startup === "firstRender") term.render(uiOps); + } + console.log(JSON.stringify({ ms: performance.now() - start })); + return; + } + let term = await createTerm({ width: 80, height: 24 }); + let input = await createInput({ escLatency: 25 }); + let unit = new TextEncoder().encode( + "the quick brown fox \x1b[A\x1b[<0;40;12MδΈ­\x1b[97;3uπŸŽ‰", + ); + let corpus = new Uint8Array(unit.length * 1000); + for (let off = 0; off < corpus.length; off += unit.length) { + corpus.set(unit, off); + } + let checksum = 0; + let workloads: Record void; operations: number }> = { + mixedRender: { + run() { + for (let i = 0; i < 250; i++) { + checksum += + term.render(i % 2 === 0 ? dashboardOps : uiOps).output.length; + } + }, + operations: 250, + }, + steadyDiff: { + run() { + for (let i = 0; i < 250; i++) { + checksum += term.render(dashboardOps).output.length; + } + }, + operations: 250, + }, + inputCorpus: { + run() { + let dispatched = 0; + for (let off = 0; off < corpus.length; off += 64) { + dispatched += + input.scan(corpus.subarray(off, off + 64)).events.length; + } + if (!dispatched) throw new Error("input workload produced no events"); + checksum += dispatched; + }, + operations: 1, + }, + }; + let results: Record = {}; + for (let [name, { run, operations }] of Object.entries(workloads)) { + let until = performance.now() + 300; + while (performance.now() < until) run(); + let samples: number[] = []; + for (let round = 0; round < 7; round++) { + let start = performance.now(); + let count = 0; + do { + run(); + count++; + } while (performance.now() - start < 150); + samples.push((performance.now() - start) * 1000 / count / operations); + } + results[name] = median(samples); + } + console.log( + JSON.stringify({ results, checksum, corpusBytes: corpus.length }), + ); +} + +function child(directory: string, startup?: string) { + let start = performance.now(); + let args = startup + ? [ + fileURLToPath(new URL("./fixtures/backend-startup.ts", import.meta.url)), + directory, + startup, + ] + : [self, "--worker", directory]; + let result = spawnSync(process.execPath, args, { + encoding: "utf8", + cwd: root, + }); + if (result.status !== 0) { + throw new Error(result.stderr || `worker exited ${result.status}`); + } + return { ...JSON.parse(result.stdout), processMs: performance.now() - start }; +} + +async function main() { + if (process.argv[2] === "--worker") { + return await worker(process.argv[3], process.argv[4]); + } + let directories = Object.fromEntries(["wasm", "native"].map((backend) => { + let directory = resolve(root, "build/backend-comparison", backend); + mkdirSync(directory, { recursive: true }); + for (let name of readdirSync(root)) { + if (name.endsWith(".ts")) { + cpSync(resolve(root, name), `${directory}/${name}`); + } + } + writeFileSync(`${directory}/package.json`, '{"type":"module"}\n'); + return [backend, directory]; + })); + mkdirSync(`${directories.native}/native`, { recursive: true }); + cpSync( + resolve(root, "native", nativeFilename()), + `${directories.native}/native/${nativeFilename()}`, + ); + let throughput: Record> = { + wasm: {}, + native: {}, + }; + // Alternate process order to reduce temperature and scheduling bias. + let runs: Record[]> = { wasm: [], native: [] }; + for (let round = 0; round < 3; round++) { + for (let backend of round % 2 ? ["native", "wasm"] : ["wasm", "native"]) { + let result = child(directories[backend]); + runs[backend].push(result.results); + } + } + for (let backend of ["wasm", "native"]) { + for (let key of Object.keys(runs[backend][0])) { + throughput[backend][key] = median(runs[backend].map((r) => r[key])); + } + } + let startup: Record< + string, + Record + > = {}; + for (let name of ["createTerm", "createInput", "firstRender"]) { + let samples: Record = { + wasm: [], + native: [], + }; + for (let round = 0; round < 23; round++) { + for (let backend of round % 2 ? ["native", "wasm"] : ["wasm", "native"]) { + let result = child(directories[backend], name); + if (round >= 3) samples[backend].push(result); + } + } + startup[name] = Object.fromEntries(["wasm", "native"].map((b) => [b, { + moduleMs: median(samples[b].map((r) => r.ms)), + processMs: median(samples[b].map((r) => r.processMs)), + }])); + } + let report = { + date: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: cpus()[0].model, + nativeBuild: JSON.parse( + readFileSync(resolve(root, "build/native-build.json"), "utf8"), + ), + inputCorpusBytes: new TextEncoder().encode( + "the quick brown fox \x1b[A\x1b[<0;40;12MδΈ­\x1b[97;3uπŸŽ‰", + ).length * 1000, + throughputUs: throughput, + startupMs: startup, + runs, + methodology: + "O2 builds; identical API workloads; 3 alternating processes/backend; 300ms warmup and 7x150ms samples/workload/process; startup 20 fresh processes/backend after 3 warmups; medians; startup module time includes import", + }; + let output = resolve(root, "build/native-benchmark.json"); + writeFileSync(output, JSON.stringify(report, null, 2) + "\n"); + console.table( + Object.keys(throughput.wasm).map((name) => ({ + workload: name, + "Wasm Β΅s": throughput.wasm[name].toFixed(2), + "Native Β΅s": throughput.native[name].toFixed(2), + "Wasm/native": + (throughput.wasm[name] / throughput.native[name]).toFixed(2) + "Γ—", + })), + ); + console.table( + Object.entries(startup).map(([name, r]) => ({ + workload: name, + "Wasm import+operation ms": r.wasm.moduleMs.toFixed(2), + "Native import+operation ms": r.native.moduleMs.toFixed(2), + "Wasm process ms": r.wasm.processMs.toFixed(2), + "Native process ms": r.native.processMs.toFixed(2), + })), + ); + console.log(`Saved ${output}`); +} + +await main(); diff --git a/bench/render.bench.ts b/bench/render.bench.ts index 1bad7a6..02209d3 100644 --- a/bench/render.bench.ts +++ b/bench/render.bench.ts @@ -1,105 +1,10 @@ import { Bench } from "tinybench"; import { sync, withCodSpeed } from "./fixtures/utils.ts"; import { createTerm } from "../term.ts"; -import { close, fixed, grow, open, rgba, text } from "../ops.ts"; -import type { Op } from "../ops.ts"; +import { dashboardOps, uiOps } from "./fixtures/render-scenes.ts"; let term = await createTerm({ width: 80, height: 24 }); -let dashboardOps: Op[] = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ttb" }, - }), - open("header", { - layout: { - width: grow(), - height: fixed(3), - padding: { left: 1 }, - direction: "ltr", - }, - bg: rgba(30, 30, 40), - border: { color: rgba(80, 80, 100), bottom: 1 }, - }), - text("Dashboard", { color: rgba(255, 255, 255) }), - close(), - open("body", { - layout: { width: grow(), height: grow(), direction: "ltr" }, - }), - open("sidebar", { - layout: { - width: fixed(20), - height: grow(), - direction: "ttb", - padding: { left: 1, top: 1 }, - }, - bg: rgba(25, 25, 35), - border: { color: rgba(60, 60, 80), right: 1 }, - }), - text("Nav 1"), - text("Nav 2"), - text("Nav 3"), - text("Nav 4"), - close(), - open("main", { - layout: { - width: grow(), - height: grow(), - direction: "ttb", - padding: { left: 2, top: 1 }, - }, - }), - ...Array.from({ length: 10 }, (_, i) => [ - open(`row-${i}`, { - layout: { - width: grow(), - height: fixed(1), - direction: "ltr", - }, - bg: i % 2 === 0 ? rgba(35, 35, 45) : undefined, - }), - text(`Row ${i}: data value ${i * 42}`), - close(), - ]).flat(), - close(), - close(), - open("footer", { - layout: { - width: grow(), - height: fixed(1), - padding: { left: 1 }, - }, - bg: rgba(30, 30, 40), - }), - text("Ready"), - close(), - close(), -]; - -let uiOps: Op[] = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ttb" }, - }), - open("button", { - layout: { - width: fixed(20), - height: fixed(3), - padding: { left: 1, right: 1 }, - }, - bg: rgba(50, 50, 200), - border: { - color: rgba(100, 100, 255), - left: 1, - right: 1, - top: 1, - bottom: 1, - }, - cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, - }), - text("Click me"), - close(), - close(), -]; - let bench = withCodSpeed(new Bench({ name: "render" })); bench diff --git a/bench/results/native-darwin-arm64.json b/bench/results/native-darwin-arm64.json new file mode 100644 index 0000000..c3a1608 --- /dev/null +++ b/bench/results/native-darwin-arm64.json @@ -0,0 +1,116 @@ +{ + "date": "2026-10-22T22:49:09.001Z", + "node": "v26.5.1", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M1 Max", + "nativeBuild": { + "compiler": "Homebrew clang version 20.1.7", + "flags": [ + "-std=c11", + "-O2", + "-fPIC", + "-fvisibility=hidden", + "-shared", + "-DCLAY_IMPLEMENTATION", + "-DCLAY_DEBUG_MODE_ENABLED=0", + "-DNAPI_VERSION=8", + "-I/opt/homebrew/Cellar/node/26.5.1/include/node", + "-Ibuild/native-clay", + "-Isrc", + "-I.", + "src/node.c", + "-o", + "native/tty-darwin-arm64.node", + "-undefined", + "dynamic_lookup" + ], + "target": { + "execPath": "/opt/homebrew/Cellar/node/26.5.1/bin/node", + "platform": "darwin", + "arch": "arm64" + } + }, + "inputCorpusBytes": 48000, + "throughputUs": { + "wasm": { + "mixedRender": 33.39634266666667, + "steadyDiff": 30.212458199999947, + "inputCorpus": 1670.8449111111097 + }, + "native": { + "mixedRender": 26.340029043478253, + "steadyDiff": 26.477101565217353, + "inputCorpus": 1681.3273111111102 + } + }, + "startupMs": { + "createTerm": { + "wasm": { + "moduleMs": 25.953958999999998, + "processMs": 100.11466699999801 + }, + "native": { + "moduleMs": 22.732124999999996, + "processMs": 100.05950000000303 + } + }, + "createInput": { + "wasm": { + "moduleMs": 26.13754099999999, + "processMs": 101.25383299999885 + }, + "native": { + "moduleMs": 22.614333000000002, + "processMs": 100.41666699999769 + } + }, + "firstRender": { + "wasm": { + "moduleMs": 28.475874999999995, + "processMs": 100.53512499999488 + }, + "native": { + "moduleMs": 22.836707999999994, + "processMs": 100.23500000000058 + } + } + }, + "runs": { + "wasm": [ + { + "mixedRender": 33.10799136842103, + "steadyDiff": 30.48475840000001, + "inputCorpus": 1667.2384333333312 + }, + { + "mixedRender": 38.37136450000003, + "steadyDiff": 30.086016799999925, + "inputCorpus": 1670.8449111111097 + }, + { + "mixedRender": 33.39634266666667, + "steadyDiff": 30.212458199999947, + "inputCorpus": 1701.5585168539292 + } + ], + "native": [ + { + "mixedRender": 27.1918116521739, + "steadyDiff": 26.717739130434772, + "inputCorpus": 1680.124077777782 + }, + { + "mixedRender": 26.311413043478247, + "steadyDiff": 26.432021739130423, + "inputCorpus": 1759.1124069767407 + }, + { + "mixedRender": 26.340029043478253, + "steadyDiff": 26.477101565217353, + "inputCorpus": 1681.3273111111102 + } + ] + }, + "methodology": "O2 builds; identical API workloads; 3 alternating processes/backend; 300ms warmup and 7x150ms samples/workload/process; startup 20 fresh processes/backend after 3 warmups; medians; startup module time includes import" +} diff --git a/deno.json b/deno.json index 0448304..da62525 100644 --- a/deno.json +++ b/deno.json @@ -2,7 +2,7 @@ "name": "@bomb.sh/tty", "license": "MIT", "tasks": { - "test": "deno test --allow-read --allow-write", + "test": "deno test --allow-read --allow-write test", "fmt": "deno fmt && clang-format -i src/*.c src/*.h", "fmt:check": "deno fmt --check && clang-format --dry-run --Werror src/*.c src/*.h", "build:npm": "deno run -A tasks/build-npm.ts", diff --git a/input-native.ts b/input-native.ts index 2c540fa..5612e6a 100644 --- a/input-native.ts +++ b/input-native.ts @@ -176,7 +176,7 @@ export function readEvent(view: DataView, ptr: number): NativeInputEvent { } export interface InputNative { - memory: WebAssembly.Memory; + memory: { readonly buffer: ArrayBufferLike }; state: number; buffer: number; scan(st: number, buf: number, len: number, now: number): number; @@ -193,13 +193,36 @@ function align8(n: number): number { return (n + 7) & ~7; } -import { compiled } from "./wasm.ts"; +import { nodeAddon } from "./node-addon.ts"; export async function createInputNative( escLatency: number, keys?: Uint8Array, initialColors?: number, ): Promise { + let addon = nodeAddon(); + if (addon) { + let handle = addon.createInput( + escLatency, + initialColors ?? 256, + keys ?? new Uint8Array(), + ); + let metadata = new DataView(handle.buffer); + return { + memory: { + get buffer() { + return handle.buffer; + }, + }, + state: 0, + buffer: handle.transfer, + scan: (_st, _buf, len, now) => addon.scan(handle, len, now), + count: () => metadata.getInt32(0, true), + event: (_st, i) => metadata.getInt32(4, true) + i * handle.eventSize, + delay: () => metadata.getInt32(8, true), + }; + } + let { compiled } = await import("./wasm.ts"); let memory = new WebAssembly.Memory({ initial: 4 }); let instance = await WebAssembly.instantiate(compiled, { diff --git a/native-platform.ts b/native-platform.ts new file mode 100644 index 0000000..05d86de --- /dev/null +++ b/native-platform.ts @@ -0,0 +1,23 @@ +import process from "node:process"; + +interface Target { + platform: string; + arch: string; + libc?: "glibc" | "musl"; +} + +/** Internal artifact name, shared by the loader and local build task. */ +export function nativeFilename(target?: Target): string { + if (!target) { + let libc: Target["libc"]; + if (process.platform === "linux") { + let report = process.report.getReport() as { + header: { glibcVersionRuntime?: string }; + }; + libc = report.header.glibcVersionRuntime ? "glibc" : "musl"; + } + target = { platform: process.platform, arch: process.arch, libc }; + } + let libc = target.libc ? `-${target.libc}` : ""; + return `tty-${target.platform}-${target.arch}${libc}.node`; +} diff --git a/node-addon.ts b/node-addon.ts new file mode 100644 index 0000000..101a6df --- /dev/null +++ b/node-addon.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { nativeFilename } from "./native-platform.ts"; +import type { BoundingBox } from "./term-native.ts"; + +interface Handle { + buffer: ArrayBuffer; + transfer: number; + eventSize: number; +} + +interface Addon { + createTerm(w: number, h: number): Handle; + createInput(latency: number, colors: number, keys: Uint8Array): Handle; + resize(handle: Handle, w: number, h: number): void; + reduce( + handle: Handle, + len: number, + mode: number, + row: number, + dt: number, + px: number, + py: number, + pointer: number, + ): void; + output(handle: Handle): number; + length(handle: Handle): number; + animating(handle: Handle): number; + errorCount(handle: Handle): number; + errorType(handle: Handle, i: number): number; + errorMessage(handle: Handle, i: number): string; + pointerIds(handle: Handle): string[]; + bounds(handle: Handle, id: string): BoundingBox | undefined; + caps(handle: Handle, flags: number): number; + scan(handle: Handle, len: number, now: number): number; + count(handle: Handle): number; + event(handle: Handle, i: number): number; + delay(handle: Handle): number; + terminfo(bytes: Uint8Array): [status: number, colors: number, flags: number]; +} + +let addon: Addon | null | undefined; + +/** A missing platform build permits Wasm; a broken present build is an error. */ +export function nodeAddon(): Addon | undefined { + if (addon !== undefined) return addon ?? undefined; + if ( + process.release.name !== "node" || process.versions.deno || + process.versions.bun + ) { + addon = null; + return undefined; + } + let path = new URL(`./native/${nativeFilename()}`, import.meta.url); + addon = existsSync(path) + ? createRequire(import.meta.url)(fileURLToPath(path)) + : null; + return addon ?? undefined; +} diff --git a/specs/input-spec.md b/specs/input-spec.md index 5107963..22ac807 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -12,7 +12,7 @@ for decoding raw terminal byte sequences into structured events. Input parsing is architecturally independent from rendering (see [Renderer Specification](renderer-spec.md), INV-7). The two concerns share a -compiled WASM binary for loading efficiency, but neither depends on the other's +compiled binary for loading efficiency, but neither depends on the other's state, types, or API surface. Both consume the shared capability layer defined in the [Terminfo Specification](terminfo-spec.md); the input parser is additionally that layer's runtime write path (see Section 6). @@ -47,7 +47,7 @@ guide future stabilization. ## 3. Terminology -**Input parser.** A WASM-backed instance that accepts raw terminal bytes and +**Input parser.** A C-engine-backed instance that accepts raw terminal bytes and produces structured events. Each parser maintains its own internal state for multi-byte sequence buffering and ESC timeout tracking. @@ -68,8 +68,15 @@ event. Discriminated on a `type` field. createInput(options?): Promise ``` -Creates an input parser instance. The returned promise resolves when the WASM -module is ready. +Creates an input parser instance. The returned promise resolves when its backend +is ready. Node.js prefers a matching packaged Node-API addon, falling back to +WASM only when no matching addon is packaged. A matching addon that fails to +load or initialize rejects creation. Other runtimes use WASM. Selecting the +native backend does not prepare or instantiate WASM. + +Each parser owns its state and buffers. Separate parsers, including parsers in +separate Node.js workers, MUST remain isolated. Backend selection does not +change scan results, timeout behavior, or terminfo integration. Options: diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 2b70afd..79224de 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -53,7 +53,7 @@ Transitions are specified separately in the ### Out of scope - Internal C code organization, function names, or file structure -- WASM memory layout or compilation details beyond behavioral requirements +- Backend memory layout or compilation details beyond behavioral requirements - Performance targets or benchmark methodology - Packaging, CI, or distribution workflow details - Higher-level UI framework concerns (e.g., component lifecycle, reconciliation) @@ -91,8 +91,8 @@ output described by the frame's directives. ANSI bytes include cursor-positioning sequences, SGR (Select Graphic Rendition) attribute sequences, and UTF-8 text. -**Renderer core.** The WASM module and its TS entry points that together -implement the render transaction. The renderer core owns layout computation, +**Renderer core.** The C engine and its TS entry points that together implement +the render transaction. The renderer core owns layout computation, render-command walking, cell-buffer diffing, and ANSI byte generation. **Caller.** Any code that invokes Clayterm's public API to produce terminal @@ -121,13 +121,12 @@ Clayterm implements a rendering pipeline with the following stages: 1. **Directive acceptance.** The caller provides a complete directive array representing the desired UI state for a single frame. -2. **Transfer.** The renderer transfers the frame description into the WASM - module. The transfer mechanism is an implementation detail. The normative - requirement is that the transfer occurs as part of a single render - transaction; the caller does not interact with the transfer mechanism - directly. +2. **Transfer.** The renderer transfers the frame description into the C engine. + The transfer mechanism is an implementation detail. The normative requirement + is that the transfer occurs as part of a single render transaction; the + caller does not interact with the transfer mechanism directly. -3. **Render transaction.** The WASM module processes the frame description. +3. **Render transaction.** The C engine processes the frame description. Internally, it drives a layout engine to compute element positions and sizes, walks the resulting render commands to populate a cell buffer, and diffs the cell buffer against the previous frame. @@ -140,7 +139,7 @@ Clayterm implements a rendering pipeline with the following stages: ### 4.2 Single-transaction rendering -A frame MUST be rendered in a single transaction that crosses the TSβ†’WASM +A frame MUST be rendered in a single transaction that crosses the TSβ†’engine boundary once. The caller provides the complete directive array, invokes the render transaction, and reads the output. There are no intermediate callbacks, yields, or partial results. @@ -163,6 +162,26 @@ Changed cells are then copied from the back buffer to the front buffer so that both buffers are identical at the end of the transaction. This mechanism is internal to the renderer and not directly observable to the caller. +### 4.5 Backend selection and isolation + +The C engine supports WebAssembly and a Node-API native addon (`.node`). The +public TypeScript signatures and rendering semantics are identical for both +backends. Backend selection is internal; callers do not supply backend options +or native pointers. + +In Node.js, a packaged native addon matching the host platform and architecture +is preferred. When no matching native addon is packaged, the library uses WASM. +Other runtimes use WASM. If a matching addon is present but fails to load or +initialize, the factory rejects with that error rather than silently switching +backends. Native selection MUST NOT require preparing or instantiating WASM. The +same selection policy applies to input parsing and terminfo parsing. + +Each Term owns its renderer state, layout context, capabilities, and buffers. +Rendering, updating, or collecting one instance MUST NOT affect another +instance, including instances in separate Node.js workers. Sharing a native +library MUST NOT introduce shared mutable renderer state or unsynchronized +layout execution. + --- ## 5. Contract Layer Boundary @@ -176,7 +195,7 @@ can depend on. This specification **does not** define the following as normative: - **The internal transfer encoding.** The mechanism by which directives are - serialized for the WASM module β€” its byte format, opcode structure, and field + serialized for the C engine β€” its byte format, opcode structure, and field encoding β€” is an implementation detail. The normative commitment is that the transfer happens within a single render transaction; the encoding is described in Section 12.1 as current implementation surface. @@ -213,7 +232,7 @@ It MUST NOT write to stdout, read from stdin, open file descriptors, or interact with the terminal device. The renderer produces bytes; the caller writes them. **INV-2. Single transaction per frame.** Each frame MUST be rendered in a single -transaction that crosses the TSβ†’WASM boundary once. The caller provides the +transaction that crosses the TSβ†’engine boundary once. The caller provides the complete frame as a directive array and receives ANSI bytes in return. **INV-3. Frame-snapshot independence.** The renderer MUST NOT require the caller @@ -235,9 +254,9 @@ function scope. **INV-7. Separation of concerns.** The rendering concern and the input-parsing concern MUST remain independent. Neither MUST depend on the other's state, -types, or API surface. They MAY share a compiled WASM binary for loading -efficiency, but this is an implementation convenience, not an architectural -coupling. Both MAY consume the shared capability layer defined in the +types, or API surface. They MAY share a compiled binary for loading efficiency, +but this is an implementation convenience, not an architectural coupling. Both +MAY consume the shared capability layer defined in the [Terminfo Specification](terminfo-spec.md); the renderer reads capabilities and the input parser surfaces probe responses as `CapabilityEvent` values, but neither observes the other beyond the capability facts it carries. @@ -263,10 +282,10 @@ The directive array is the sole required input to a render transaction. When the caller invokes a render transaction: 1. The renderer accepts the directive array and transfers the frame description - into the WASM module. -2. The WASM module processes the frame: it computes layout, walks render - commands, populates the cell buffer, diffs against the previous frame, and - writes ANSI bytes for changed cells. + into the C engine. +2. The C engine processes the frame: it computes layout, walks render commands, + populates the cell buffer, diffs against the previous frame, and writes ANSI + bytes for changed cells. 3. Control returns to the caller with the ANSI byte output available. The render transaction is synchronous from the caller's perspective once @@ -297,9 +316,9 @@ output beyond that point MUST copy it. A Term instance is created for specific terminal dimensions. The caller provides width and height at creation time. -Creation of a Term is asynchronous because it may involve WASM module -preparation. A Term instance MAY be used for any number of render transactions. -The Term retains its cell buffers across frames for diffing purposes. +Creation of a Term is asynchronous because it may involve backend preparation. A +Term instance MAY be used for any number of render transactions. The Term +retains its cell buffers across frames for diffing purposes. A Term instance's dimensions MAY be changed after creation through the update transaction (Β§7.7). A resized Term remains valid: it continues to accept render @@ -437,9 +456,10 @@ be a no-op for that step. **Resize semantics.** A non-no-op resize step: -1. Reallocates renderer state for the new dimensions within the Term's existing - WASM instance and linear memory, growing the memory if required. The renderer - MUST NOT create a new WASM module or instance. +1. Reinitializes renderer state for the new dimensions within the Term's + existing backend instance, growing its allocation if required. The renderer + MUST NOT replace the backend instance. For WASM, it MUST reuse the module + instance and linear memory. 2. Discards all diff state. The next render transaction MUST emit the frame as a complete redraw, so that no cell laid out at the previous dimensions can linger on screen. @@ -458,14 +478,17 @@ terminal immediately. An empty array is valid when the update changes no rendered state (TINV-5). The caller writes the bytes to its output stream before the next render. -**Memory.** WASM linear memory can only grow. An update to smaller dimensions -retains the high-water-mark allocation; memory is not reclaimed until the Term -itself is discarded. This is accepted behavior, not a defect. +**Memory.** A backend MAY retain its high-water-mark allocation after an update +to smaller dimensions. WASM linear memory can only grow. Native allocations MUST +be released when their owning instance and any views retaining those allocations +are no longer reachable. -**Output invalidation.** Growing linear memory detaches existing buffer views. -Output `Uint8Array`s returned by render transactions prior to a resize update -MUST NOT be used after it (this strengthens the validity window in Β§7.3: output -is valid until the next `render()` **or** `update()` call). +**Output invalidation.** Growing WASM linear memory detaches existing buffer +views. Native output views MUST keep their backing allocation alive even when a +resize replaces the allocation or the Term becomes unreachable. Output +`Uint8Array`s returned by render transactions prior to a resize update MUST NOT +be used after it (this strengthens the validity window in Β§7.3: output is valid +until the next `render()` **or** `update()` call). ### 7.8 Capability consumption @@ -820,12 +843,12 @@ A snapshot is semantically equivalent to splicing its source ops into the array at the snapshot's position. The renderer MUST produce identical layout and output regardless of whether ops are provided directly or via a snapshot. -### 9.2 Transfer to the WASM module +### 9.2 Transfer to the C engine As part of the render transaction, the directive array is transferred into a -form that the WASM module can process. This transfer is handled internally by -the renderer and is not an operation the caller performs or observes. The -transfer mechanism is an implementation detail described in Section 12.1. +form that the C engine can process. This transfer is handled internally by the +renderer and is not an operation the caller performs or observes. The transfer +mechanism is an implementation detail described in Section 12.1. If a frame exceeds transfer-buffer capacity while packing string content, the renderer MUST throw a descriptive `RangeError` that identifies the condition as @@ -884,8 +907,8 @@ _This section is normative._ The renderer MUST NOT write to any output stream. The renderer MUST NOT read from any input stream. The renderer produces bytes; the caller decides when and how to write them. This enables the renderer to operate in any environment where -WebAssembly is available, including browsers, server-side runtimes, and embedded -contexts. +a supported backend is available, including browsers through WebAssembly and +Node.js through a native addon or WebAssembly. ### 11.2 The renderer does not manage terminal state @@ -1073,8 +1096,8 @@ CSS-like shorthand/longhand fashion before rendering: the terminal default remains in effect where no element background applies. Fallback resolution is performed on the TypeScript side before the frame is -transferred; the WASM renderer consumes explicit per-side attributes and does -not implement the public fallback rules. +transferred; the renderer consumes explicit per-side attributes and does not +implement the public fallback rules. **Independent sides and corners.** Each enabled side renders as a straight edge (`─` for horizontal sides, `β”‚` for vertical sides). A corner glyph MUST be @@ -1095,10 +1118,10 @@ corner cells; corner glyph shape selection (including rounded corners via **Border width and layout interaction.** The renderer automatically reserves space for each enabled border side. For each side, the effective padding passed -to the layout engine is `userPadding + borderWidth`; the WASM renderer applies -this adjustment when decoding the element declaration. Border glyphs are drawn -at the same positions as before; the change is purely in how much layout space -Clay allocates for the element. +to the layout engine is `userPadding + borderWidth`; the renderer applies this +adjustment when decoding the element declaration. Border glyphs are drawn at the +same positions as before; the change is purely in how much layout space Clay +allocates for the element. Semantics of the additive rule: @@ -1220,24 +1243,27 @@ _This section is non-normative. These notes describe current implementation details that aid understanding but do not define contract. They may change without notice._ -**WASM module structure.** The renderer is implemented in C and compiled to -WebAssembly as a single module. The module contains both rendering and -input-parsing functionality; they share a binary but maintain independent state. +**Backend structure.** The engine is implemented in C and compiled to a WASM +module or a Node-API native addon. Rendering and input parsing share a compiled +binary but maintain independent state. + +**WASM loading.** The WASM binary is Brotli-compressed and Z85-encoded in a +generated module. It is prepared lazily when the WASM backend is selected and +instantiated per Term or Input with fresh memory. -**WASM loading.** The WASM binary is inlined as a base64-encoded string in a -generated module and instantiated per Term or Input with fresh memory. +**Native loading.** The Node.js adapter loads a `.node` shared library and owns +per-instance allocations. Packed directives and raw input bytes cross the +boundary through byte buffers; native pointers are not exposed to callers. Text +measurement calls C directly rather than calling back into JavaScript. -**Memory layout.** WASM linear memory is initialized with 256 pages (16MB). The -renderer state struct and the transfer buffer are allocated in WASM linear -memory. The specific layout is an implementation detail. +**Memory layout.** Renderer state and the transfer buffer occupy backend-owned +memory. Pointer widths, alignment, allocation sizes, and struct layouts follow +the target ABI; WASM offsets MUST NOT be treated as native addresses. -**Capability state.** `RuntimeCapabilities` is currently held in TypeScript and -folded by `update()`; the WASM renderer state carries no capability fields -because no renderer output depends on them yet (Β§7.8). When a -capability-consuming feature lands, the authoritative state is expected to move -into WASM linear memory alongside the renderer state, so that `render()` reads -it without per-frame transfer; `term.capabilities` remains a frozen snapshot -decoded on access. +**Capability state.** TypeScript folds capability events in `update()`. The +renderer holds the capability flags it consumes in per-instance C state; those +flags are updated on capability changes and restored after resize, rather than +being transferred on every frame. **Layout engine.** The underlying layout engine is Clay, included as a dependency. Clay provides flexbox-like layout computation with support for diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 6093ba9..4bf55ca 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -50,7 +50,7 @@ event stream. **`Capabilities`.** A plain TypeScript interface holding the static capability state of one terminal, resolved at detection time. Frozen after `detectTerminal` -returns. No WASM backing. +returns. No backend-owned backing memory. **`TerminalInfo`.** The value returned by `detectTerminal()`. Carries the frozen `Capabilities`, the probe query batch (`probe`), and the raw terminfo @@ -151,8 +151,8 @@ directions: a probe denial overrides a statically-set capability. `detectTerminal()` without touching any stream. The host writes the bytes. `detectTerminal()` resolves β€” never rejects β€” on timeout, non-TTY streams, a missing, oversized, or malformed terminfo file, or abort. Environmental -conditions degrade to the Β§7.1 baseline. The only rejection is caller error -(Β§10.1). +conditions degrade to the Β§7.1 baseline. Rejections are limited to caller error +(Β§10.1) and backend loading or initialization failure (Β§8). **TINV-5. Immediate update output.** `term.update()` returns a `Uint8Array` of bytes to write now. It MUST NOT defer output to the next `render()` call. An @@ -404,9 +404,16 @@ responses (TINV-3). _This section is normative._ -Parsing is implemented in C and exported from the wasm module. It is internal: -`detectTerminal()` is the only caller, and nothing below is part of the public -API. +Parsing is implemented in C and exposed through WASM or a Node-API addon. +Node.js prefers a matching packaged addon and uses WASM when none is packaged. A +matching addon that fails to load or initialize rejects detection. Other +runtimes use WASM. Native detection does not prepare or instantiate WASM. +Results own their byte data independently of backend allocations and remain +valid after parser cleanup. Both backends produce identical capability values, +probe bytes, and key tables for the same inputs. + +The C parsing interface is internal: `detectTerminal()` is the only caller, and +nothing below is part of the public API. ```c int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *out); @@ -515,8 +522,8 @@ type KeyTable = Uint8Array; `detectTerminal()`: 1. Rejects with a `RangeError` if `entry` is provided and exceeds - `MAX_TERMINFO_ENTRY` bytes. This is the only rejection: it reports invalid - caller input, not an environmental condition. + `MAX_TERMINFO_ENTRY` bytes. This reports invalid caller input. Backend + loading or initialization failures also reject (Β§8). 2. Locates and reads the compiled terminfo entry for the terminal (unless raw bytes are provided via `entry`), following the ncurses search path: `$TERMINFO`, `$HOME/.terminfo`, `$TERMINFO_DIRS` (empty entry = compiled-in @@ -529,7 +536,8 @@ type KeyTable = Uint8Array; `keys`. 4. Applies environment evidence (Β§7.2) from the injectable `env`. 5. Constructs `probe` without performing any IO. -6. Resolves the `TerminalInfo`. Apart from step 1, it never rejects. +6. Resolves the `TerminalInfo`. Apart from step 1 and backend loading or + initialization failure, it never rejects. Every environmental dependency is injectable (`env`, `entry`), making the function fully testable without a TTY or real terminfo files. diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 3387d39..b01e9c0 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -147,7 +147,7 @@ caller has no way to know that a transition it triggered is still in progress. ### 4.5 Boundary preservation Transition configuration MUST be fully serializable. No function pointers, -closures, or callback registries cross the TSβ†’WASM boundary during a render +closures, or callback registries cross the TSβ†’engine boundary during a render transaction. This preserves INV-2 (single transaction per frame): one binary buffer in, one @@ -171,7 +171,7 @@ deterministic rendering possible for tests and snapshots. **INV-T3. No callbacks across the boundary.** Transition configuration MUST be fully serializable. No function pointers, closures, or callback registries cross -the TSβ†’WASM boundary during a render transaction. +the TSβ†’engine boundary during a render transaction. **INV-T4. Identity is drawn from element IDs.** Transition state is associated with elements by their declared `id`. Callers using transitions on an element @@ -474,12 +474,11 @@ state. Multiple Terms created in the same process remain isolated. ### 12.4 Resolving the active Term inside the handler -Clay's transition-handler signature does not carry a `userData` pointer or -element ID. Each `reduce()` call records the currently-active Term pointer in a -module-level variable (`ct_active_context`) and clears it at the end. The -handler reads this variable to reach the Term's `animating_count`. A single -render pass cannot overlap with another (renders are synchronous), so there is -no concurrency concern. +Transition handlers resolve their owning Term through per-instance context. +Backend integration MUST preserve this ownership when Clay invokes a handler. +Sharing a native addon across Terms or Node.js workers MUST NOT cause a handler +to read or mutate another Term's animation state. Any layout-library execution +that cannot run concurrently MUST be serialized by the backend. --- diff --git a/src/clayterm.c b/src/clayterm.c index dea1c84..8333638 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -1,6 +1,6 @@ -/* clayterm.c β€” WASM terminal rendering engine for Clay UI +/* clayterm.c β€” terminal rendering engine for Clay UI * - * Public API (exported to WASM): + * Host entry points: * clayterm_size β€” compute arena size for given dimensions * init β€” initialize a Clayterm instance in provided memory * reduce β€” decode command buffer, run Clay layout, render to ANSI @@ -13,21 +13,13 @@ #include "clayterm.h" #include "transitions.h" -#include "../clay/clay.h" +#include "clay.h" #include "buffer.h" #include "cell.h" #include "mem.h" #include "utf8.h" #include "wcwidth.h" -/* Module-level pointer to the Term currently executing reduce(). - * Set/cleared around each render pass so transition handlers (which Clay - * invokes with no userData β€” see Clay_TransitionCallbackArguments) can - * report back to the right Term's animating_count. Revisit once - * nicbarker/clay#603 lands userData on transition callbacks; then the - * handler can resolve its Term from args directly and this can go away. */ -struct Clayterm *ct_active_context = NULL; - /* ── Command buffer protocol ──────────────────────────────────────── */ #define OP_BEGIN_LAYOUT 0x01 @@ -66,6 +58,7 @@ typedef struct { struct Clayterm { int w, h; + Clay_Context *context; Cell *front; Cell *back; Buffer out; @@ -151,7 +144,8 @@ static void cursor_hover(Clay_ElementId id, Clay_PointerData pointer, void *shape) { (void)id; (void)pointer; - ct_active_context->hover_shape = (int)(uintptr_t)shape; + struct Clayterm *ct = Clay_GetCurrentContext()->errorHandler.userData; + ct->hover_shape = (int)(uintptr_t)shape; } /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -734,10 +728,10 @@ int error_message_length(struct Clayterm *ct, int index) { return ct->errors[index].errorText.length; } -int error_message_ptr(struct Clayterm *ct, int index) { +const char *error_message_ptr(struct Clayterm *ct, int index) { if (index < 0 || index >= ct->error_count) return 0; - return (int)ct->errors[index].errorText.chars; + return ct->errors[index].errorText.chars; } struct Clayterm *init(void *mem, int w, int h) { @@ -752,10 +746,12 @@ struct Clayterm *init(void *mem, int w, int h) { int clay_bytes = align64((int)Clay_MinMemorySize()); Clay_Arena arena = Clay_CreateArenaWithCapacityAndMemory(clay_bytes, clay_mem); - Clay_Initialize(arena, (Clay_Dimensions){(float)w, (float)h}, - (Clay_ErrorHandler){clay_error, ct}); + Clay_Context *context = + Clay_Initialize(arena, (Clay_Dimensions){(float)w, (float)h}, + (Clay_ErrorHandler){clay_error, ct}); *ct = (struct Clayterm){ + .context = context, .w = w, .h = h, .front = (Cell *)base, @@ -795,7 +791,7 @@ void set_pointer_shape(struct Clayterm *ct, int shape) { void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime, float px, float py, int pointer) { int i = 0; - ct_active_context = ct; + Clay_SetCurrentContext(ct->context); ct->error_count = 0; ct->animating_count = 0; ct->caret_text_chars = NULL; @@ -1126,8 +1122,6 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, buf_str(&ct->out, POINTER_SHAPES[ct->pointer_shape]); buf_str(&ct->out, "\x1b\\"); } - - ct_active_context = NULL; } char *output(struct Clayterm *ct) { return ct->out.data; } @@ -1159,19 +1153,20 @@ int pointer_over_id_string_length(int index) { return ids.internalArray[index].stringId.length; } -int pointer_over_id_string_ptr(int index) { +const char *pointer_over_id_string_ptr(int index) { Clay_ElementIdArray ids = Clay_GetPointerOverIds(); if (index >= ids.length) return 0; - return (int)ids.internalArray[index].stringId.chars; + return ids.internalArray[index].stringId.chars; } -void measure(int ret, int txt) { - /* Read Clay_StringSlice from txt address. - * Clay_StringSlice layout: { int32_t length, const char *chars, ... } - * We only need length and chars. */ - int32_t slen = *(int32_t *)txt; - const char *chars = *(const char **)(txt + 4); +static Clay_Dimensions measure_text(Clay_StringSlice text, + Clay_TextElementConfig *config, + void *userData) { + (void)config; + (void)userData; + int32_t slen = text.length; + const char *chars = text.chars; int w = 0; const char *p = chars; @@ -1194,8 +1189,12 @@ void measure(int ret, int txt) { rem -= n; } - /* Write Clay_Dimensions { float width, float height } to ret */ - float *dims = (float *)ret; - dims[0] = (float)w; - dims[1] = 1.0f; + return (Clay_Dimensions){(float)w, 1.0f}; +} + +#ifdef CLAY_WASM +void measure(int ret, int txt) { + *(Clay_Dimensions *)(uintptr_t)ret = + measure_text(*(Clay_StringSlice *)(uintptr_t)txt, NULL, NULL); } +#endif diff --git a/src/clayterm.h b/src/clayterm.h index 27069f1..e296fa7 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -1,4 +1,4 @@ -/* clayterm.h β€” WASM terminal rendering engine for Clay UI */ +/* clayterm.h β€” terminal rendering engine for Clay UI */ #ifndef CLAYTERM_H #define CLAYTERM_H @@ -13,7 +13,7 @@ struct Clayterm; #define RENDER_CAP_SYNC_OUTPUT (1u << 0) #define RENDER_CAP_POINTER_SHAPE (1u << 1) -/* WASM exports */ +/* Host entry points */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); int set_capabilities(struct Clayterm *ct, uint32_t caps); @@ -24,12 +24,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); +#ifdef CLAY_WASM void measure(int ret, int txt); +#endif int get_element_bounds(const char *name, int name_len, float *out); int pointer_over_count(void); int pointer_over_id_string_length(int index); -int pointer_over_id_string_ptr(int index); +const char *pointer_over_id_string_ptr(int index); #endif diff --git a/src/mem.c b/src/mem.c index 3a0895a..33435cf 100644 --- a/src/mem.c +++ b/src/mem.c @@ -2,6 +2,7 @@ #include "mem.h" +#ifdef CLAY_WASM void *memcpy(void *dst, const void *src, size_t n) { unsigned char *d = (unsigned char *)dst; const unsigned char *s = (const unsigned char *)src; @@ -24,4 +25,6 @@ size_t strlen(const char *s) { return n; } +#endif + int align8(int n) { return (n + 7) & ~7; } diff --git a/src/module.c b/src/module.c index 5438e13..bdeb3bc 100644 --- a/src/module.c +++ b/src/module.c @@ -1,6 +1,6 @@ -/* module.c β€” single compilation unit for clayterm WASM module */ +/* module.c β€” single compilation unit for the C engine */ -#include "../clay/clay.h" +#include "clay.h" #include "mem.c" #include "terminfo.c" diff --git a/src/node.c b/src/node.c new file mode 100644 index 0000000..4402691 --- /dev/null +++ b/src/node.c @@ -0,0 +1,411 @@ +/* Node-API adapter. The engine stays synchronous and performs no terminal IO. + */ +#include +#include +#include +#include + +#include "module.c" + +static const napi_type_tag NODE_STATE_TAG = {0x96ff9182c1a7bd30, + 0x3745288fba210c46}; + +#define TRANSFER_BYTES (1024 * 1024 + 8192 * 120) + +/* The JavaScript ArrayBuffer owns the arena. A handle retains its current + * buffer; output views retain older buffers across resize and handle GC. */ +typedef struct { + int kind; + char *arena; + size_t capacity; + int transfer; + struct Clayterm *term; + struct InputState *input; + napi_ref buffer; +} NodeState; + +#define CHECK(call) \ + do { \ + if ((call) != napi_ok) { \ + bool pending = false; \ + napi_is_exception_pending(env, &pending); \ + if (!pending) \ + napi_throw_error(env, NULL, "Node-API operation failed: " #call); \ + return NULL; \ + } \ + } while (0) + +static napi_value number(napi_env env, double n) { + napi_value value; + CHECK(napi_create_double(env, n, &value)); + return value; +} + +static void finalize_state(napi_env env, void *data, void *hint) { + (void)hint; + NodeState *state = data; + if (state->buffer) + napi_delete_reference(env, state->buffer); + free(state); +} + +static napi_value allocate(napi_env env, napi_value object, NodeState *state, + size_t capacity) { + napi_value buffer; + void *arena; + CHECK(napi_create_arraybuffer(env, capacity, &arena, &buffer)); + napi_ref reference; + CHECK(napi_create_reference(env, buffer, 1, &reference)); + CHECK(napi_set_named_property(env, object, "buffer", buffer)); + if (state->buffer) + CHECK(napi_delete_reference(env, state->buffer)); + state->buffer = reference; + state->arena = arena; + state->capacity = capacity; + return buffer; +} + +static napi_value resize(napi_env env, napi_value object, NodeState *state, + double width, double height) { + /* Keep all engine int-sized arithmetic representable before calling C. */ + if (!isfinite(width) || !isfinite(height) || width < 1 || height < 1 || + width != floor(width) || height != floor(height) || + width * height > (INT_MAX - 128 * 1024 * 1024) / + (2 * sizeof(Cell) + OUT_BYTES_PER_CELL)) { + napi_throw_range_error(env, NULL, + "terminal dimensions exceed native capacity"); + return NULL; + } + uint32_t caps = state->term ? state->term->caps : 0; + int shape = state->term ? pointer_shape(state->term) : 0; + /* Do not let sizing or initialization inherit another Term's context. */ + Clay_SetCurrentContext(NULL); + int size = clayterm_size((int)width, (int)height); + int transfer = align8(size); + size_t capacity = (size_t)transfer + TRANSFER_BYTES; + if (capacity > state->capacity) { + if (!allocate(env, object, state, capacity)) + return NULL; + } + state->term = init(state->arena, (int)width, (int)height); + state->transfer = transfer; + Clay_SetMeasureTextFunction(measure_text, state->term); + set_capabilities(state->term, caps); + set_pointer_shape(state->term, shape); + Clay_SetCurrentContext(NULL); + CHECK( + napi_set_named_property(env, object, "transfer", number(env, transfer))); + return object; +} + +static napi_value create(napi_env env, napi_callback_info info) { + napi_value args[4]; + size_t argc = 4; + void *kind; + CHECK(napi_get_cb_info(env, info, &argc, args, NULL, &kind)); + double a, b; + if (argc < 2) { + napi_throw_type_error(env, NULL, "native creation requires arguments"); + return NULL; + } + CHECK(napi_get_value_double(env, args[0], &a)); + CHECK(napi_get_value_double(env, args[1], &b)); + NodeState *state = calloc(1, sizeof(NodeState)); + if (!state) { + napi_throw_error(env, NULL, "native state allocation failed"); + return NULL; + } + state->kind = (int)(uintptr_t)kind; + napi_value object; + napi_status status = napi_create_object(env, &object); + if (status != napi_ok) { + free(state); + CHECK(status); + } + status = napi_wrap(env, object, state, finalize_state, NULL, NULL); + if (status != napi_ok) { + free(state); + CHECK(status); + } + CHECK(napi_type_tag_object(env, object, &NODE_STATE_TAG)); + if (state->kind == 1) + return resize(env, object, state, a, b); + + if (argc != 3 || !isfinite(a) || a < 0 || a > INT_MAX || !isfinite(b) || + b < 0 || b > INT_MAX) { + napi_throw_range_error(env, NULL, "invalid native input configuration"); + return NULL; + } + napi_typedarray_type type; + size_t key_len, offset; + void *keys; + napi_value buffer; + CHECK(napi_get_typedarray_info(env, args[2], &type, &key_len, &keys, &buffer, + &offset)); + if (type != napi_uint8_array || key_len > INT_MAX) { + napi_throw_range_error(env, NULL, "invalid terminfo key table"); + return NULL; + } + int transfer = 16 + input_size(); + if (!allocate(env, object, state, (size_t)transfer + SCAN_BUFFER_SIZE)) + return NULL; + state->input = + input_init(state->arena + 16, (int)a, keys, (int)key_len, (int)b); + state->transfer = transfer; + CHECK( + napi_set_named_property(env, object, "transfer", number(env, transfer))); + CHECK(napi_set_named_property(env, object, "eventSize", + number(env, sizeof(struct InputEvent)))); + return object; +} + +enum { + RESIZE, + REDUCE, + OUTPUT, + LENGTH, + ANIMATING, + ERROR_COUNT, + ERROR_TYPE, + ERROR_MESSAGE, + POINTER_IDS, + BOUNDS, + CAPS, + SCAN, + COUNT, + EVENT, + DELAY +}; + +static napi_value call(napi_env env, napi_callback_info info) { + napi_value args[8]; + size_t argc = 8; + void *operation; + CHECK(napi_get_cb_info(env, info, &argc, args, NULL, &operation)); + if (!argc) { + napi_throw_type_error(env, NULL, "native call requires a handle"); + return NULL; + } + bool tagged; + CHECK(napi_check_object_type_tag(env, args[0], &NODE_STATE_TAG, &tagged)); + if (!tagged) { + napi_throw_type_error(env, NULL, "invalid native handle"); + return NULL; + } + NodeState *state; + CHECK(napi_unwrap(env, args[0], (void **)&state)); + /* Public output exposes the backing ArrayBuffer. Unlike Wasm memory, a + * regular ArrayBuffer can be detached or transferred by JavaScript. Never + * dereference arena pointers after that happens, even through an old info + * object or a resize call. No user code runs during an engine transaction. */ + napi_value buffer; + void *arena; + size_t capacity; + CHECK(napi_get_reference_value(env, state->buffer, &buffer)); + CHECK(napi_get_arraybuffer_info(env, buffer, &arena, &capacity)); + if (arena != state->arena || capacity != state->capacity) { + napi_throw_error(env, NULL, "native memory buffer was detached"); + return NULL; + } + int op = (int)(uintptr_t)operation; + if ((op >= SCAN ? 2 : 1) != state->kind) { + napi_throw_type_error(env, NULL, "incorrect native handle kind"); + return NULL; + } + if (op == RESIZE) { + double w, h; + if (argc != 3) { + napi_throw_type_error(env, NULL, "resize requires dimensions"); + return NULL; + } + CHECK(napi_get_value_double(env, args[1], &w)); + CHECK(napi_get_value_double(env, args[2], &h)); + return resize(env, args[0], state, w, h); + } + size_t expected = (op == REDUCE) ? 8 + : (op == SCAN) ? 3 + : (op == BOUNDS || op == CAPS || op == ERROR_TYPE || + op == ERROR_MESSAGE || op == EVENT) + ? 2 + : 1; + if (argc != expected) { + napi_throw_type_error(env, NULL, "incorrect native argument count"); + return NULL; + } + /* All layout operations restore and clear the upstream current context. + * Clay's native routing state is thread-local, so workers cannot race. */ + if (state->term) + Clay_SetCurrentContext(state->term->context); + napi_value result; + double n[7] = {0}; + if (op != BOUNDS) { + for (size_t i = 1; i < argc; i++) { + CHECK(napi_get_value_double(env, args[i], &n[i - 1])); + if (!isfinite(n[i - 1])) { + napi_throw_range_error(env, NULL, "native argument must be finite"); + return NULL; + } + } + } + switch (op) { + case REDUCE: + if (argc != 8 || n[0] < 0 || n[0] > TRANSFER_BYTES / 4 || + n[0] != floor(n[0])) { + napi_throw_range_error(env, NULL, "invalid packed frame length"); + return NULL; + } + reduce(state->term, (uint32_t *)(state->arena + state->transfer), (int)n[0], + (int)n[1], (int)n[2], (float)n[3], (float)n[4], (float)n[5], + (int)n[6]); + CHECK(napi_get_undefined(env, &result)); + break; + case OUTPUT: + result = number(env, output(state->term) - state->arena); + break; + case LENGTH: + result = number(env, length(state->term)); + break; + case ANIMATING: + result = number(env, animating(state->term)); + break; + case ERROR_COUNT: + result = number(env, error_count(state->term)); + break; + case ERROR_TYPE: + result = number(env, error_type(state->term, (int)n[0])); + break; + case ERROR_MESSAGE: { + const char *message = error_message_ptr(state->term, (int)n[0]); + CHECK(napi_create_string_utf8(env, message ? message : "", + error_message_length(state->term, (int)n[0]), + &result)); + break; + } + case CAPS: + result = number(env, set_capabilities(state->term, (uint32_t)n[0])); + break; + case POINTER_IDS: { + int count = pointer_over_count(); + CHECK(napi_create_array(env, &result)); + uint32_t index = 0; + for (int i = 0; i < count; i++) { + int len = pointer_over_id_string_length(i); + if (!len) + continue; + napi_value id; + CHECK(napi_create_string_utf8(env, pointer_over_id_string_ptr(i), len, + &id)); + CHECK(napi_set_element(env, result, index++, id)); + } + break; + } + case BOUNDS: { + size_t len; + CHECK(napi_get_value_string_utf8(env, args[1], NULL, 0, &len)); + if (len > TRANSFER_BYTES - 1) { + napi_throw_range_error(env, NULL, "element id exceeds native capacity"); + return NULL; + } + char *name = state->arena + state->transfer; + CHECK(napi_get_value_string_utf8(env, args[1], name, len + 1, &len)); + float box[4]; + if (!get_element_bounds(name, (int)len, box)) { + CHECK(napi_get_undefined(env, &result)); + break; + } + CHECK(napi_create_object(env, &result)); + const char *names[] = {"x", "y", "width", "height"}; + for (int i = 0; i < 4; i++) + CHECK( + napi_set_named_property(env, result, names[i], number(env, box[i]))); + break; + } + case SCAN: + if (argc != 3 || n[0] < 0 || n[0] > SCAN_BUFFER_SIZE || + n[0] != floor(n[0])) { + napi_throw_range_error(env, NULL, "invalid native scan length"); + return NULL; + } + int accepted = input_scan(state->input, state->arena + state->transfer, + (int)n[0], n[1]); + int32_t *metadata = (int32_t *)state->arena; + metadata[0] = input_count(state->input); + struct InputEvent *first = input_event(state->input, 0); + metadata[1] = first ? (int)((char *)first - state->arena) : 0; + metadata[2] = input_delay(state->input); + result = number(env, accepted); + break; + case COUNT: + result = number(env, input_count(state->input)); + break; + case EVENT: { + struct InputEvent *event = input_event(state->input, (int)n[0]); + result = number(env, event ? (char *)event - state->arena : 0); + break; + } + case DELAY: + result = number(env, input_delay(state->input)); + break; + default: + napi_throw_error(env, NULL, "unknown native operation"); + return NULL; + } + if (state->term) + Clay_SetCurrentContext(NULL); + return result; +} + +static napi_value parse_terminfo(napi_env env, napi_callback_info info) { + napi_value arg; + size_t argc = 1; + CHECK(napi_get_cb_info(env, info, &argc, &arg, NULL, NULL)); + napi_typedarray_type type; + size_t len, offset; + void *bytes; + napi_value buffer; + CHECK(napi_get_typedarray_info(env, arg, &type, &len, &bytes, &buffer, + &offset)); + if (type != napi_uint8_array || len > 32768) { + napi_throw_range_error(env, NULL, "terminfo entry exceeds native capacity"); + return NULL; + } + struct TermInfo ti; + terminfo_init(&ti); + int status = len ? terminfo_parse(bytes, (int)len, &ti) : 1; + napi_value result; + CHECK(napi_create_array_with_length(env, 3, &result)); + CHECK(napi_set_element(env, result, 0, number(env, status))); + CHECK(napi_set_element(env, result, 1, number(env, ti.colors))); + CHECK(napi_set_element(env, result, 2, number(env, ti.flags))); + return result; +} + +NAPI_MODULE_INIT() { +#define METHOD(name, fn, op) \ + { \ + name, NULL, fn, NULL, NULL, NULL, napi_default, (void *)(uintptr_t)(op) \ + } + const napi_property_descriptor methods[] = { + METHOD("createTerm", create, 1), + METHOD("createInput", create, 2), + METHOD("resize", call, RESIZE), + METHOD("reduce", call, REDUCE), + METHOD("output", call, OUTPUT), + METHOD("length", call, LENGTH), + METHOD("animating", call, ANIMATING), + METHOD("errorCount", call, ERROR_COUNT), + METHOD("errorType", call, ERROR_TYPE), + METHOD("errorMessage", call, ERROR_MESSAGE), + METHOD("pointerIds", call, POINTER_IDS), + METHOD("bounds", call, BOUNDS), + METHOD("caps", call, CAPS), + METHOD("scan", call, SCAN), + METHOD("count", call, COUNT), + METHOD("event", call, EVENT), + METHOD("delay", call, DELAY), + METHOD("terminfo", parse_terminfo, 0), + }; + CHECK(napi_define_properties(env, exports, + sizeof(methods) / sizeof(methods[0]), methods)); + return exports; +} diff --git a/src/transitions.c b/src/transitions.c index 7c6837b..1059c3c 100644 --- a/src/transitions.c +++ b/src/transitions.c @@ -1,8 +1,6 @@ #include "transitions.h" #include "clayterm.h" -extern struct Clayterm *ct_active_context; - static float clampf(float v, float lo, float hi) { if (v < lo) { return lo; @@ -83,8 +81,9 @@ static bool apply(Clay_TransitionCallbackArguments args, float eased, (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, args.target.borderWidth.betweenChildren, eased); } - if (ct_active_context && !done) { - ct_active_context->animating_count++; + struct Clayterm *ct = Clay_GetCurrentContext()->errorHandler.userData; + if (ct && !done) { + ct->animating_count++; } return done; } diff --git a/src/transitions.h b/src/transitions.h index 4a68e43..3f1ab13 100644 --- a/src/transitions.h +++ b/src/transitions.h @@ -2,7 +2,7 @@ #define CLAYTERM_TRANSITIONS_H #include -#include "../clay/clay.h" +#include "clay.h" #define CT_EASING_LINEAR 0 #define CT_EASING_EASE_IN 1 diff --git a/tasks/build-native.ts b/tasks/build-native.ts new file mode 100644 index 0000000..6984f73 --- /dev/null +++ b/tasks/build-native.ts @@ -0,0 +1,90 @@ +import { dirname, resolve } from "node:path"; +import { nativeFilename } from "../native-platform.ts"; + +// Keep upstream Clay's context routing and callback slots isolated per worker. +// Overflow sentinels are private to each worker too. Term state itself stays +// on its arena; every native call selects its context. +let clay = await Deno.readTextFile("build/clay/clay.h"); +for ( + let declaration of [ + "Clay_Context *Clay__currentContext;", + "int32_t Clay__defaultMaxElementCount = 8192;", + "int32_t Clay__defaultMaxMeasureTextWordCacheCount = 16384;", + "Clay_Dimensions (*Clay__MeasureText)", + "Clay_Vector2 (*Clay__QueryScrollOffset)", + "typeName typeName##_DEFAULT = CLAY__DEFAULT_STRUCT;", + "Clay__Warning CLAY__WARNING_DEFAULT = CLAY__DEFAULT_STRUCT;", + ] +) { + if (!clay.includes(declaration)) { + throw new Error(`Clay declaration changed: ${declaration}`); + } + clay = clay.replace(declaration, `_Thread_local ${declaration}`); +} +await Deno.mkdir("build/native-clay", { recursive: true }); +await Deno.writeTextFile("build/native-clay/clay.h", clay); + +let node = new Deno.Command("node", { + args: [ + "-p", + `JSON.stringify({execPath:process.execPath,platform:process.platform,arch:process.arch,libc:process.platform==='linux'?(process.report.getReport().header.glibcVersionRuntime?'glibc':'musl'):undefined})`, + ], + stdout: "piped", +}); +let result = await node.output(); +if (!result.success) throw new Error("Node.js is required to build the addon"); +let target = JSON.parse(new TextDecoder().decode(result.stdout)); +let execPath: string = target.execPath; +let filename = nativeFilename(target); +let include = Deno.env.get("NODE_INCLUDE_DIR") ?? + resolve(dirname(execPath), "../include/node"); +if (!await Deno.stat(`${include}/node_api.h`).catch(() => undefined)) { + throw new Error( + "Node headers missing; set NODE_INCLUDE_DIR to their directory", + ); +} +await Deno.mkdir("native", { recursive: true }); +let args = [ + "-std=c11", + "-O2", + "-fPIC", + "-fvisibility=hidden", + "-shared", + "-DCLAY_IMPLEMENTATION", + "-DCLAY_DEBUG_MODE_ENABLED=0", + "-DNAPI_VERSION=8", + `-I${include}`, + "-Ibuild/native-clay", + "-Isrc", + "-I.", + "src/node.c", + "-o", + `native/${filename}`, +]; +if (Deno.build.os === "darwin") args.push("-undefined", "dynamic_lookup"); +else if (Deno.build.os === "linux") args.push("-lm"); +else throw new Error("The local native build supports macOS and Linux"); +let compiler = Deno.env.get("CC") ?? "clang"; +let build = await new Deno.Command(compiler, { + args, + stdout: "inherit", + stderr: "inherit", +}).output(); +if (!build.success) Deno.exit(build.code); +let version = await new Deno.Command(compiler, { + args: ["--version"], + stdout: "piped", +}).output(); +await Deno.writeTextFile( + "build/native-build.json", + JSON.stringify( + { + compiler: new TextDecoder().decode(version.stdout).split("\n")[0], + flags: args, + target, + }, + null, + 2, + ) + "\n", +); +console.log(`Built native/${filename}`); diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index bae171d..23d191e 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -4,7 +4,8 @@ const outDir = "./build/npm"; await emptyDir(outDir); -const [version] = Deno.args; +const [version, ...flags] = Deno.args; +const test = flags.includes("--test"); if (!version) { throw new Error("a version argument is required to build the npm package"); } @@ -13,10 +14,31 @@ await build({ entryPoints: ["./mod.ts"], outDir, shims: { - deno: false, + deno: test ? "dev" : false, }, scriptModule: false, - test: false, + test, + rootTestDir: "./test", + mappings: test ? { "./test/suite.ts": "./test/suite-node.ts" } : {}, + async postBuild() { + if ( + await Deno.stat("native").catch((error) => { + if (error instanceof Deno.errors.NotFound) return undefined; + throw error; + }) + ) { + let directory = Deno.readDir("native"); + await Deno.mkdir(`${outDir}/esm/native`, { recursive: true }); + for await (let entry of directory) { + if (entry.isFile && entry.name.endsWith(".node")) { + await Deno.copyFile( + `native/${entry.name}`, + `${outDir}/esm/native/${entry.name}`, + ); + } + } + } + }, typeCheck: false, compilerOptions: { lib: ["ESNext"], @@ -26,7 +48,7 @@ await build({ name: "@bomb.sh/tty", version, description: - "A terminal rendering backend for Clay, compiled to WebAssembly", + "A terminal rendering backend for Clay with native Node.js and WebAssembly backends", license: "MIT", repository: { type: "git", @@ -39,7 +61,7 @@ await build({ node: ">= 22", }, sideEffects: false, - type: "module", + type: test ? "commonjs" : "module", }, }); diff --git a/term-native.ts b/term-native.ts index 62f11b3..ef78ecc 100644 --- a/term-native.ts +++ b/term-native.ts @@ -40,7 +40,7 @@ export const POINTER_UP = 1; export const POINTER_DOWN = 2; export interface Native { - memory: WebAssembly.Memory; + memory: { readonly buffer: ArrayBufferLike }; statePtr: number; opsBuf: number; /** @@ -77,13 +77,45 @@ export interface Native { errorMessage(ct: number, index: number): string; } -import { compiled } from "./wasm.ts"; +import { nodeAddon } from "./node-addon.ts"; export async function createTermNative( w: number, h: number, caps: RenderCapabilities, ): Promise { + let addon = nodeAddon(); + if (addon) { + let handle = addon.createTerm(w, h); + let flags = (caps: RenderCapabilities) => + (caps.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | + (caps.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0); + addon.caps(handle, flags(caps)); + return { + memory: { + get buffer() { + return handle.buffer; + }, + }, + statePtr: 0, + get opsBuf() { + return handle.transfer; + }, + update: (w, h) => addon.resize(handle, w, h), + setCapabilities: (caps) => addon.caps(handle, flags(caps)) !== 0, + reduce: (_ct, _buf, len, mode, row, dt, px, py, pointer) => + addon.reduce(handle, len, mode, row, dt, px, py, pointer), + output: () => addon.output(handle), + length: () => addon.length(handle), + animating: () => addon.animating(handle), + errorCount: () => addon.errorCount(handle), + errorType: (_ct, i) => addon.errorType(handle, i), + errorMessage: (_ct, i) => addon.errorMessage(handle, i), + getPointerOverIds: () => addon.pointerIds(handle), + getElementBounds: (id) => addon.bounds(handle, id), + }; + } + let { compiled } = await import("./wasm.ts"); let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; diff --git a/terminfo.ts b/terminfo.ts index f00175b..8406d58 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import process from "node:process"; -import { compiled } from "./wasm.ts"; +import { nodeAddon } from "./node-addon.ts"; import { offsets, struct, uint32 } from "./typedef.ts"; export const MAX_TERMINFO_ENTRY = 32768; @@ -140,8 +140,8 @@ function rgbOf(packed: number): Rgb { /** * Detect the terminal's capabilities from the compiled terminfo entry and * process environment. Pure: performs no IO beyond reading the terminfo file. - * Rejects only when `entry` exceeds MAX_TERMINFO_ENTRY (caller error); - * environmental failures resolve to the baseline. See specs/terminfo-spec.md. + * Oversized caller entries and backend initialization failures reject; + * environmental detection failures resolve to the baseline. See specs/terminfo-spec.md. */ export async function detectTerminal( options: DetectOptions = {}, @@ -159,66 +159,27 @@ export async function detectTerminal( bytes = await loadTerminfo({ term: options.term, env }); } - let memory = new WebAssembly.Memory({ initial: 2 }); - let instance = await WebAssembly.instantiate(compiled, { - env: { memory }, - clay: { - measureTextFunction() {}, - queryScrollOffsetFunction(ret: number) { - let v = new DataView(memory.buffer); - v.setFloat32(ret, 0, true); - v.setFloat32(ret + 4, 0, true); - }, - }, - }); - - let exports = instance.exports as unknown as { - __heap_base: WebAssembly.Global; - terminfo_size(): number; - terminfo_init(mem: number): number; - terminfo_parse(bytes: number, len: number, ti: number): number; - terminfo_grant(ti: number, flags: number): void; - }; - - let top = ((exports.__heap_base.value as number) + 7) & ~7; - function alloc(size: number): number { - let ptr = top; - top += (size + 7) & ~7; - let pages = Math.ceil(top / WASM_PAGE_BYTES); - let current = memory.buffer.byteLength / WASM_PAGE_BYTES; - if (pages > current) memory.grow(pages - current); - return ptr; - } - - let structPtr = alloc(exports.terminfo_size()); - exports.terminfo_init(structPtr); - - let keys: KeyTable = new Uint8Array(0); - if (bytes) { - let bytesPtr = alloc(MAX_TERMINFO_ENTRY); - new Uint8Array(memory.buffer).set(bytes, bytesPtr); - if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) { - keys = bytes.slice(); - } - } - - let colorterm = env.COLORTERM; - if (colorterm === "truecolor" || colorterm === "24bit") { - exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); + let addon = nodeAddon(); + let parsed; + if (addon) { + let [status, colors, flags] = addon.terminfo(bytes ?? new Uint8Array()); + parsed = { + colors, + flags, + keys: status === 0 ? bytes!.slice() : new Uint8Array(), + }; + } else { + parsed = await parseWasm(bytes); } - - /* Environment evidence (terminfo-spec Β§7.2): a running tmux consumes - * DEC mode 2026 from applications inside it, so its presence is - * positive sync-output evidence even for tmux releases that do not - * answer the DECRPM probe. */ - if (env.TMUX) { - exports.terminfo_grant(structPtr, FLAG_SYNC); + let { colors, keys } = parsed; + let flags = parsed.flags; + if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") { + flags |= FLAG_TRUECOLOR; } - - let view = new DataView(memory.buffer); - let flags = view.getUint32(structPtr + TI.flags, true); + // A running tmux is positive synchronized-output evidence (Β§7.2). + if (env.TMUX) flags |= FLAG_SYNC; let capabilities: Capabilities = Object.freeze({ - colors: view.getUint32(structPtr + TI.colors, true), + colors, trueColor: !!(flags & FLAG_TRUECOLOR), bce: !!(flags & FLAG_BACK_COLOR_ERASE), autoMargin: !!(flags & FLAG_AUTO_RIGHT_MARGIN), @@ -325,3 +286,58 @@ async function loadTerminfo( /* Re-export Rgb helper for consumers that need it. */ export { rgbOf }; + +async function parseWasm( + bytes?: Uint8Array, +): Promise<{ colors: number; flags: number; keys: KeyTable }> { + let { compiled } = await import("./wasm.ts"); + let memory = new WebAssembly.Memory({ initial: 2 }); + let instance = await WebAssembly.instantiate(compiled, { + env: { memory }, + clay: { + measureTextFunction() {}, + queryScrollOffsetFunction(ret: number) { + let v = new DataView(memory.buffer); + v.setFloat32(ret, 0, true); + v.setFloat32(ret + 4, 0, true); + }, + }, + }); + + let exports = instance.exports as unknown as { + __heap_base: WebAssembly.Global; + terminfo_size(): number; + terminfo_init(mem: number): number; + terminfo_parse(bytes: number, len: number, ti: number): number; + terminfo_grant(ti: number, flags: number): void; + }; + + let top = ((exports.__heap_base.value as number) + 7) & ~7; + function alloc(size: number): number { + let ptr = top; + top += (size + 7) & ~7; + let pages = Math.ceil(top / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; + if (pages > current) memory.grow(pages - current); + return ptr; + } + + let structPtr = alloc(exports.terminfo_size()); + exports.terminfo_init(structPtr); + + let keys: KeyTable = new Uint8Array(0); + if (bytes) { + let bytesPtr = alloc(MAX_TERMINFO_ENTRY); + new Uint8Array(memory.buffer).set(bytes, bytesPtr); + if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) { + keys = bytes.slice(); + } + } + + let view = new DataView(memory.buffer); + return { + colors: view.getUint32(structPtr + TI.colors, true), + flags: view.getUint32(structPtr + TI.flags, true), + keys, + }; +} diff --git a/test/native-renderer.node.ts b/test/native-renderer.node.ts new file mode 100644 index 0000000..5efd2ac --- /dev/null +++ b/test/native-renderer.node.ts @@ -0,0 +1,109 @@ +/** Node-only renderer invariants; run after `make native` with + * `node --expose-gc --test test/native-renderer.node.ts`. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isMainThread, parentPort, Worker } from "node:worker_threads"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, open, text } from "../ops.ts"; +import { print } from "./print.ts"; +import { nodeAddon } from "../node-addon.ts"; + +assert.ok(nodeAddon(), "build the native addon before running native tests"); +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const trim = (s: string) => + s.split("\n").map((line) => line.trimEnd()).join("\n"); +const frame = (value: string) => [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" as const }, + }), + open("label", { layout: { width: fixed(8), height: fixed(1) } }), + text(value), + close(), + close(), +]; + +async function exercise() { + let a = await createTerm({ width: 12, height: 2 }); + let b = await createTerm({ width: 80, height: 24 }); + let width = 12; + for (let i = 0; i < 100; i++) { + let ansi = decode(a.render(frame("AδΈ­e\u0301"), { mode: "line" }).output); + assert.equal(trim(print(ansi, width, 2)), "AδΈ­e\u0301\n"); + b.render(frame("other"), { + deltaTime: 0.02, + pointer: { x: 1, y: 0, down: false }, + }); + assert.equal( + a.render(frame("AδΈ­e\u0301")).info.get("label")?.bounds.width, + 8, + ); + width = 12 + (i % 2); + a.update([{ type: "resize", width, height: 2 }]); + } +} + +if (!isMainThread) { + await exercise(); + parentPort!.postMessage("ok"); +} else { + test("native Terms keep layout and buffers isolated", exercise); + test("native Terms remain isolated across Node workers", async () => { + await Promise.all( + Array.from({ length: 4 }, () => + new Promise((resolve, reject) => { + let worker = new Worker(new URL(import.meta.url), { execArgv: [] }); + worker.once("error", reject); + worker.once("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`worker exited ${code}`))); + })), + ); + }); + test("native output retains its allocation after resize and Term collection", async () => { + let retained: Uint8Array; + let copy: Uint8Array; + { + let term = await createTerm({ width: 12, height: 2 }); + retained = term.render(frame("retained"), { mode: "line" }).output; + copy = retained.slice(); + term.update([{ type: "resize", width: 160, height: 60 }]); + term.render(frame("replacement")); + } + let gc = (globalThis as typeof globalThis & { gc?: () => void }).gc; + assert.ok(gc, "run this test with --expose-gc"); + for (let i = 0; i < 5; i++) { + gc(); + await new Promise((resolve) => setImmediate(resolve)); + } + // The public validity window has ended; retaining a view must still be + // memory-safe. An old allocation is not reused after a growing resize. + assert.deepEqual(retained, copy); + }); + test("detached native output cannot leave usable native pointers", async () => { + let term = await createTerm({ width: 12, height: 2 }); + let result = term.render(frame("native")); + let buffer = result.output.buffer; + assert.ok(buffer instanceof ArrayBuffer); + structuredClone(buffer, { transfer: [buffer] }); + assert.throws(() => result.info.get("label"), /detached/); + assert.throws( + () => term.update([{ type: "resize", width: 20, height: 3 }]), + /detached/, + ); + }); + test("native rendering does not prepare Wasm", async () => { + let original = WebAssembly.compile; + WebAssembly.compile = () => { + throw new Error("unexpected Wasm compilation"); + }; + try { + let term = await createTerm({ width: 12, height: 2 }); + let ansi = decode(term.render(frame("native"), { mode: "line" }).output); + assert.equal(trim(print(ansi, 12, 2)), "native\n"); + } finally { + WebAssembly.compile = original; + } + }); +} diff --git a/test/suite-node.ts b/test/suite-node.ts new file mode 100644 index 0000000..231674b --- /dev/null +++ b/test/suite-node.ts @@ -0,0 +1,5 @@ +// Load the package entry point, including dnt's runtime polyfills, before the +// Node test runner imports internal modules directly. +import "../mod.ts"; +export { beforeEach, describe, it } from "node:test"; +export { expect } from "@std/expect";