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
6 changes: 6 additions & 0 deletions examples/web/rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use rustls::{DigitallySignedStruct, SignatureScheme};
use tokio::sync::{Notify, RwLock};
use tokio::task::JoinSet;
use tokio::{select, signal};
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
use tracing::{debug, error, info, instrument, trace, warn};
use tracing_subscriber::layer::SubscriberExt as _;
Expand Down Expand Up @@ -691,6 +692,11 @@ export const PORT = "{port}"
),
)),
)
// Serve the `@bytecodealliance/wrpc` codec the UI imports.
.nest_service(
"/wrpc",
ServeDir::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../../js/src")),
)
.fallback(index)
.layer(TraceLayer::new_for_http()),
);
Expand Down
380 changes: 80 additions & 300 deletions examples/web/ui/index.html

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions js/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
*.tgz
package-lock.json
118 changes: 118 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# `@bytecodealliance/wrpc`

The [wRPC](https://github.com/bytecodealliance/wrpc) codec for JavaScript: a
transport-agnostic encoder/decoder for the wRPC wire format. It encodes and
decodes [component model] values against a WIT type tree and frames an
invocation's parameters and results, leaving the byte transport (WebTransport,
WebSocket, a `MessagePort`, …) up to you.

It is dependency-free and ships as standard ES modules, so it runs directly in
the browser (`<script type="module">`) as well as through a bundler or Node.

[component model]: https://component-model.bytecodealliance.org

## Install

```sh
npm install @bytecodealliance/wrpc
```

## Quick start

Describe a value's type with the `t` (types) builders, then encode and decode:

```js
import { t, encode, decode } from "@bytecodealliance/wrpc";

const error = t.variant({
"no-such-store": null,
"access-denied": null,
other: t.string,
});
const ty = t.result(t.option(t.list(t.u8)), error);

const bytes = encode(ty, { tag: "ok", val: new TextEncoder().encode("hi") });
const value = decode(ty, bytes); // { tag: "ok", val: Uint8Array }
```

To invoke a function, build the request bytes with `encodeInvocation`, write
them on a transport stream, and decode the reply with `decodeResults`:

```js
import { t, encodeInvocation, decodeResults, concatBytes } from "@bytecodealliance/wrpc";

const store = "wasi:keyvalue/store@0.2.0-draft2";

// open: func(identifier: string) -> result<bucket, error>
const payload = encodeInvocation(store, "open", [t.string], [""]);

// ... write `payload` on a bidirectional byte stream and read the reply ...
const reply = concatBytes(chunks);

const [opened] = decodeResults(reply, [t.result(t.own("bucket"), error)]);
if (opened.tag === "err") throw opened.val;
const bucket = opened.val; // a Uint8Array handle
```

The framing matches the default `wrpc-transport` framing (TCP, Unix,
WebTransport, WebSocket): a single connection per invocation, with the protocol
byte, the instance and function names, and a root parameter frame on the way
out, and a root result frame on the way back.

## Value mapping

