Skip to content
LogoLogo

@portable/rpc

Functions

Rpc.connect

Connects to an API served on the other side of transport and returns a proxy for it. Calling a method sends a JSON-RPC request and returns a promise for the result. Pass methods to answer calls from the other side over the same transport.

Where the runtime has Symbol.asyncDispose, the proxy's [Symbol.asyncDispose]() closes the connection as aborting signal does, with an AbortError DOMException, so a remote declared with await using is closed when the scope ends.

function connect<api>(
  transport: Transport,
  options: connect.Options = {},
): Remote<api> & AsyncDisposeMethod;
OptionTypeDefaultDescription
codecCodec<any>Rpc.jsonEncodes messages for the transport.
methodsobjectMethods the other side can call over the same transport.
signalAbortSignalCloses the connection: waiting calls reject with signal.reason, and the transport is cancelled.
import * as Rpc from '@portable/rpc';
import type { Api } from './worker.ts';
 
const worker = Rpc.connect<Api>(
  Rpc.port(new Worker('./worker.ts', { type: 'module' })),
);
await worker.add(1, 2);
for await (const event of worker.watch('x')) {
  console.log(event);
}

Rpc.provide

Answers calls to methods over transport: every method, including methods of nested objects as 'name.method', can be called with JSON-RPC. A method that returns an async iterable or a ReadableStream streams its values back with backpressure.

function provide(
  transport: Transport,
  methods: object,
  options: provide.Options = {},
): void;
OptionTypeDefaultDescription
codecCodec<any>Rpc.jsonEncodes messages for the transport.
signalAbortSignalCloses the connection: waiting calls reject with signal.reason, and the transport is cancelled.
import * as Rpc from '@portable/rpc';
 
const api = {
  add: (a: number, b: number) => a + b,
  async *watch(id: string) {
    for (let i = 0; ; i++) {
      yield { id, i };
    }
  },
};
export type Api = typeof api;
 
Rpc.provide(Rpc.port(self), api);

Rpc.handler

Answers calls to methods over HTTP. The returned function answers a POST of a JSON-RPC request or batch. The response is JSON, or, when a method returns a stream, newline-delimited messages that carry the stream's values as they are produced. Methods wrapped with withContext get the Request, and their signal aborts when the client disconnects.

function handler(
  methods: object,
): (request: Request) => Promise<Response>;
import * as Rpc from '@portable/rpc';
 
const rpc = Rpc.handler(api);
export default { fetch: (request: Request) => rpc(request) };

Rpc.http

Connects to an API served by handler, or any JSON-RPC server that accepts POST requests. Calls made in the same tick are sent as one batch request. Where the runtime has Symbol.asyncDispose, the remote's [Symbol.asyncDispose]() aborts every request and rejects waiting calls with an AbortError DOMException, as aborting signal does.

function http<api>(
  url: string | URL,
  options: http.Options = {},
): Remote<api> & AsyncDisposeMethod;
OptionTypeDefaultDescription
fetch((request: Request) => Promise<Response>)globalThis.fetchThe fetch to send requests with, such as one that adds an authorization header.
signalAbortSignalAborts every request and rejects waiting calls with signal.reason.
import * as Rpc from '@portable/rpc';
import type { Api } from './server.ts';
 
const api = Rpc.http<Api>('https://example.com/rpc');
await api.add(1, 2);

Rpc.port

A transport over postMessage: a MessagePort, a Worker, a BroadcastChannel, or self inside a worker. Messages are structured-cloned, so the identity codec { encode: (m) => m, decode: (m) => m } also works and keeps Date, Map and Uint8Array values intact.

function port(
  target: port.Target,
  options: port.Options = {},
): Transport;
OptionTypeDefaultDescription
signalAbortSignalStops listening and errors the readable with signal.reason.
import * as Rpc from '@portable/rpc';
 
const { port1, port2 } = new MessageChannel();
Rpc.provide(Rpc.port(port1), { add: (a: number, b: number) => a + b });
const remote = Rpc.connect<{ add(a: number, b: number): number }>(
  Rpc.port(port2),
);
await remote.add(1, 2);

Rpc.call

Calls a remote method with explicit parameters and options. Use it to pass a signal, or to send parameters by name (an object), as MCP and LSP servers expect.

function call<fn extends (...args: any[]) => unknown>(
  method: fn,
  params: CallParams<fn>,
  options: call.Options = {},
): ReturnType<fn>;
OptionTypeDefaultDescription
signalAbortSignalCancels the call: the promise rejects with signal.reason, and the client sends $/cancel to the remote.
import * as Rpc from '@portable/rpc';
 
const sum = await Rpc.call(remote.add, [1, 2], {
  signal: AbortSignal.timeout(5000),
});
 
const mcp = Rpc.connect<any>(transport);
const { tools } = await Rpc.call(mcp['tools/list'], {});

Rpc.notify

Sends a JSON-RPC notification: a call without an id, which the remote doesn't answer. The promise resolves once the message is written.

function notify<fn extends (...args: any[]) => unknown>(
  method: fn,
  params: CallParams<fn>,
  options: notify.Options = {},
): Promise<void>;
OptionTypeDefaultDescription
signalAbortSignalRejects with signal.reason instead of sending when already aborted.
import * as Rpc from '@portable/rpc';
 
