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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,3 +736,64 @@ let stub: RemoteMainInterface = session.getRemoteMain();
```

Note that sessions are entirely symmetric: neither side is defined as the "client" nor the "server". Each side can optionally expose a "main interface" to the other. In typical scenarios with a logical client and server, the server exposes a main interface but the client does not.

### Encoding Levels

Transports can operate at different encoding levels, controlling how messages are serialized:

| Level | Message Format | Use Case |
| --------------- | ------------------------------- | ------------------------------- |
| `"stringify"` | JSON string | HTTP batch, WebSocket (default) |
| `"devalue"` | JS object (JSON-compatible) | Custom JSON-like encoders |
| `"partial"` | JS object with raw `Uint8Array` | CBOR, MessagePack |
| `"passthrough"` | Structured-clonable object | MessagePort, `postMessage()` |

**Default behavior:** Existing code works unchanged. WebSocket and HTTP batch use `"stringify"`. MessagePort automatically uses `"passthrough"` for efficient structured cloning.

```ts
// MessagePort: Uint8Array passed directly via structured clone, no base64 overhead
const channel = new MessageChannel();
newMessagePortRpcSession(channel.port1, new FileService());
const stub = newMessagePortRpcSession<FileService>(channel.port2);
const contents = await stub.getFileContents("/path"); // Uint8Array transferred efficiently
```

**Binary encoding (CBOR/MessagePack):** Use `wrapTransport()` to add encoding at the `"partial"` level:

```ts
import { wrapTransport, RpcSession } from "capnweb";
import * as cbor from "cbor-x";

const rawTransport = createWebSocketTransport(url);
const cborTransport = wrapTransport(
rawTransport,
(msg) => cbor.encode(msg),
(data) => cbor.decode(data),
"partial" // Keeps Uint8Array raw for CBOR
);

