Skip to content
Merged
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
36 changes: 23 additions & 13 deletions runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,41 @@ the counterpart of the Rust `comline-runtime` crate.

## Status

First cut: the framing-agnostic **contract** plus a JSON codec. No `Framing`,
`Transport`, `Client`, or `Server` yet — those, and a generator that emits a
`<Proto>Client` / dispatcher against this package, follow.
The contract, two framings, an in-memory transport, and a `Client` / `Server`.
Next: a generator that emits a `<Proto>Client` / dispatcher against this
package, and a stream transport.

| Piece | State |
|---|---|
| `RuntimeError`, `Kind` / `resolveKind`, `Call`, `Envelope`, `Outcome`, `Reply`, `CallError` | ✅ |
| `RuntimeError`, `Kind` / `resolveKind`, `Call`, `Envelope`, `Outcome`, `Reply`, `CallError`, `Dispatch` | ✅ |
| `Codec` interface + `JsonCodec` (`name === "json"`) | ✅ |
| `Handshake` — 31-byte frame, FNV-1a `nameHash`, `check` — byte-compatible with `comline-runtime` | ✅ |
| `Dispatch` interface | ✅ (shape only) |
| `Framing` (datagram + JSON-RPC), `Transport`, `Client`, `Server` | — |
| `Framing` + `DatagramFraming` + `JsonRpcFraming` — wire-compatible with `comline-runtime` | ✅ |
| `Transport` interface + `duplex()` in-memory pair | ✅ |
| `Client` (`connect` / `call` / `notify`) and `Server` (`serve` / `serveHandshaked`) | ✅ |
| A stream `Transport`; a MessagePack `Codec` | — |

The `Handshake` wire format and `nameHash` are cross-checked against
The `Handshake` and framing wire formats are cross-checked against
`comline-runtime`'s reference vectors, so a TypeScript peer and a Rust peer
generated from the same schema negotiate the same frame.
generated from the same schema negotiate the same frame and speak the same
request / response bytes.

## Layout

```
src/
contract.ts RuntimeError, Kind, Call, Envelope, Outcome, Reply, CallError, Codec, Dispatch
handshake.ts Handshake, nameHash, FRAMING_DATAGRAM
codec.ts JsonCodec
index.ts public surface
test/ node:test, zero runtime deps
contract.ts RuntimeError, Kind, Call, Envelope, Reply, CallError, Codec, Dispatch, Framing
handshake.ts Handshake, nameHash, FRAMING_DATAGRAM
codec.ts JsonCodec
envelope.ts the datagram tag-byte Envelope form
framing/
datagram.ts DatagramFraming
jsonrpc.ts JsonRpcFraming
transport.ts Transport, duplex()
client.ts Client
server.ts Server
index.ts public surface
test/ node:test, zero runtime deps
```

## Develop
Expand Down
61 changes: 61 additions & 0 deletions runtime/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import type { Call, Codec, Envelope, Framing } from "./contract.js";
import { RuntimeError } from "./contract.js";
import { Handshake } from "./handshake.js";
import type { Transport } from "./transport.js";
import { DatagramFraming } from "./framing/datagram.js";

/**
* The consumer side. Frames a call, sends it, waits for the matching response,
* and hands the generated stub the raw {@link Envelope} to decode. Generic over
* the {@link Framing}; defaults to {@link DatagramFraming}.
*/
export class Client {
private nextId = 0n;

constructor(
private readonly transport: Transport,
readonly codec: Codec,
readonly framing: Framing = new DatagramFraming(),
) {}

/**
* Bind and run the connection {@link Handshake}: send `local`, read the
* peer's, refuse (`RuntimeError("handshake")`) on a mismatch.
*/
static async connect(
transport: Transport,
codec: Codec,
local: Handshake,
framing: Framing = new DatagramFraming(),
): Promise<Client> {
await transport.send(local.encode());
const peer = Handshake.decode(await transport.recv());
if (!peer) throw RuntimeError.handshake();
local.check(peer);
return new Client(transport, codec, framing);
}

/** Make `call` with `params`, block for the response, return its {@link Envelope}. */
async call(call: Call, params: unknown): Promise<Envelope> {
const requestId = this.nextId++;
await this.transport.send(
this.framing.encodeRequest(call, requestId, this.codec.encode(params)),
);
const res = this.framing.decodeResponse(await this.transport.recv());
if (!res) throw RuntimeError.framing();
if (res.requestId !== requestId) throw RuntimeError.framing();
return res.envelope;
}

/** Fire-and-forget: send the call, expect no response (a one-way function). */
async notify(call: Call, params: unknown): Promise<void> {
const requestId = this.nextId++;
await this.transport.send(
this.framing.encodeRequest(call, requestId, this.codec.encode(params)),
);
}
}
34 changes: 34 additions & 0 deletions runtime/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,37 @@ export interface Dispatch {
calls(): readonly string[];
dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise<void>;
}

