diff --git a/src/create-lite-rpc.ts b/src/create-lite-rpc.ts new file mode 100644 index 0000000..be34146 --- /dev/null +++ b/src/create-lite-rpc.ts @@ -0,0 +1,18 @@ +import { _createLiteRPC, type LiteRPCOptions } from "./lite-rpc.js"; +import { type LiteRPC, type RPCSchema } from "./types.js"; + +/** + * Creates a lite RPC instance that can send and receive requests, responses + * and messages. + */ +export function createLiteRPC< + Schema extends RPCSchema = RPCSchema, + RemoteSchema extends RPCSchema = Schema, +>( + /** + * The options that will be used to configure the RPC instance. + */ + options?: LiteRPCOptions, +): LiteRPC { + return _createLiteRPC(options); +} diff --git a/src/index.ts b/src/index.ts index b61710d..260b4a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ +export * from "./create-lite-rpc.js"; export * from "./create-request-handler.js"; export * from "./create-rpc.js"; +export * from "./lite-transports/browser-runtime-port.js"; +export * from "./lite-transports/message-port.js"; export * from "./transport-bridge.js"; export * from "./transport-utils.js"; export * from "./transports/browser-runtime-port.js"; diff --git a/src/lite-rpc.ts b/src/lite-rpc.ts new file mode 100644 index 0000000..5ad0361 --- /dev/null +++ b/src/lite-rpc.ts @@ -0,0 +1,296 @@ +import { + type _RPCMessagePacket, + type _RPCMessagePacketFromSchema, + type _RPCRequestPacket, + type _RPCRequestPacketFromSchema, + type _RPCResponsePacket, + type _RPCResponsePacketFromSchema, + type RPCMessageHandlerFn, + type RPCMessagePayload, + type RPCRequestHandlerObject, + type RPCRequestResponse, + type RPCSchema, + type RPCTransport, +} from "./types.js"; + +// constants +// --------- + +const MAX_ID = 1e10; +const DEFAULT_MAX_REQUEST_TIME = 1000; + +const MISSING_TRANSPORT_METHOD_ERROR = new Error("Missing transport method"); +const UNEXPECTED_MESSAGE_ERROR = new Error("Unexpected message"); + +// options +// ------- + +export type _LiteRPCOptions = { + /** + * A transport object that will be used to send and receive + * messages. + */ + transport?: RPCTransport; + + /** + * The functions that will be used to handle requests. + */ + requestHandler?: Omit, "_">; + + /** + * The maximum time to wait for a response to a request, in + * milliseconds. If exceeded, the promise will be rejected. + * @default 1000 + */ + maxRequestTime?: number; +}; + +type BaseOption = "transport"; +type RequestsInOption = "requestHandler"; +type RequestsOutOption = "maxRequestTime"; + +type OptionsByLocalSchema = + NonNullable extends Schema["requests"] ? never : RequestsInOption; + +type OptionsByRemoteSchema = + NonNullable extends RemoteSchema["requests"] + ? never + : RequestsOutOption; + +/** + * Options for creating a lite RPC instance, tailored to a specific + * set of schemas. Options will be ommitted if they are not + * supported according to the schemas. + * + * For example, if the remote schema doesn't have a `requests` + * property, the `maxRequestTime` option will be omitted because + * the instance won't be able to send requests. + */ +export type LiteRPCOptions< + Schema extends RPCSchema, + RemoteSchema extends RPCSchema, +> = Pick< + _LiteRPCOptions, + | BaseOption + | OptionsByLocalSchema + | OptionsByRemoteSchema +>; + +// lite rpc +// -------- + +export function _createLiteRPC< + Schema extends RPCSchema = RPCSchema, + RemoteSchema extends RPCSchema = Schema, +>( + /** + * The options that will be used to configure the lite RPC instance. + */ + options?: LiteRPCOptions, +) { + // options + // ------- + + const { + transport = {}, + requestHandler, + maxRequestTime = DEFAULT_MAX_REQUEST_TIME, + // hackish cast, nothing to see here, move along + } = options as LiteRPCOptions< + RPCSchema<{ requests: { hack: { params: unknown } } }>, + RPCSchema<{ requests: { hack: { params: unknown } } }> + >; + transport.registerHandler?.(handler); + function requestHandlerFn(method: any, params: any) { + const handlerFn = requestHandler?.[method as "hack"]; + if (handlerFn) return handlerFn(params); + throw new Error(`Missing request handler`); + } + + // requests + // -------- + + let lastRequestId = 0; + function getRequestId() { + if (lastRequestId <= MAX_ID) return ++lastRequestId; + return (lastRequestId = 0); + } + const requestListeners = new Map< + number, + { resolve: (result: unknown) => void; reject: (error: Error) => void } + >(); + const requestTimeouts = new Map>(); + + /** + * Sends a request to the remote RPC endpoint and returns a promise + * with the response. + * + * @example + * + * ```js + * await rpc.request("methodName", { param: "value" }); + * ``` + */ + function request( + method: Method, + ...args: "params" extends keyof RemoteSchema["requests"][Method] + ? undefined extends RemoteSchema["requests"][Method]["params"] + ? [params?: RemoteSchema["requests"][Method]["params"]] + : [params: RemoteSchema["requests"][Method]["params"]] + : [] + ): Promise> { + const params = args[0]; + return new Promise((resolve, reject) => { + if (!transport.send) throw MISSING_TRANSPORT_METHOD_ERROR; + const requestId = getRequestId(); + const request: _RPCRequestPacket = { + type: "request", + id: requestId, + method, + params, + }; + requestListeners.set(requestId, { resolve, reject }); + if (maxRequestTime !== Infinity) + requestTimeouts.set( + requestId, + setTimeout(() => { + requestTimeouts.delete(requestId); + reject(new Error("RPC request timed out.")); + }, maxRequestTime), + ); + transport.send(request); + }) as Promise; + } + + // messages + // -------- + + /** + * Sends a message to the remote RPC endpoint. + * + * @example + * + * ```js + * rpc.send("messageName", { content: "value" }); + * ``` + */ + function send( + /** + * The name of the message to send. + */ + message: Message, + ...args: void extends RPCMessagePayload + ? [] + : undefined extends RPCMessagePayload + ? [payload?: RPCMessagePayload] + : [payload: RPCMessagePayload] + ) { + const payload = args[0]; + if (!transport.send) throw MISSING_TRANSPORT_METHOD_ERROR; + const rpcMessage: _RPCMessagePacket = { + type: "message", + id: message as string, + payload, + }; + transport.send(rpcMessage); + } + + const messageListeners = new Map void>>(); + + /** + * Adds a listener for a message from the remote RPC endpoint. + */ + function addMessageListener( + /** + * The name of the message to listen to. + */ + message: Message, + /** + * The function that will be called when a message is received. + */ + listener: RPCMessageHandlerFn, + ): void { + if (!transport.registerHandler) throw MISSING_TRANSPORT_METHOD_ERROR; + if (!messageListeners.has(message)) + messageListeners.set(message, new Set()); + messageListeners.get(message)?.add(listener as any); + } + + /** + * Removes a listener for a message from the remote RPC endpoint. + */ + function removeMessageListener< + Message extends keyof RemoteSchema["messages"], + >( + /** + * The name of the message to remove the listener for. + */ + message: Message, + /** + * The listener function that will be removed. + */ + listener: RPCMessageHandlerFn, + ): void { + messageListeners.get(message)?.delete(listener as any); + if (messageListeners.get(message)?.size === 0) + messageListeners.delete(message); + } + + // message handling + // ---------------- + + async function handler( + message: + | _RPCRequestPacketFromSchema + | _RPCResponsePacketFromSchema + | _RPCMessagePacketFromSchema, + ) { + if (!("type" in message)) throw UNEXPECTED_MESSAGE_ERROR; + if (message.type === "request") { + if (!transport.send || !requestHandler) + throw MISSING_TRANSPORT_METHOD_ERROR; + const { id, method, params } = message; + let response: _RPCResponsePacket; + try { + response = { + type: "response", + id, + success: true, + payload: await requestHandlerFn(method, params), + }; + } catch (error) { + if (!(error instanceof Error)) throw error; + response = { + type: "response", + id, + success: false, + error: error.message, + }; + } + transport.send(response); + return; + } + if (message.type === "response") { + const timeout = requestTimeouts.get(message.id); + if (timeout != null) clearTimeout(timeout); + const { resolve, reject } = requestListeners.get(message.id) ?? {}; + if (!message.success) reject?.(new Error(message.error)); + else resolve?.(message.payload); + return; + } + if (message.type === "message") { + const listeners = messageListeners.get(message.id); + if (!listeners) return; + for (const listener of listeners) listener(message.payload); + return; + } + throw UNEXPECTED_MESSAGE_ERROR; + } + + return { request, send, addMessageListener, removeMessageListener }; +} + +export type LiteRPCInstance< + Schema extends RPCSchema = RPCSchema, + RemoteSchema extends RPCSchema = Schema, +> = ReturnType>; diff --git a/src/lite-transports/browser-runtime-port.ts b/src/lite-transports/browser-runtime-port.ts new file mode 100644 index 0000000..b108fa6 --- /dev/null +++ b/src/lite-transports/browser-runtime-port.ts @@ -0,0 +1,51 @@ +import { type Browser, type Chrome } from "browser-namespace"; + +import { + rpcTransportMessageInLite, + rpcTransportMessageOut, +} from "../transport-utils.js"; +import { type RPCTransport } from "../types.js"; + +type Port = Browser.Runtime.Port | Chrome.runtime.Port; + +/** + * Options for the message port transport. + */ +export type RPCBrowserRuntimePortLiteTransportOptions = { + /** + * An optional unique ID to use for the transport. Useful in cases where + * messages are sent to or received from multiple sources, which causes + * issues. + */ + transportId?: string | number; +}; + +/** + * Creates a transport from a browser runtime port. Useful for RPCs + * between content scripts and service workers in browser extensions. + */ +export function createLiteTransportFromBrowserRuntimePort( + /** + * The browser runtime port. + */ + port: Port, + /** + * Options for the browser runtime port transport. + */ + { transportId }: RPCBrowserRuntimePortLiteTransportOptions = {}, +): RPCTransport { + return { + send(data) { + port.postMessage(rpcTransportMessageOut(data, { transportId })); + }, + registerHandler(handler) { + port.onMessage.addListener((message) => { + const [ignore, data] = rpcTransportMessageInLite(message, transportId); + if (ignore) return; + handler(data); + }); + }, + }; +} + +// TODO: browser runtime port transport tests. diff --git a/src/lite-transports/message-port.ts b/src/lite-transports/message-port.ts new file mode 100644 index 0000000..75ce1a0 --- /dev/null +++ b/src/lite-transports/message-port.ts @@ -0,0 +1,63 @@ +import { + rpcTransportMessageInLite, + rpcTransportMessageOut, +} from "../transport-utils.js"; +import { type RPCTransport } from "../types.js"; + +/** + * Options for the lite message port transport. + */ +export type RPCMessagePortLiteTransportOptions = { + /** + * An optional unique ID to use for the transport. Useful in cases where + * messages are sent to or received from multiple sources, which causes + * issues. + */ + transportId?: string | number; +}; + +/** + * Creates a transport from objects that support `postMessage(message)` + * and `addEventListener("message", listener)`. This includes `Window`, + * `Worker`, `MessagePort`, and `BroadcastChannel`. + * + * This is useful for RPCs between, among other things, iframes or workers. + */ +export function createLiteTransportFromMessagePort( + /** + * The local port that will receive and handled "message" events + * through `addEventListener("message", listener)`. + */ + localPort: MessagePort | Window | Worker | BroadcastChannel, + /** + * The remote port to send messages to through `postMessage(message)`. + */ + remotePort: MessagePort | Window | Worker | BroadcastChannel, + /** + * Options for the message port transport. + */ + { transportId }: RPCMessagePortLiteTransportOptions = {}, +): RPCTransport { + return { + send(data) { + (remotePort as Window).postMessage( + rpcTransportMessageOut(data, { transportId }), + ); + }, + registerHandler(handler) { + (localPort as Window).addEventListener( + "message", + (event: MessageEvent) => { + const message = event.data; + const [ignore, data] = rpcTransportMessageInLite( + message, + transportId, + ); + if (!ignore) handler(data); + }, + ); + }, + }; +} + +// TODO: message port transport tests. diff --git a/src/rpc.ts b/src/rpc.ts index 51f6012..968a00e 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -45,13 +45,12 @@ type DebugHooks = { export type _RPCOptions = { /** * A transport object that will be used to send and receive - * messages. Setting the `send` function manually will override - * the transport's `send` function. + * messages. */ transport?: RPCTransport; /** - * The function that will be used to handle requests. + * The functions that will be used to handle requests. */ requestHandler?: RPCRequestHandler; diff --git a/src/tests/rpc.test.ts b/src/tests/rpc.test.ts index f1e30dc..3220531 100644 --- a/src/tests/rpc.test.ts +++ b/src/tests/rpc.test.ts @@ -279,3 +279,4 @@ test("proxy object works for requests and messages", async () => { }); // TODO: find a way to run these tests with all actual transports too. +// TODO: maxRequestTime tests. diff --git a/src/transport-utils.ts b/src/transport-utils.ts index c8b315e..d4d56aa 100644 --- a/src/transport-utils.ts +++ b/src/transport-utils.ts @@ -54,3 +54,19 @@ export function rpcTransportMessageIn( if (filterResult === false) return [true]; return [false, data]; } + +/** + * Determines if a message should be ignored, and if not, returns the message + * too. If the message was wrapped in a transport object, it is unwrapped. + */ +export function rpcTransportMessageInLite( + message: any, + transportId?: string | number, +): [ignore: false, message: any] | [ignore: true] { + let data = message; + if (transportId) { + if (message[transportIdKey] !== transportId) return [true]; + data = message.data; + } + return [false, data]; +} diff --git a/src/transports/browser-runtime-port.ts b/src/transports/browser-runtime-port.ts index 3459fb6..54f80c2 100644 --- a/src/transports/browser-runtime-port.ts +++ b/src/transports/browser-runtime-port.ts @@ -10,7 +10,7 @@ import { type RPCTransport } from "../types.js"; type Port = Browser.Runtime.Port | Chrome.runtime.Port; /** - * Options for the message port transport. + * Options for the browser runtime port transport. */ export type RPCBrowserRuntimePortTransportOptions = Pick< RPCTransportOptions, diff --git a/src/transports/message-port.ts b/src/transports/message-port.ts index baf6c67..842a238 100644 --- a/src/transports/message-port.ts +++ b/src/transports/message-port.ts @@ -6,7 +6,7 @@ import { import { type RPCTransport } from "../types.js"; /** - * Options for the message port transport. + * Options for the browser runtime port transport. */ export type RPCMessagePortTransportOptions = Pick< RPCTransportOptions, @@ -26,7 +26,7 @@ export type RPCMessagePortTransportOptions = Pick< }; /** - * Creates a transport from an object that supports `postMessage(message)` + * Creates a transport from objects that support `postMessage(message)` * and `addEventListener("message", listener)`. This includes `Window`, * `Worker`, `MessagePort`, and `BroadcastChannel`. * diff --git a/src/types.ts b/src/types.ts index 01c8e02..da7d3e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ // data // ---- +import { type LiteRPCInstance } from "./lite-rpc.js"; import { type _RPCOptions, type RPCInstance } from "./rpc.js"; /** @@ -503,3 +504,37 @@ export type RPC< | MethodsByRemoteSchema | MethodsByRemoteSchemaAndLocalSchema >; + +type LiteRPCRequestsOutMethod = "request"; +type LiteRPCMessagesInMethod = "addMessageListener" | "removeMessageListener"; +type LiteRPCMessagesOutMethod = "send"; + +type LiteMethodsByLocalSchema = + NonNullable extends Schema["messages"] + ? never + : LiteRPCMessagesOutMethod; + +type LiteMethodsByRemoteSchema = + | (NonNullable extends RemoteSchema["requests"] + ? never + : LiteRPCRequestsOutMethod) + | (NonNullable extends RemoteSchema["messages"] + ? never + : LiteRPCMessagesInMethod); + +/** + * A lite RPC instance type, tailored to a specific set of schemas. + * Methods will be ommitted if they are not supported according + * to the schemas. + * + * For example, if the remote schema doesn't have a `requests` + * property, the `request` method will be omitted because the + * instance won't be able to send requests. + */ +export type LiteRPC< + Schema extends RPCSchema, + RemoteSchema extends RPCSchema, +> = Pick< + LiteRPCInstance, + LiteMethodsByLocalSchema | LiteMethodsByRemoteSchema +>;