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
21 changes: 21 additions & 0 deletions .github/actions/setup-emsdk/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Setup Emscripten
description: Install Emscripten SDK
inputs:
version:
description: Emscripten Version
required: false
default: 'latest'
runs:
using: composite
steps:
- name: Run setup script
shell: bash
run: bash ci/setup/install_emsdk.sh ${{ inputs.version }}

- name: Inject Global Environment
shell: bash
run: |
EMSDK_DIR="${GITHUB_WORKSPACE}/.emsdk"
source "${EMSDK_DIR}/emsdk_env.sh" > /dev/null 2>&1
env | grep '^EMSDK' >> $GITHUB_ENV
echo "$PATH" | tr ':' '\n' | grep "${EMSDK_DIR}" >> $GITHUB_PATH
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,67 @@ jobs:
working-directory: bindings/node
run: npm test

cpp_wasm:
name: C++ (Emscripten, ${{ matrix.build_type }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
build_type: [ debug, release ]
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Environment
uses: ./.github/actions/setup-tachyon-env
with:
lang: 'cpp'

- name: Setup Emscripten
uses: ./.github/actions/setup-emsdk

- name: Configure
run: cmake --preset emscripten-${{ matrix.build_type }}

- name: Build
run: cmake --build --preset emscripten-${{ matrix.build_type }}

- name: Check WASM artefacts
run: |
ls -lh build/emscripten-${{ matrix.build_type }}/core/libtachyon.a
ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.js
ls -lh build/emscripten-${{ matrix.build_type }}/core/tachyon.wasm

nodejs_browser_wasm:
name: Browser WASM tests
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
cache-dependency-path: bindings/node/package-lock.json

- name: Install dependencies
working-directory: bindings/node
run: npm install --ignore-scripts

- name: Check Chromium
run: /usr/bin/chromium --version

# Do not run build:wasm here. The generated WASM module is committed; CI
# validates the committed artifact (test:browser stages it into dist via
# build:ts) rather than regenerating it and masking a stale-artifact diff.
- name: Browser WASM tests
working-directory: bindings/node
env:
CHROMIUM_BIN: /usr/bin/chromium
run: npm run test:browser

csharp:
name: C# Bindings (${{ matrix.runner }})
runs-on: ${{ matrix.runner }}
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,5 @@ Testing/
crash-
.private/
oss-fuzz/

.emsdk/
examples/browser_wasm/pkg/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pip install tachyon-ipc
npm install @tachyon-ipc/core
```

The same npm package also includes a browser WASM build for bundlers. Browser code keeps the same
`import { Bus } from '@tachyon-ipc/core'` shape; bundlers that honor the package `browser` field resolve to the
page-local WASM transport automatically.

**Java (Maven):**

```xml
Expand Down
1 change: 1 addition & 0 deletions bindings/node/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/ts/wasm/
44 changes: 44 additions & 0 deletions bindings/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ compiled from source at installation time via `cmake-js`.
- [Requirements](#requirements)
- [Install](#install)
- [Quickstart](#quickstart)
- [Browser WASM](#browser-wasm)
- [API](#api)
- [Zero-copy pattern](#zero-copy-pattern)
- [Batch pattern](#batch-pattern)
Expand Down Expand Up @@ -48,6 +49,9 @@ Clang 17+ must be available on the build machine.

The package ships as **ESM** (`"type": "module"`). CommonJS consumers must use dynamic `import()`.

Browser bundlers that honor the package `browser` field resolve `@tachyon-ipc/core` to the WASM browser build. Node.js
continues to resolve the native N-API entrypoint through the existing `main` and `types` fields.

## Quickstart

The consumer must start first, it owns the UNIX socket and the SHM arena.
Expand All @@ -72,6 +76,46 @@ bus.send(Buffer.from('hello tachyon'), 1);
`Bus` implements `Disposable`. The `using` keyword (TypeScript 5.2+, ES2023 Explicit Resource Management) guarantees
`close()` is called on scope exit regardless of exceptions.

## Browser WASM

The browser build is shipped from the same npm package and keeps the same import and constructor shape:

```typescript
import {Bus} from '@tachyon-ipc/core';

using consumer = Bus.listen('/page/demo', 1 << 20);
using producer = Bus.connect('/page/demo');

producer.send(new Uint8Array([1, 2, 3, 4]), 7);
const {data, typeId} = consumer.recv();
```

The browser build runs the same C++ core compiled to WebAssembly with Emscripten, so the ring engine is identical to the
native binding — there is no second implementation to keep in sync.

Browsers do not expose POSIX shared memory or UNIX sockets, so `socketPath` is a page-local endpoint key rather than a
filesystem socket. `listen()` creates the in-page WASM ring and `connect()` attaches to that ring. The message layout
still uses Tachyon's 64-byte header, `type_id`, alignment, and skip-marker rules. Capacities are capped at 2GB because
wasm32 pointers are 32-bit.

The browser implementation is intentionally direct-doorbell oriented. After JavaScript commits a message, call the WASM
work function immediately instead of scheduling a browser event or spinning in a poll loop. This avoids event-loop
latency and keeps sub-microsecond round trips possible for in-page communication. Because the browser ring is
non-blocking, `recv()` returns `null` when the ring is empty rather than throwing.

Browser differences:

- Repeated browser `connect()` calls return aliases to the same page-local ring; they are not independent subscribers,
and multiple consumers compete for the same ordered SPSC stream.
- `recv()` and `acquireRx()` are non-blocking because the main browser thread cannot park like a native futex wait.
- Browser `drainBatch()` preserves order but copies batch entries before returning, so ring slots are released
immediately; use `acquireRx()` for a direct WASM memory view.
- `setNumaNode()` and `setPollingMode()` are no-ops in browsers.
- `Buffer` is not a browser primitive; returned data is a `Uint8Array`.
- Native cross-process IPC still requires Node.js or another native binding.
- The browser build uses wasm32 for now, so WASM pointers, capacities, and slot sizes are `u32`-bounded; it can move to
wasm64/Memory64 later if a single linear-memory arena above 4 GiB becomes necessary.

## API

### Lifecycle
Expand Down
2 changes: 1 addition & 1 deletion bindings/node/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**'],
ignores: ['dist/**', 'build/**', 'node_modules/**', 'test/**', 'src/ts/wasm/**'],
},
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
Expand Down
14 changes: 11 additions & 3 deletions bindings/node/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tachyon-ipc/core",
"version": "0.5.1",
"description": "Tachyon IPC bindings for Node.js",
"description": "Tachyon IPC bindings for Node.js and the browser (WASM)",
"keywords": [
"ipc",
"shm",
Expand All @@ -17,20 +17,28 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"browser": {
"./dist/index.js": "./dist/browser.js"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "npm run build:native && npm run build:ts",
"build:native": "cmake-js compile",
"build:ts": "tsc",
"build:ts": "tsc && node scripts/stage-wasm.mjs",
"build:wasm": "npm run cmake:wasm && npm run copy:wasm",
"clean": "rm -rf build dist",
"format": "prettier --write src/ts test/",
"format:check": "prettier --check src/ts test/",
"cmake:wasm": "cd ../.. && cmake --preset emscripten-release && cmake --build --preset emscripten-release",
"copy:wasm": "node scripts/copy-wasm.mjs",
"install": "cmake-js compile",
"lint": "eslint src/ts",
"prebuild": "cmake-js compile --runtime node --runtime-version $(node -v | cut -c2-)",
"test": "mocha"
"test": "mocha",
"pretest:browser": "npm run build:ts",
"test:browser": "node test/browser_wasm.spec.mjs"
},
"dependencies": {
"node-addon-api": "^8.7.0"
Expand Down
33 changes: 33 additions & 0 deletions bindings/node/scripts/copy-wasm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

const BUILD_DIR = resolve("../../build/emscripten-release/core");
const TARGET_DIR = resolve("src/ts/wasm");

const GENERATED_HEADER = [
'/* eslint-disable */',
'// Generated by Emscripten. Do not edit by hand.',
'// Regenerate with: npm run build:wasm',
'',
].join('\n');

async function main(){
await mkdir(TARGET_DIR, { recursive: true });
await copyFile(
resolve(BUILD_DIR, "tachyon.wasm"),
resolve(TARGET_DIR, "tachyon.wasm")
);

const jsBody = await readFile(resolve(BUILD_DIR, 'tachyon.js'), 'utf8');
await writeFile(
resolve(TARGET_DIR, "tachyon.js"),
`${GENERATED_HEADER}\n${jsBody}`
);

console.log("WASM artefacts copied");
}

main().catch((err) => {
console.error("Failed to process WASM artefacts: ", err);
process.exit(1);
})
21 changes: 21 additions & 0 deletions bindings/node/scripts/stage-wasm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { copyFile, mkdir } from 'node:fs/promises';
import { resolve } from 'node:path';

// tsc only emits the TypeScript sources; the committed Emscripten module
// (tachyon.js + tachyon.wasm) is a plain asset, so copy it next to the compiled
// browser entry at dist/wasm/ where `dist/browser.js` imports it from.
const SRC_DIR = resolve('src/ts/wasm');
const DIST_DIR = resolve('dist/wasm');

async function main() {
await mkdir(DIST_DIR, { recursive: true });
for (const file of ['tachyon.js', 'tachyon.wasm']) {
await copyFile(resolve(SRC_DIR, file), resolve(DIST_DIR, file));
}
console.log('WASM artefacts staged into dist/wasm');
}

main().catch((err) => {
console.error('Failed to stage WASM artefacts: ', err);
process.exit(1);
});
18 changes: 9 additions & 9 deletions bindings/node/src/ts/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export interface BatchController {
}

/** A single message inside an {@link RxBatch}. Valid only until the batch is committed. */
export interface RxMessage {
readonly data: RxSlot;
export interface RxMessage<S extends Uint8Array = Buffer> {
readonly data: RxSlot<S>;
readonly typeId: number;
readonly size: number;
}
Expand All @@ -22,7 +22,7 @@ export interface RxMessage {
* `using` commits automatically.
*
* All `RxMessage.data` references are invalidated on commit. Any cached reference
* will throw `TypeError` (underlying ArrayBuffers are detached by the C++ side).
* will throw `TypeError` where the platform can detach the underlying ArrayBuffers.
*
* @example
* ```ts
Expand All @@ -32,13 +32,13 @@ export interface RxMessage {
* }
* ```
*/
export class RxBatch {
export class RxBatch<S extends Uint8Array = Buffer> {
#ctrl: BatchController;
#messages: RxMessage[];
#messages: RxMessage<S>[];
#done = false;

/** @internal */
public constructor(ctrl: BatchController, messages: RxMessage[]) {
public constructor(ctrl: BatchController, messages: RxMessage<S>[]) {
this.#ctrl = ctrl;
this.#messages = messages;
}
Expand All @@ -55,7 +55,7 @@ export class RxBatch {
* @throws {Error} If the batch has already been committed.
* @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR.
*/
public at(i: number): RxMessage {
public at(i: number): RxMessage<S> {
this.#assertOpen();
if (this.#ctrl.getState() === 4 /* TACHYON_STATE_FATAL_ERROR */) throw new PeerDeadError();

Expand All @@ -74,10 +74,10 @@ export class RxBatch {
*
* @throws {PeerDeadError} If the bus has transitioned to TACHYON_STATE_FATAL_ERROR.
*/
public [Symbol.iterator](): Iterator<RxMessage> {
public [Symbol.iterator](): Iterator<RxMessage<S>> {
let i = 0;
return {
next: (): IteratorResult<RxMessage> => {
next: (): IteratorResult<RxMessage<S>> => {
if (this.#done || i >= this.#messages.length) {
return { value: undefined, done: true };
}
Expand Down
Loading
Loading