Component-model values map to JavaScript following the same conventions as
[`jco`](https://github.com/bytecodealliance/jco):

| WIT | JavaScript |
| ------------------------- | -------------------------------------------- |
| `bool` | `boolean` |
| `s8`–`s32`, `u8`–`u32` | `number` |
| `s64`, `u64` | `bigint` |
| `f32`, `f64` | `number` |
| `char`, `string` | `string` |
| `list<u8>` | `Uint8Array` |
| `list<T>` | `Array` |
| `tuple<...>` | `Array` |
| `record` | object keyed by field name |
| `option<T>` | `T \| undefined` |
| `result<O, E>` | `{ tag: "ok", val } \| { tag: "err", val }` |
| `variant` | `{ tag: caseName, val? }` |
| `enum` | `string` (the case name) |
| `flags` | `{ [name]: boolean }` |
| `own<R>` / `borrow<R>` | `Uint8Array` (the opaque handle bytes) |

## Working without static types

If a server renders its interface as inlined WIT (as the `wrpc-wasmtime` CLI
does), `parseWit` / `parseType` turn that text into the same type tree the
`t` builders produce, so you can drive the codec without generated bindings:

```js
import { parseType, encode, decode } from "@bytecodealliance/wrpc";

const ty = parseType("result<option<list<u8>>, variant { not-found, other(string) }>");
const value = decode(ty, encode(ty, { tag: "ok", val: new Uint8Array([1, 2, 3]) }));
```

## API

- `t` / `types` — type-tree builders (`bool`, `list`, `record`, `result`, …).
- `encode(ty, value)` / `decode(ty, bytes)` — single-value codec.
- `encodeValue(writer, ty, value)` / `decodeValue(reader, ty)` — streaming codec.
- `Writer` / `Reader` — LEB128 and `core:name` byte primitives.
- `parseType(text)` / `parseWit(text)` — WIT text → type tree.
- `encodeInvocation(instance, func, paramTypes, args)` — the request bytes.
- `decodeResults(bytes, resultTypes)` — the decoded results.
- `concatBytes(chunks)` — join the byte chunks a transport yields.

## Development

```sh
npm test # codec / framing / WIT round-trip tests (node --test)
npm run check # type-check the sources and declarations (tsc)
```

## License

Apache-2.0 WITH LLVM-exception
45 changes: 45 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@bytecodealliance/wrpc",
"version": "0.1.0",
"description": "Transport-agnostic, component-native wRPC client for JavaScript",
"type": "module",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://wrpc.io",
"repository": {
"type": "git",
"url": "git+https://github.com/bytecodealliance/wrpc.git",
"directory": "js"
},
"keywords": [
"wrpc",
"wit",
"rpc",
"component-model",
"webassembly",
"wasm"
],
"main": "./src/index.js",
"module": "./src/index.js",
"types": "./src/index.d.ts",
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./package.json": "./package.json"
},
"files": [
"src"
],
"sideEffects": false,
"scripts": {
"test": "node --test",
"check": "tsc"
},
"devDependencies": {
"typescript": "^5.6.0"
},
"engines": {
"node": ">=18"
}
}
93 changes: 93 additions & 0 deletions js/src/frame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// The default wRPC framing for non-multiplexed byte-stream transports (the same
// framing `wrpc-transport`'s `frame` module implements, used by TCP, Unix,
// WebSocket and WebTransport).
//
// A client writes, on the connection's root stream:
// - the framing protocol byte `0x00`,
// - the `core:name`-encoded instance and function names,
// - the root frame `[path-len=0][data-len][params]`, whose data is the
// concatenated encoding of the parameters.
//
// The server replies with the concatenated encoding of the results in its own
// root frame `[path-len=0][data-len][results]`.
//
// This module covers the synchronous root channel only — the indexed
// sub-streams used for async params/results (`stream`, `future`) are not
// handled here.

import { Reader, Writer } from "./io.js";
import { encodeValue, decodeValue } from "./value.js";

/** @typedef {import("./index.d.ts").Type} Type */

/** The wRPC framing protocol version byte. */
export const PROTOCOL = 0x00;

/**
* Build the bytes a client writes to invoke `func` on `instance`: the protocol
* byte, the instance and function names, and the root parameter frame.
* @param {string} instance
* @param {string} func
* @param {Type[]} paramTypes
* @param {any[]} args
* @returns {Uint8Array}
*/
export function encodeInvocation(instance, func, paramTypes, args) {
if (args.length !== paramTypes.length) {
throw new RangeError(`expected ${paramTypes.length} argument(s), got ${args.length}`);
}
const params = new Writer();
paramTypes.forEach((ty, i) => encodeValue(params, ty, args[i]));
const paramBytes = params.finish();

const w = new Writer();
w.u8(PROTOCOL);
w.name(instance);
w.name(func);
w.u8(0); // root frame path length
w.varU(paramBytes.length); // root frame data length
w.bytes(paramBytes);
return w.finish();
}

