@trpc/client
Version:
291 lines (263 loc) • 8.9 kB
text/typescript
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;
}
}