await Rpc.notify(remote.log, ['page loaded']);
await Rpc.notify(mcp['notifications/initialized'], {});

Rpc.withContext

Wraps a method so that it receives a Context as its first argument when it is called remotely. The context's signal aborts when the caller cancels the call or the connection closes. Callers see the method without the context argument.

function withContext<args extends unknown[], result>(
  fn: (context: Context, ...args: args) => result,
): (...args: args) => result;
import * as Rpc from '@portable/rpc';
 
const api = {
  search: Rpc.withContext(async ({ signal }, query: string) => {
    const response = await fetch(
      `https://example.com/search?q=${query}`,
      { signal },
    );
    return response.json();
  }),
};

Rpc.window

A transport to another window, such as an iframe or a popup. Both sides send a hello every 100 ms until the other answers, so either may start first, and writes wait until the handshake is done. The handshake has no timeout: give each call a signal to stop waiting for a window that never answers.

function window(
  target: window.Target,
  options: window.Options,
): Transport;
OptionTypeDefaultDescription
originstringThe other window's origin, such as 'https://app.example.com'. Messages from any other origin are ignored, and messages are posted only to this origin. '*' accepts and sends to any origin.
channelstring''Separates several connections to the same window. Both sides must use the same channel.
signalAbortSignalStops the handshake and listening, and errors the readable with signal.reason.
import * as Rpc from '@portable/rpc';
 
// In the parent page
const iframe = document.querySelector('iframe')!;
const child = Rpc.connect<ChildApi>(
  Rpc.window(iframe.contentWindow!, {
    origin: 'https://child.example.com',
  }),
);
 
// In the iframe
Rpc.provide(
  Rpc.window(window.parent, { origin: 'https://parent.example.com' }),
  api,
);

Constants

Rpc.json

The default codec: JSON text. It decodes text or UTF-8 bytes, so it reads binary WebSocket frames too.

const json: Codec<string | Uint8Array>;
import * as Rpc from '@portable/rpc';
import * as cbor from 'cborg';
 
Rpc.connect(transport, { codec: Rpc.json });
Rpc.connect(transport, {
  codec: { encode: cbor.encode, decode: cbor.decode },
});

Errors

Rpc.RemoteError

A JSON-RPC error. A client rejects with it when the remote answers with an error, and a method throws it to send a chosen code and data. Any other error a method throws is sent with code -32603 and its message.

class RemoteError extends Errors.BaseError {
  constructor(options: RemoteError.Options);
  readonly kind: string;
  readonly code: number;
  readonly data: unknown;
}
OptionTypeDefaultDescription
codenumber
messagestring
dataunknown
causeunknown
import * as Rpc from '@portable/rpc';
 
const api = {
  user(id: string) {
    throw new Rpc.RemoteError({
      code: 404,
      message: 'No such user',
      data: { id },
    });
  },
};
 
try {
  await remote.user('x');
} catch (error) {
  if (error instanceof Rpc.RemoteError && error.code === 404) {
    return null;
  }
  throw error;
}

Rpc.TransportError

The error that waiting calls reject with when the transport closes or fails, or when an HTTP request fails. cause holds the underlying error, which is an Errors.ResponseError for a failed HTTP response.

class TransportError extends Errors.BaseError {
  readonly kind: string;
}
import * as Rpc from '@portable/rpc';
 
try {
  await remote.save(doc);
} catch (error) {
  if (error instanceof Rpc.TransportError) {
    return retryLater(doc);
  }
  throw error;
}

Types

Rpc.Codec

Turns messages into what a transport carries, and back. decode may return anything: malformed messages are answered with JSON-RPC errors.

type Codec<encoded = unknown> = {
  encode(message: Message): encoded;
  decode(data: encoded): unknown;
};

Rpc.Context

What a method wrapped with withContext receives as its first argument.

type Context = {
  readonly signal: AbortSignal;
  readonly request?: Request | undefined;
};

Rpc.Message

A JSON-RPC 2.0 message object, or a batch of them.

type Message =
  | RequestMessage
  | NotificationMessage
  | ResponseMessage
  | readonly (RequestMessage | NotificationMessage)[]
  | readonly ResponseMessage[];

Rpc.Remote

The type of a remote API. Every method returns a promise, and a method that returns an async iterable or a ReadableStream returns a promise for a ReadableStream that can also be iterated with for await directly.

type Remote<api> =  extends  & api ? any : {
    readonly [key in keyof api]: api[key] extends (...args: infer args) => infer result ? (...args: args) => Result<Awaited<result>> : api[key] extends object ? Remote<api[key]> : never;
};

Rpc.Result

What a remote method returns for a result of type value.

type Result<value> = [value] extends [
  AsyncIterable<infer item> | ReadableStream<infer item>,
]
  ? Promise<ReadableStream<item>> & AsyncIterable<item>
  : Promise<value>;

Rpc.Transport

A pair of streams that carry encoded messages, such as a WebSocket wrapped in streams.

type Transport = {
  readonly readable: ReadableStream<unknown>;
  readonly writable: WritableStream<unknown>;
};