Skip to content

Latest commit

 

History

History
635 lines (486 loc) · 17.3 KB

1-rpc.md

File metadata and controls

635 lines (486 loc) · 17.3 KB

RPC

Before reading this documentation, it is recommended to check out the "Getting started" section of the README.

Table of contents

RPC schemas

A schema defines the requests that a specific endpoint can respond to, and the messages that it can send. Since RPC Anywhere doesn't enforce a client-server architecture, each endpoint has its own schema, and both RPC instances need to "know" about the other's schema.

Schema types bring type safety to an instance, both when acting as a "client" (sending requests and listening for messages) and as a "server" (responding to requests and sending messages).

Declaring schemas

Schemas are declared with the RPCSchema<InputSchema> type, using the following structure:

import { type RPCSchema } from "rpc-anywhere";

type MySchema = RPCSchema<{
  requests: {
    requestName: {
      params: {
        /* request parameters */
      };
      response: {
        /* response content */
      };
    };
  };
  messages: {
    messageName: {
      /* message content */
    };
  };
}>;

Using schemas in RPC instances

Once you've declared your schemas, you can use them to create an RPC instance. An instance acts as the "client" that sends requests and listens for messages from the other endpoint, and as the "server" that responds to requests and sends messages to the other endpoint.

For this reason, you need to pass two schema types to createRPC: the local schema (representing this instance's capabilities), and the remote schema (representing the other endpoint's capabilities).

The local schema is the first type parameter, and the remote schema is the second type parameter.

import { createRPC } from "rpc-anywhere";

const rpc = createRPC<LocalSchema, RemoteSchema>({
  // ...
});

A typical pattern is to declare the local schema in the same file where the corresponding RPC instance is created. For example, you might end up with a file structure like this:

// rpc-a.ts
import { type SchemaB } from "./rpc-b.js";

export type SchemaA = RPCSchema</* ... */>;
const rpcA = createRPC<SchemaA, SchemaB>();

// rpc-b.ts
import { type SchemaA } from "./rpc-a.js";

export type SchemaB = RPCSchema</* ... */>;
const rpcB = createRPC<SchemaB, SchemaA>();

You might have noticed that the schema imports are circular. This is completely fine! Circular imports are only problematic for runtime values, but schemas are types which are only used for type-checking/IDE features and do not affect bundling or runtime behavior.

Schema flexibility

There is complete flexibility in the structure of the schemas. All properties can be omitted or set to void. Request parameters and message contents can be optional too. Some examples:

type MySchema = RPCSchema<{
  requests: {
    // request with optional parameters
    requestName: {
      params?: {
        direction: "up" | "down";
        velocity?: number;
      };
      response: string | number;
    };

    // request with no response
    requestName: {
      params: string;
    };

    // request with no parameters
    requestName: {
      response: [string, number];
    };

    // request with no parameters and no response
    requestName: void;
  };
  messages: {
    // message with no content
    messageName: void;

    // message with optional content
    messageName?: {
      content?: string;
    };
  };
}>;

// schema with no requests
type MySchema = RPCSchema<{
  messages: {
    messageName: void;
  };
}>;

// schema with no messages
type MySchema = RPCSchema<{
  requests: {
    requestName: void;
  };
}>;

Empty schemas

Schemas can be "empty" if one of the RPC instances does not handle requests or send messages (resembling a "pure" client/server connection). For this situation, there is a special type: EmptyRPCSchema.

type RemoteSchema = RPCSchema<{
  requests: {
    requestName: void;
  };
}>;

// rpc-local.ts (client)
const rpc = createRPC<EmptyRPCSchema, RemoteSchema>(/* ... */);
rpc.request("requestName");

// rpc-remote.ts (server)
const rpc = createRPC<RemoteSchema, EmptyRPCSchema>({
  requestHandler: {
    requestName() {
      /* ... */
    },
  },
});

Client/server RPC schemas

For convenience, createClientRPC and createServerRPC can be used to achieve the same result as in the previous section in a simpler way. They both take the remote (server) schema as a type parameter, as it is the only one that matters (the local/client one is empty).

// rpc-local.ts (client)
const rpc = createClientRPC<RemoteSchema>(/* ... */);
await rpc.request("requestName");

// rpc-remote.ts (server)
const rpc = createServerRPC<RemoteSchema>({
  requestHandler: {
    requestName() {
      /* ... */
    },
  },
});

Symmetrical RPC schemas

If both RPC endpoints are "symmetrical" (i.e. they both handle the same requests and send the same messages), you can skip the second schema type parameter:

