Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/native-node-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset should start with an approved active present-tense verb. Changeset does not name the affected public API confidently. Changeset is not sufficiently consumer-focused.

View the proposed replacement
---
'@bomb.sh/tty': minor
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

"@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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
/wasm.ts
/build/
/node_modules/

/native/
26 changes: 13 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
68 changes: 53 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>` 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

Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions bench/fixtures/backend-startup.ts
Original file line number Diff line number Diff line change
@@ -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 }));
95 changes: 95 additions & 0 deletions bench/fixtures/render-scenes.ts
Original file line number Diff line number Diff line change
@@ -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(),
];
Loading
Loading