const session = new RpcSession<MyApi>(cborTransport);
```

**Custom transports:** Declare `encodingLevel` to tell the RPC system what format you expect:

```ts
class MyBinaryTransport implements RpcTransport {
readonly encodingLevel: EncodingLevel = "partial";

async send(message: object): Promise<void> {
// message is JS object; Uint8Array values are raw, not base64
await this.connection.write(myEncoder.encode(message));
}

async receive(): Promise<object> {
return myDecoder.decode(await this.connection.read());
}
}
```

What happens to `Uint8Array([1, 2, 3])` at each level:
- `"stringify"` → `'["bytes","AQID"]'` (JSON string)
- `"devalue"` → `["bytes", "AQID"]` (JS object)
- `"partial"` → `["bytes", Uint8Array([1,2,3])]` (raw binary)
- `"passthrough"` → `["bytes", Uint8Array([1,2,3])]` (also preserves Date, BigInt, Error)
17 changes: 11 additions & 6 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

import { RpcStub } from "./core.js";
import { RpcTransport, RpcSession, RpcSessionOptions } from "./rpc.js";
import type { EncodingLevel } from "./serialize.js";
import type { IncomingMessage, ServerResponse, OutgoingHttpHeader, OutgoingHttpHeaders } from "node:http";

type SendBatchFunc = (batch: string[]) => Promise<string[]>;

class BatchClientTransport implements RpcTransport {
readonly encodingLevel: EncodingLevel = "stringify";

constructor(sendBatch: SendBatchFunc) {
this.#promise = this.#scheduleBatch(sendBatch);
}
Expand All @@ -19,16 +22,16 @@ class BatchClientTransport implements RpcTransport {
#batchToSend: string[] | null = [];
#batchToReceive: string[] | null = null;

async send(message: string): Promise<void> {
async send(message: string | object): Promise<void> {
// If the batch was already sent, we just ignore the message, because throwing may cause the
// RPC system to abort prematurely. Once the last receive() is done then we'll throw an error
// that aborts the RPC system at the right time and will propagate to all other requests.
if (this.#batchToSend !== null) {
this.#batchToSend.push(message);
this.#batchToSend.push(message as string);
}
}

async receive(): Promise<string> {
async receive(): Promise<string | object> {
if (!this.#batchToReceive) {
await this.#promise;
}
Expand Down Expand Up @@ -90,6 +93,8 @@ export function newHttpBatchRpcSession(
}

class BatchServerTransport implements RpcTransport {
readonly encodingLevel: EncodingLevel = "stringify";

constructor(batch: string[]) {
this.#batchToReceive = batch;
}
Expand All @@ -98,11 +103,11 @@ class BatchServerTransport implements RpcTransport {
#batchToReceive: string[];
#allReceived: PromiseWithResolvers<void> = Promise.withResolvers<void>();

async send(message: string): Promise<void> {
this.#batchToSend.push(message);
async send(message: string | object): Promise<void> {
this.#batchToSend.push(message as string);
}

async receive(): Promise<string> {
async receive(): Promise<string | object> {
let msg = this.#batchToReceive!.shift();
if (msg !== undefined) {
return msg;
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// https://opensource.org/license/mit

import { RpcTarget as RpcTargetImpl, RpcStub as RpcStubImpl, RpcPromise as RpcPromiseImpl } from "./core.js";
import { serialize, deserialize } from "./serialize.js";
import { RpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions } from "./rpc.js";
import { serialize, deserialize, EncodingLevel } from "./serialize.js";
import { RpcTransport, RpcSession as RpcSessionImpl, RpcSessionOptions, wrapTransport } from "./rpc.js";
import { RpcTargetBranded, RpcCompatible, Stub, Stubify, __RPC_TARGET_BRAND } from "./types.js";
import { newWebSocketRpcSession as newWebSocketRpcSessionImpl,
newWorkersWebSocketRpcResponse } from "./websocket.js";
Expand All @@ -19,8 +19,8 @@ forceInitStreams();

// Re-export public API types.
export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpcResponse,
nodeHttpBatchRpcResponse };
export type { RpcTransport, RpcSessionOptions, RpcCompatible };
nodeHttpBatchRpcResponse, wrapTransport };
export type { RpcTransport, RpcSessionOptions, RpcCompatible, EncodingLevel };

// Hack the type system to make RpcStub's types work nicely!
/**
Expand Down
18 changes: 10 additions & 8 deletions src/messageport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { RpcStub } from "./core.js";
import { RpcTransport, RpcSession, RpcSessionOptions } from "./rpc.js";
import type { EncodingLevel } from "./serialize.js";

// Start a MessagePort session given a MessagePort or a pair of MessagePorts.
//
Expand All @@ -17,6 +18,8 @@ export function newMessagePortRpcSession(
}

class MessagePortTransport implements RpcTransport {
readonly encodingLevel: EncodingLevel = "passthrough";

constructor (port: MessagePort) {
this.#port = port;

Expand All @@ -29,16 +32,15 @@ class MessagePortTransport implements RpcTransport {
} else if (event.data === null) {
// Peer is signaling that they're closing the connection
this.#receivedError(new Error("Peer closed MessagePort connection."));
} else if (typeof event.data === "string") {
} else {
// Accept any structured-clonable data
if (this.#receiveResolver) {
this.#receiveResolver(event.data);
this.#receiveResolver = undefined;
this.#receiveRejecter = undefined;
} else {
this.#receiveQueue.push(event.data);
}
} else {
this.#receivedError(new TypeError("Received non-string message from MessagePort."));
}
});

Expand All @@ -48,25 +50,25 @@ class MessagePortTransport implements RpcTransport {
}

#port: MessagePort;
#receiveResolver?: (message: string) => void;
#receiveResolver?: (message: string | object) => void;
#receiveRejecter?: (err: any) => void;
#receiveQueue: string[] = [];
#receiveQueue: (string | object)[] = [];
#error?: any;

async send(message: string): Promise<void> {
async send(message: string | object): Promise<void> {
if (this.#error) {
throw this.#error;
}
this.#port.postMessage(message);
}

async receive(): Promise<string> {
async receive(): Promise<string | object> {
if (this.#receiveQueue.length > 0) {
return this.#receiveQueue.shift()!;
} else if (this.#error) {
throw this.#error;
} else {
return new Promise<string>((resolve, reject) => {
return new Promise<string | object>((resolve, reject) => {
this.#receiveResolver = resolve;
this.#receiveRejecter = reject;
});
Expand Down
Loading