// rpc-a.ts
const rpcA = createRPC<SymmetricalSchema>(/* ... */);

// rpc-b.ts
const rpcB = createRPC<SymmetricalSchema>(/* ... */);

In this case, the passed schema will be interpreted as both the local and remote schema.

Documenting schemas with JSDoc

Schemas support JSDoc comments in almost everything that can be defined, including:

  • Requests.
  • Request parameters.
  • Request responses.
  • Messages.
  • Message contents.

These comments are later accessible when using the RPC instances. For example, this is how a request might be documented:

type MySchema = RPCSchema<{
  requests: {
    /**
     * Move the car.
     *
     * @example
     *
     * ```
     * const result = await rpc.request.move({ direction: "left", duration: 1000 });
     * ```
     */
    move: {
      params: {
        /**
         * The direction of the movement.
         */
        direction: "left" | "right";
        /**
         * The total duration of the movement.
         */
        duration: number;
        /**
         * The velocity of the car.
         *
         * @default 100
         */
        velocity?: number;
      };
      response: {
        /**
         * The total distance traveled by the car.
         */
        distance: number;
        /**
         * The final position of the car.
         */
        position: number;
      };
    };
  };
}>;

If this example schema is used for the remote RPC endpoint, hovering over any of the symbols highlighted below (in a supported IDE, like Visual Studio Code) will show the corresponding JSDoc documentation, along with their types.

const { distance, position } = await rpc.request.move({
  //    ^         ^                              ^
  direction: "left",
  // ^
  duration: 1000,
  // ^
  velocity: 200,
  // ^
});

Transports

An RPC transport is the channel through which messages are sent and received between point A and point B. In RPC Anywhere, a transport is an object that contains the specific logic to accomplish this.

Using a built-in transport is strongly recommended. You can learn about them in the Built-in transports page.

If you can't find one that fits your use case, you can create one yourself. Learn how in the Creating a custom transport page. You can also consider filing a feature request or contributing a new built-in transport to the project.

To provide a transport to an RPC instance pass it to createRPC as the transport option, or lazily set it at a later time using the setTransport method. For example:

const rpc = createRPC<LocalSchema, RemoteSchema>({
  transport: createTransportFromMessagePort(window, iframe.contentWindow),
});

// or

const rpc = createRPC<LocalSchema, RemoteSchema>();
rpc.setTransport(createTransportFromMessagePort(window, iframe.contentWindow));

Keep in mind that if the transport is set lazily, the RPC instance will be unusable until then.

Transports can be hot-swapped by using the lazy setter, as long as the transport supports this. All built-in transports support hot-swapping. If you create a custom transport, you can add support for it by making sure that it cleans up after itself when replaced, typically by unregistering event listeners in the unregisterHandler method.

Requests

Making requests

Requests are sent using the request method:

const response = await rpc.request("requestName", {
  /* request parameters */
});

The parameters can be omitted if the request doesn't support any (or if they are optional):

const response = await rpc.request("requestName");

The request proxy API

Alternatively, you can use the request proxy API:

const response = await rpc.request.requestName({
  /* request parameters */
});

The rpc.request property acts as a function and as an object at the same time. This has an unfortunate effect: when autocompleting with TypeScript (when you type rpc.request.), some suggestions will be properties from the function JavaScript prototype (apply, bind, call...).

If you want a version that only contains the proxied methods (e.g. for a better developer experience or aliasing), you can use requestProxy instead:

const chef = chefRPC.requestProxy;
const dish = await chef.cook({ recipe: "rice" });

Request timeout

If the remote endpoint takes too long to respond to a request, it will time out and be rejected with an error. The default request timeout is 1000 milliseconds (1 second). You can change it by passing a maxRequestTime option to createRPC:

const rpc = createRPC<Schema>({
  // ...
  maxRequestTime: 5000,
});

To disable the timeout, pass Infinity. Be careful! It can lead to requests hanging indefinitely.

Handling requests

Requests are handled using the requestHandler option of createRPC. The request handler can be defined in two ways:

Object format

The object format is the recommended way to define request handlers because it is the most ergonomic, provides full type safety, and supports a "fallback" handler. All handlers can be async.

const rpc = createRPC<Schema>({
  // ...
  requestHandler: {
    requestName(/* request parameters */) {
      /* handle the request */
      return /* response */;
    },
    // or
    async requestName(/* request parameters */) {
      await doSomething();
      /* handle the request */
      return /* response */;
    },

    // fallback handler
    _(method, params) {
      /* handle requests that don't have a handler defined (not type-safe) */
      return /* response */;
    },
    // or
    async _(method, params) {
      await doSomething();
      /* handle requests that don't have a handler defined (not type-safe) */
      return /* response */;
    },
  },
});

