UNPKG

@trpc/client

Version:

The tRPC client library

291 lines (263 loc) • 8.9 kB
import type { AnyTRPCRouter, inferRouterError } from '@trpc/server'; import type { Observer } from '@trpc/server/observable'; import type { TRPCClientOutgoingMessage, TRPCResponseMessage, } from '@trpc/server/unstable-core-do-not-import'; import type { TRPCClientError } from '../../../TRPCClientError'; import { withResolvers } from './utils'; export type TCallbacks = Observer< TRPCResponseMessage<unknown, inferRouterError<AnyTRPCRouter>>, TRPCClientError<AnyTRPCRouter> >; type MessageId = string; type MessageIdLike = string | number | null; /** * Represents a WebSocket request managed by the RequestManager. * Combines the network message, a utility promise (`end`) that mirrors the lifecycle * handled by `callbacks`, and a set of state monitoring callbacks. */ interface Request { message: TRPCClientOutgoingMessage; end: Promise<void>; callbacks: TCallbacks; /** * The socket the request was sent on, or `null` while it's outgoing. */ ws: WebSocket | null; /** * The socket a subscription is moving to. `ws` keeps delivering until this * one confirms the subscription with `started`. */ successor: WebSocket | null; /** * The event ids `ws` delivered while the subscription was moving, which * its successor replays and are dropped from it. */ overlapEventIds: Set<string> | null; /** * The sockets a subscription moved from, each until everything it sent * before the stop has arrived. Their events without ids are still * delivered, since the new socket may have started listening after they * were sent. */ movedFrom: Set<WebSocket>; } /** * Manages WebSocket requests, tracking their lifecycle and providing utility methods * for handling outgoing and pending requests. * * - **Outgoing requests**: Requests that are queued and waiting to be sent. * - **Pending requests**: Requests that have been sent and are in flight awaiting a response. * For subscriptions, multiple responses may be received until the subscription is closed. */ export class RequestManager { /** * Stores requests that are outgoing, meaning they are registered but not yet sent over the WebSocket. */ private outgoingRequests = new Array<Request & { id: MessageId }>(); /** * Stores requests that are pending (in flight), meaning they have been sent over the WebSocket * and are awaiting responses. For subscriptions, this includes requests * that may receive multiple responses. */ private pendingRequests: Record<MessageId, Request> = {}; /** * Subscriptions sent again under a new id, by their earlier id, while * sockets they moved from may still send events under that one. */ private drainingRequests: Record<MessageId, Request> = {}; /** * Registers a new request by adding it to the outgoing queue and setting up * callbacks for lifecycle events such as completion or error. * * @param message - The outgoing message to be sent. * @param callbacks - Callback functions to observe the request's state. * @returns A cleanup function to manually remove the request. */ public register(message: TRPCClientOutgoingMessage, callbacks: TCallbacks) { const { promise: end, resolve } = withResolvers<void>(); this.outgoingRequests.push({ id: String(message.id), message, end, ws: null, successor: null, overlapEventIds: null, movedFrom: new Set(), callbacks: { next: callbacks.next, complete: () => { callbacks.complete(); resolve(); }, error: (e) => { try { callbacks.error(e); } finally { resolve(); } }, }, }); return () => { this.delete(message); callbacks.complete(); resolve(); }; } /** * Deletes a request from both the outgoing and pending collections, if it exists. * * Matches the message itself rather than its id: a retry reuses the id, and * registers its request before the failed request with that id is deleted. */ public delete(message: TRPCClientOutgoingMessage) { this.outgoingRequests = this.outgoingRequests.filter( (request) => request.message !== message, ); const id = String(message.id); if (this.pendingRequests[id]?.message === message) { delete this.pendingRequests[id]; } for (const [drainingId, request] of Object.entries(this.drainingRequests)) { if (request.message === message) { delete this.drainingRequests[drainingId]; } } } /** * Moves all outgoing requests to the pending state and clears the outgoing queue. * * The caller is expected to handle the actual sending of the requests * (e.g., sending them over the network) after this method is called. * * @param ws - The socket the requests are sent on. * @returns The list of requests that were transitioned to the pending state. */ public flush(ws: WebSocket | null = null) { const requests = this.outgoingRequests; this.outgoingRequests = []; for (const request of requests) { request.ws = ws; request.successor = null; request.movedFrom = new Set(); this.pendingRequests[request.id] = request; } return requests; } /** * Moves a pending request back to the outgoing queue, to be sent again * with `id`. Its earlier id then matches only the sockets it's still * draining; see `getDrainingRequest()`. */ public requeue( message: TRPCClientOutgoingMessage, id: TRPCClientOutgoingMessage['id'], ) { const earlierId = String(message.id); const request = this.pendingRequests[earlierId]; if (request?.message !== message) return; delete this.pendingRequests[earlierId]; if (request.movedFrom.size) this.drainingRequests[earlierId] = request; message.id = id; this.outgoingRequests.push({ ...request, id: String(id), ws: null, successor: null, overlapEventIds: null, movedFrom: new Set(), }); } /** * Retrieves all currently pending requests, which are in flight awaiting responses * or handling ongoing subscriptions. */ public getPendingRequests() { return Object.values(this.pendingRequests); } /** * Retrieves a specific pending request by its message ID. */ public getPendingRequest(messageId: MessageIdLike) { if (messageId === null) return null; return this.pendingRequests[String(messageId)]; } /** * Retrieves a subscription sent again under a new id by its earlier id, * while a socket it moved from may still send events under that one. */ public getDrainingRequest(messageId: MessageIdLike) { if (messageId === null) return null; return this.drainingRequests[String(messageId)]; } /** * Retrieves the subscriptions sent again under a new id whose earlier id * is still in use on a socket they moved from. */ public getDrainingRequests() { return Object.values(this.drainingRequests); } /** * Forgets the earlier ids of subscriptions no longer draining any socket. */ public deleteDrained() { for (const [id, request] of Object.entries(this.drainingRequests)) { if (!request.movedFrom.size) delete this.drainingRequests[id]; } } /** * Retrieves all outgoing requests, which are waiting to be sent. */ public getOutgoingRequests() { return this.outgoingRequests; } /** * Retrieves all requests, both outgoing and pending, with their respective states. * * @returns An array of all requests with their state ("outgoing" or "pending"). */ public getRequests() { return [ ...this.getOutgoingRequests().map((request) => ({ state: 'outgoing' as const, message: request.message, end: request.end, callbacks: request.callbacks, })), ...this.getPendingRequests().map((request) => ({ state: 'pending' as const, message: request.message, end: request.end, callbacks: request.callbacks, })), ]; } /** * Checks if there are any pending requests, including ongoing subscriptions. */ public hasPendingRequests() { return this.getPendingRequests().length > 0; } /** * Checks if there are any pending subscriptions */ public hasPendingSubscriptions() { return this.getPendingRequests().some( (request) => request.message.method === 'subscription', ); } /** * Checks if a message is still waiting to be sent. */ public isOutgoing(message: TRPCClientOutgoingMessage) { return this.outgoingRequests.some((request) => request.message === message); } /** * Checks if there are any outgoing requests waiting to be sent. */ public hasOutgoingRequests() { return this.outgoingRequests.length > 0; } }