/** Whichever call address a framing put on the wire. */
export type RequestCall = { readonly id: number } | { readonly name: string };

/** A decoded request frame. */
export interface DecodedRequest {
readonly call: RequestCall;
readonly requestId: bigint;
/** The params sub-frame, independently decodable with the peer's {@link Codec}. */
readonly params: Uint8Array;
}

/** A decoded response frame: the correlation id and its {@link Envelope}. */
export interface DecodedResponse {
readonly requestId: bigint;
readonly envelope: Envelope;
}

/**
* How a call becomes bytes and back — the axis orthogonal to {@link Codec}
* (which serializes the *parts*). {@link DatagramFraming} is the default;
* {@link JsonRpcFraming} is the name-oriented alternative. `params` / `payload`
* / `body` arrive already {@link Codec}-encoded; the framing only positions
* them. Both ends of a connection must agree — the {@link Handshake} carries
* `name`, hashed.
*/
export interface Framing {
readonly name: string;
encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array;
decodeRequest(frame: Uint8Array): DecodedRequest | undefined;
encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array;
encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array;
decodeResponse(frame: Uint8Array): DecodedResponse | undefined;
}
43 changes: 43 additions & 0 deletions runtime/src/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
* The tag-byte {@link Envelope} form the Comline datagram framing wraps a
* response body in — `[0] payload` for ok, `[1] id:u16 LE body` for err.
* Matches `comline_runtime::contract::Envelope`.
*/

import type { Envelope } from "./contract.js";

const TAG_OK = 0;
const TAG_ERR = 1;

export function encodeEnvelopeOk(payload: Uint8Array): Uint8Array {
const out = new Uint8Array(1 + payload.length);
out[0] = TAG_OK;
out.set(payload, 1);
return out;
}

export function encodeEnvelopeErr(id: number, body: Uint8Array): Uint8Array {
const out = new Uint8Array(3 + body.length);
out[0] = TAG_ERR;
out[1] = id & 0xff;
out[2] = (id >> 8) & 0xff;
out.set(body, 3);
return out;
}

export function decodeEnvelope(frame: Uint8Array): Envelope | undefined {
const tag = frame[0];
if (tag === TAG_OK) {
return { ok: frame.subarray(1) };
}
if (tag === TAG_ERR) {
if (frame.length < 3) return undefined;
const id = frame[1]! | (frame[2]! << 8);
return { err: { id, body: frame.subarray(3) } };
}
return undefined;
}
60 changes: 60 additions & 0 deletions runtime/src/framing/datagram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import type { Call, DecodedRequest, DecodedResponse, Framing } from "../contract.js";
import { FRAMING_DATAGRAM } from "../handshake.js";
import { decodeEnvelope, encodeEnvelopeErr, encodeEnvelopeOk } from "../envelope.js";

const HEAD = 10; // [call_id:u16][request_id:u64], both LE

/**
* The Comline datagram framing — compact, one frame per message. Matches
* `comline_runtime::contract::DatagramFraming`:
* request `[call_id:u16 LE][request_id:u64 LE][params]`,
* response `[request_id:u64 LE][envelope]`.
*/
export class DatagramFraming implements Framing {
readonly name = FRAMING_DATAGRAM;

encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array {
const out = new Uint8Array(HEAD + params.length);
const view = new DataView(out.buffer);
view.setUint16(0, call.id, true);
view.setBigUint64(2, requestId, true);
out.set(params, HEAD);
return out;
}

decodeRequest(frame: Uint8Array): DecodedRequest | undefined {
if (frame.length < HEAD) return undefined;
const view = new DataView(frame.buffer, frame.byteOffset, HEAD);
return {
call: { id: view.getUint16(0, true) },
requestId: view.getBigUint64(2, true),
params: frame.subarray(HEAD),
};
}

encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array {
return withRequestId(requestId, encodeEnvelopeOk(payload));
}

encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array {
return withRequestId(requestId, encodeEnvelopeErr(id, body));
}

decodeResponse(frame: Uint8Array): DecodedResponse | undefined {
if (frame.length < 8) return undefined;
const requestId = new DataView(frame.buffer, frame.byteOffset, 8).getBigUint64(0, true);
const envelope = decodeEnvelope(frame.subarray(8));
return envelope && { requestId, envelope };
}
}