Unless a fallback handler is defined, requests that don't have a handler defined will be rejected with an error.

Function format

The function format is useful when you need to handle requests dynamically, delegate/forward them somewhere else, etc.

This format is not type-safe, so it's recommended to use the object format instead whenever possible.

const rpc = createRPC<Schema>({
  // ...
  requestHandler(method, params) {
    /* handle the request */
    return /* response */;
  },
  // or
  async requestHandler(method, params) {
    await doSomething();
    /* handle the request */
    return /* response */;
  },
});

The request handler can be lazily set with the setRequestHandler method:

const rpc = createRPC<Schema>();
rpc.setRequestHandler(/* ... */);

Until the request handler is set, the RPC instance won't be able to handle requests.

Inferring the schema from the request handler

Defining both a "requests" schema and a request handler can be redundant. For example:

type Schema = RPCSchema<{
  requests: {
    myRequest: {
      params: { a: number; b: string };
      response: { c: boolean };
    };
  };
  messages: { myMessage: void };
}>;

const rpc = createRPC<Schema>({
  // ...
  requestHandler: {
    myRequest({ a, b }) {
      return { c: a > 0 && b.length > 0 };
    },
  },
});

To reduce duplication, RPC Anywhere provides a way to partially infer the schema type from the request handler.

To do this, first create the request handler (in object format) using createRPCRequestHandler, and then pass its type as the second type parameter by using typeof. Updating the previous example:

const myRequestHandler = createRPCRequestHandler({
  myRequest({ a, b }: { a: number; b: string }) {
    return { c: a > 0 && b.length > 0 };
  },
});

type Schema = RPCSchema<
  { messages: { myMessage: void } },
  typeof myRequestHandler
>;

const rpc = createRPC<Schema>({
  // ...
  requestHandler: myRequestHandler,
});

If there are no messages in the schema, you can pass void as the first type parameter to RPCSchema:

type Schema = RPCSchema<void, typeof myRequestHandler>;

Messages

Sending messages

Messages are sent using the send method:

rpc.send("messageName", {
  /* message content */
});

The content can be omitted if the message doesn't have any or if it's optional:

rpc.send("messageName");

Similar to requests, there is a message proxy API you can use:

rpc.send.messageName({
  /* message content */
});

// or

rpc.sendProxy.messageName({
  /* message content */
});

Listening for messages

Messages are received by adding a message listener:

rpc.addMessageListener("messageName", (messageContent) => {
  /* handle the message */
});

To listen for all messages, use the * (asterisk) key:

rpc.addMessageListener("*", (messageName, messageContent) => {
  /* handle the message */
});

A listener can be removed with the removeMessageListener method:

rpc.removeMessageListener("messageName", listener);
rpc.removeMessageListener("*", listener);

The proxy property

RPC instances also expose a proxy property, which is an object that contains both proxies (request and send). It is an alternative API provided for convenience, for example:

const rpc = createRPC<Schema>(/* ... */).proxy;
rpc.request.requestName(/* ... */);
rpc.send.messageName(/* ... */);

Recipes

Below are some common examples to help you get started with RPC Anywhere.

Client/server RPC

// server.ts
const requestHandler = createRPCRequestHandler({
  hello(name: string) {
    return `Hello, ${name}!`;
  },
});

export type ServerSchema = RPCSchema<void, typeof requestHandler>;

const rpc = createServerRPC<ServerSchema>({
  // ...
  requestHandler,
});

// client.ts
import { type ServerSchema } from "./server.js";

const rpc = createClientRPC<ServerSchema>(/* ... */).proxy.request;
const response = await rpc.hello("world");
console.log(response); // Hello, world!

Symmetrical RPC

// schema.ts
type SymmetricalSchema = RPCSchema<{
  requests: {
    hello: {
      params: { name: string };
      response: string;
    };
  };
  messages: {
    goodbye: void;
  };
}>;

// rpc-a.ts
const rpcA = createRPC<SymmetricalSchema>(/* ... */);
rpcA.addMessageListener("goodbye", () => {
  console.log("Goodbye!");
});

// rpc-b.ts
const rpcB = createRPC<SymmetricalSchema>(/* ... */);
const response = await rpcB.request.hello({ name: "world" });
console.log(response); // Hello, world!
rpcB.send.goodbye();