/**
* Decode the server's root result frame `[path-len=0][data-len][data]` into the
* result values. A function with no results writes no frame, so an empty input
* is expected (and returns `[]`) only when no results are expected. If results
* are expected but the input is empty, the peer closed the stream without
* responding (e.g. the invocation failed server-side) and an error is thrown.
* @param {Uint8Array} bytes
* @param {Type[]} resultTypes
* @returns {any[]}
*/
export function decodeResults(bytes, resultTypes) {
if (resultTypes.length === 0) return [];
if (bytes.length === 0) {
throw new Error(
"peer closed the stream without sending a result frame " +
"(the invocation likely failed on the other end)",
);
}
const frame = new Reader(bytes);
const pathLen = Number(frame.varU());
if (pathLen !== 0) throw new Error(`unexpected result frame path length ${pathLen}`);
const dataLen = Number(frame.varU());
const data = new Reader(frame.take(dataLen));
return resultTypes.map((ty) => decodeValue(data, ty));
}

/**
* Concatenate a list of byte chunks into a single `Uint8Array`.
* @param {Uint8Array[]} chunks
*/
export function concatBytes(chunks) {
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
104 changes: 104 additions & 0 deletions js/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Type definitions for the `wrpc` package.

/** A structural wRPC/WIT type. */
export type Type =
| { kind: "bool" | "s8" | "u8" | "s16" | "u16" | "s32" | "u32" | "s64" | "u64" | "f32" | "f64" | "char" | "string" }
| { kind: "list"; elem: Type }
| { kind: "option"; some: Type }
| { kind: "result"; ok: Type | null; err: Type | null }
| { kind: "tuple"; elems: Type[] }
| { kind: "record"; fields: { name: string; ty: Type }[] }
| { kind: "variant"; cases: { name: string; ty: Type | null }[] }
| { kind: "enum"; names: string[] }
| { kind: "flags"; names: string[] }
| { kind: "own" | "borrow"; name: string };

/** The JavaScript representation of a decoded component-model value. */
export type Value =
| boolean
| number
| bigint
| string
| Uint8Array
| Value[]
| { [field: string]: Value }
| { tag: string; val?: Value }
| undefined;

// ----- types.js -------------------------------------------------------------

export namespace types {
const bool: Type;
const s8: Type;
const u8: Type;
const s16: Type;
const u16: Type;
const s32: Type;
const u32: Type;
const s64: Type;
const u64: Type;
const f32: Type;
const f64: Type;
const char: Type;
const string: Type;
function list(elem: Type): Type;
function option(some: Type): Type;
function result(ok?: Type | null, err?: Type | null): Type;
function tuple(...elems: Type[]): Type;
function record(fields: Record<string, Type> | { name: string; ty: Type }[]): Type;
function variant(cases: Record<string, Type | null> | { name: string; ty: Type | null }[]): Type;
function enumType(names: string[]): Type;
export { enumType as enum };
function flags(names: string[]): Type;
function own(name: string): Type;
function borrow(name: string): Type;
}

export { types as t };

// ----- io.js ----------------------------------------------------------------

export class Writer {
constructor();
u8(b: number): void;
bytes(bytes: Uint8Array | number[]): void;
varU(value: number | bigint): void;
varS(value: number | bigint): void;
name(str: string): void;
finish(): Uint8Array;
}

export class Reader {
constructor(bytes: Uint8Array);
readonly done: boolean;
bytes: Uint8Array;
pos: number;
u8(): number;
take(n: number): Uint8Array;
varU(): bigint;
varS(): bigint;
name(): string;
}

// ----- value.js -------------------------------------------------------------

export function encode(ty: Type, v: Value): Uint8Array;
export function decode(ty: Type, bytes: Uint8Array): Value;
export function encodeValue(w: Writer, ty: Type, v: Value): void;
export function decodeValue(r: Reader, ty: Type): Value;

// ----- wit.js ---------------------------------------------------------------

export function parseType(text: string): Type;
export function parseWit(text: string): {
package: string | null;
interface: string;
funcs: { name: string; params: { name: string; ty: Type }[]; results: Type[] }[];
};

// ----- frame.js -------------------------------------------------------------

export const PROTOCOL: number;
export function encodeInvocation(instance: string, func: string, paramTypes: Type[], args: Value[]): Uint8Array;
export function decodeResults(bytes: Uint8Array, resultTypes: Type[]): Value[];
export function concatBytes(chunks: Uint8Array[]): Uint8Array;
Loading
Loading