function withRequestId(requestId: bigint, rest: Uint8Array): Uint8Array {
const out = new Uint8Array(8 + rest.length);
new DataView(out.buffer).setBigUint64(0, requestId, true);
out.set(rest, 8);
return out;
}
80 changes: 80 additions & 0 deletions runtime/src/framing/jsonrpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

import type { Call, DecodedRequest, DecodedResponse, Framing } from "../contract.js";

const enc = new TextEncoder();
const dec = new TextDecoder();

/** JSON bytes of a value, or `null` for an empty sub-frame. */
function jsonBytes(sub: unknown): Uint8Array {
return enc.encode(sub === undefined ? "null" : JSON.stringify(sub));
}

/**
* [JSON-RPC 2.0](https://www.jsonrpc.org/specification) framing — name-oriented,
* human-readable. Pair with {@link JsonCodec}. Matches
* `comline_runtime::framing::JsonRpcFraming`:
*
* - request: `{"jsonrpc":"2.0","method":<name>,"params":<params>,"id":<n>}`
* - ok: `{"jsonrpc":"2.0","result":<r>,"id":<n>}`
* - err: `{"jsonrpc":"2.0","error":{"code":<ordinal>,"message":...,"data":<body>},"id":<n>}`
*
* `params` / `payload` / `body` arrive as already-encoded JSON bytes and are
* spliced in verbatim.
*/
export class JsonRpcFraming implements Framing {
readonly name = "jsonrpc-2.0";

encodeRequest(call: Call, requestId: bigint, params: Uint8Array): Uint8Array {
const p = params.length === 0 ? "null" : dec.decode(params);
return enc.encode(
`{"jsonrpc":"2.0","method":${JSON.stringify(call.name)},"params":${p},"id":${requestId}}`,
);
}

decodeRequest(frame: Uint8Array): DecodedRequest | undefined {
let r: { method?: unknown; params?: unknown; id?: unknown };
try {
r = JSON.parse(dec.decode(frame));
} catch {
return undefined;
}
if (typeof r.method !== "string") return undefined;
return {
call: { name: r.method },
requestId: r.id === undefined || r.id === null ? 0n : BigInt(r.id as number),
params: jsonBytes(r.params),
};
}

encodeResponseOk(requestId: bigint, payload: Uint8Array): Uint8Array {
const r = payload.length === 0 ? "null" : dec.decode(payload);
return enc.encode(`{"jsonrpc":"2.0","result":${r},"id":${requestId}}`);
}

encodeResponseErr(requestId: bigint, id: number, body: Uint8Array): Uint8Array {
const d = body.length === 0 ? "null" : dec.decode(body);
return enc.encode(
`{"jsonrpc":"2.0","error":{"code":${id},"message":"application error","data":${d}},"id":${requestId}}`,
);
}

decodeResponse(frame: Uint8Array): DecodedResponse | undefined {
let r: { result?: unknown; error?: { code?: unknown; data?: unknown }; id?: unknown };
try {
r = JSON.parse(dec.decode(frame));
} catch {
return undefined;
}
const requestId = r.id === undefined || r.id === null ? 0n : BigInt(r.id as number);
if (r.error && typeof r.error === "object") {
return {
requestId,
envelope: { err: { id: Number(r.error.code ?? 0), body: jsonBytes(r.error.data) } },
};
}
return { requestId, envelope: { ok: jsonBytes(r.result) } };
}
}
20 changes: 18 additions & 2 deletions runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

/**
* `@comline/runtime` — the TypeScript runtime that Comline-generated RPC
* bindings link against. This first cut is the framing-agnostic contract plus
* a JSON codec; `Framing`, `Transport`, `Client`, and `Server` follow.
* bindings link against. The framing-agnostic contract, two framings, an
* in-memory transport, and a `Client` / `Server`. The generator emitting a
* `<Proto>Client` / dispatcher against this package, and a stream transport,
* follow.
*/

export {
Expand All @@ -21,6 +23,10 @@ export {
type CallError,
type Codec,
type Dispatch,
type RequestCall,
type DecodedRequest,
type DecodedResponse,
type Framing,
} from "./contract.js";

export {
Expand All @@ -31,3 +37,13 @@ export {
} from "./handshake.js";

export { JsonCodec } from "./codec.js";

export { encodeEnvelopeOk, encodeEnvelopeErr, decodeEnvelope } from "./envelope.js";

export { DatagramFraming } from "./framing/datagram.js";
export { JsonRpcFraming } from "./framing/jsonrpc.js";

export { type Transport, type InMemoryTransport, duplex } from "./transport.js";

export { Client } from "./client.js";
export { Server } from "./server.js";
Loading
Loading