UNPKG

@trpc/server

Version:

The tRPC server library

940 lines (862 loc) • 27.6 kB
import type { IncomingMessage } from 'http'; import type { WebSocketServer, WebSocket as WsWebSocket } from 'ws'; import type { AnyRouter, CreateContextCallback, inferRouterContext, } from '../@trpc/server'; import { callTRPCProcedure, getErrorShape, getTRPCErrorFromUnknown, transformTRPCResponse, TRPCError, } from '../@trpc/server'; import type { TRPCRequestInfo } from '../@trpc/server/http'; import { type BaseHandlerOptions } from '../@trpc/server/http'; import { parseTRPCMessage, TRPC_ERROR_CODES_BY_KEY } from '../@trpc/server/rpc'; // @trpc/server/rpc import type { TRPCClientOutgoingMessage, TRPCConnectionParamsMessage, TRPCErrorShape, TRPCReconnectNotification, TRPCResponseMessage, TRPCResultMessage, } from '../@trpc/server/rpc'; import { parseConnectionParamsFromUnknown } from '../http'; import { isObservable, observableToAsyncIterable } from '../observable'; // oxlint-disable-next-line no-restricted-imports import { isAbortError, isAsyncIterable, isObject, isPromise, isTrackedEnvelope, makeResource, run, type MaybePromise, } from '../unstable-core-do-not-import'; // oxlint-disable-next-line no-restricted-imports import type { Result } from '../unstable-core-do-not-import'; // oxlint-disable-next-line no-restricted-imports import { iteratorResource } from '../unstable-core-do-not-import/stream/utils/asyncIterable'; import { Unpromise } from '../vendor/unpromise'; import { createURL, type NodeHTTPCreateContextFnOptions } from './node-http'; import type { Encoder } from './wsEncoder'; import { jsonEncoder } from './wsEncoder'; /** * Importing ws causes a build error * @see https://github.com/trpc/trpc/pull/5279 */ const WEBSOCKET_OPEN = 1; /* ws.WebSocket.OPEN */ function settle<TValue>(promise: Promise<TValue>): Promise<Result<TValue>> { return promise.then( (value): Result<TValue> => ({ ok: true, value }), (error: unknown): Result<TValue> => ({ ok: false, error }), ); } /** * What `await using` rejects with when disposal fails after the scope threw: * `error` is the disposal failure and `suppressed` the original error. */ function isSuppressedError( cause: unknown, ): cause is { error: unknown; suppressed: unknown } { return ( isObject(cause) && cause['name'] === 'SuppressedError' && 'error' in cause && 'suppressed' in cause ); } /** * What the client gets when its error can't be formatted or encoded. It says * nothing about the error, which is what `errorFormatter` would have hidden. */ const fallbackErrorShape: TRPCErrorShape = { code: TRPC_ERROR_CODES_BY_KEY.INTERNAL_SERVER_ERROR, message: 'Internal server error', data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, }; /** * A procedure that gives up because its subscription was cancelled rejects * with an `AbortError`, which is wrapped as an INTERNAL_SERVER_ERROR. */ function isCancelledSetup(error: TRPCError, signal: AbortSignal) { return ( signal.aborted && error.code === 'INTERNAL_SERVER_ERROR' && isAbortError(error.cause) ); } /** * What `createContext` gets in the WebSocket adapter: the upgrade request * as `req`, the socket as `res`, and `info`. It's called once per * connection. * @see https://trpc.io/docs/server/websockets#creating-a-websocket-server * @public */ export type CreateWSSContextFnOptions = NodeHTTPCreateContextFnOptions< IncomingMessage, WsWebSocket >; /** * The `createContext` function the WebSocket adapter takes. * @public */ export type CreateWSSContextFn<TRouter extends AnyRouter> = ( opts: CreateWSSContextFnOptions, ) => MaybePromise<inferRouterContext<TRouter>>; /** The options of `getWSConnectionHandler`. */ export type WSConnectionHandlerOptions<TRouter extends AnyRouter> = BaseHandlerOptions<TRouter, IncomingMessage> & CreateContextCallback< inferRouterContext<TRouter>, CreateWSSContextFn<TRouter> >; /** * The options of `applyWSSHandler`. * @see https://trpc.io/docs/server/websockets#creating-a-websocket-server */ export type WSSHandlerOptions<TRouter extends AnyRouter> = WSConnectionHandlerOptions<TRouter> & { /** The `ws` server to serve the router on. */ wss: WebSocketServer; /** Only connections whose URL starts with this are served. */ prefix?: string; /** * Sends pings to each client and closes connections that stop * answering. */ keepAlive?: { /** * Whether to send pings. * @default false */ enabled: boolean; /** * How long a connection can be quiet before a ping, in milliseconds. * @default 30_000 */ pingMs?: number; /** * How long to wait for a pong before closing the connection, in * milliseconds. * @default 5_000 */ pongWaitMs?: number; }; /** * Stops answering clients' pings. Meant for tests. * @default false */ dangerouslyDisablePong?: boolean; /** * Encodes and decodes messages, for formats other than JSON. * Experimental: it may change in any release. * @default jsonEncoder */ experimental_encoder?: Encoder; }; /** * `iterator.next()` for a subscription loop that races it against the * subscription's `signal`, with a rejection turned into a `TRPCError`. * * Once the subscription is stopped, the loop doesn't wait for this call, so a * rejection after the stop, such as a teardown that throws, goes to * `onErrorAfterStop` and resolves `'abort'`. An `AbortError` is how a stopped * subscription is expected to end, and isn't reported. */ function nextUnlessStopped<TValue, TReturn>( iterator: AsyncIterator<TValue, TReturn>, signal: AbortSignal, onErrorAfterStop: (error: TRPCError) => void, ): Promise<IteratorResult<TValue, TReturn> | TRPCError | 'abort'> { return iterator.next().catch((cause: unknown) => { const error = getTRPCErrorFromUnknown(cause); if (!signal.aborted) { return error; } if (!isAbortError(error.cause)) { onErrorAfterStop(error); } return 'abort'; }); } /** * Returns a `ws` connection listener that serves a router. `applyWSSHandler` * uses it; call it to attach tRPC to your own WebSocket server. */ export function getWSConnectionHandler<TRouter extends AnyRouter>( opts: WSSHandlerOptions<TRouter>, ) { const { createContext, router } = opts; const { transformer } = router._def._config; const encoder = opts.experimental_encoder ?? jsonEncoder; return (client: WsWebSocket, req: IncomingMessage) => { type Context = inferRouterContext<TRouter>; type ContextResult = Result<Context>; const clientSubscriptions = new Map<number | string, AbortController>(); const abortController = new AbortController(); function registerSubscription( id: number | string, subscription: AbortController, ) { clientSubscriptions.set(id, subscription); return makeResource({}, () => { clientSubscriptions.delete(id); }); } if (opts.keepAlive?.enabled) { const { pingMs, pongWaitMs } = opts.keepAlive; handleKeepAlive(client, pingMs, pongWaitMs); } function respond(untransformedJSON: TRPCResponseMessage) { client.send( encoder.encode( transformTRPCResponse(router._def._config, untransformedJSON), ), ); } type ErrorInfo = Parameters<NonNullable<typeof opts.onError>>[0]; /** * `onError` runs in listeners and in promises nothing awaits, where a * throw or a rejection would crash the process, so either goes to * `onFailure` instead. */ function guardOnError( info: ErrorInfo, onFailure: (cause: unknown) => void, ) { try { const result: unknown = opts.onError?.(info); if (isPromise(result)) { result.catch(onFailure); } } catch (cause) { onFailure(cause); } } /** Reports an error to `onError`, ignoring an `onError` that fails. */ function callOnError(info: ErrorInfo) { guardOnError(info, () => { // ignored, see guardOnError }); } /** * Reports a failure of the adapter's own error handling, such as an * `errorFormatter` that throws or a reply that can't be sent. It's logged * when `onError` is missing or fails, so it's never dropped silently. */ function reportInternalError(info: ErrorInfo) { const log = () => { // oxlint-disable-next-line no-console console.error('[@trpc/server/adapters/ws]', info.error); }; if (!opts.onError) { log(); return; } guardOnError(info, log); } /** * The error shape the client gets, or a minimal one when `errorFormatter` * throws, so that the request is still answered. */ function errorShape(call: Omit<ErrorInfo, 'ctx' | 'req'>): TRPCErrorShape { try { return getErrorShape({ config: router._def._config, ctx, ...call }); } catch (cause) { reportInternalError({ ...call, error: getTRPCErrorFromUnknown(cause), ctx, req, }); return fallbackErrorShape; } } function reportConnectionError(cause: unknown) { reportInternalError({ error: getTRPCErrorFromUnknown(cause), path: undefined, type: 'unknown', ctx, req, input: undefined, }); } /** * Sends a response that isn't tied to a request, reporting it if it * can't be built or sent. One that can't be sent is replaced by the * fallback error, so that a client still learns it was rejected. */ function respondOrReport(response: () => TRPCResponseMessage) { let message: TRPCResponseMessage | null = null; try { message = response(); respond(message); } catch (cause) { reportConnectionError(cause); if (message === null) { return; } try { respond({ id: message.id, jsonrpc: message.jsonrpc, error: fallbackErrorShape, }); } catch { // the same failure, and it was reported above } } } async function createCtxPromise( getConnectionParams: () => TRPCRequestInfo['connectionParams'], ): Promise<ContextResult> { try { return await run(async (): Promise<ContextResult> => { ctx = await createContext?.({ req, res: client, info: { connectionParams: getConnectionParams(), calls: [], isBatchCall: false, accept: null, type: 'unknown', signal: abortController.signal, url: null, }, }); return { ok: true, value: ctx, }; }); } catch (cause) { const error = getTRPCErrorFromUnknown(cause); callOnError({ error, path: undefined, type: 'unknown', ctx, req, input: undefined, }); respondOrReport(() => ({ id: null, error: errorShape({ error, type: 'unknown', path: undefined, input: undefined, }), })); // close in next tick (globalThis.setImmediate ?? globalThis.setTimeout)(() => { client.close(); }); return { ok: false, error, }; } } let ctx: Context | undefined = undefined; /** * promise for initializing the context * * - the context promise will be created immediately on connection if no connectionParams are expected * - if connection params are expected, they will be created once received */ let ctxPromise = createURL(req).searchParams.get('connectionParams') === '1' ? null : createCtxPromise(() => null); async function handleRequest( msg: TRPCClientOutgoingMessage, batchIndex: number, ctxResult: Promise<ContextResult>, ) { const { id, jsonrpc } = msg; if (id === null) { const error = getTRPCErrorFromUnknown( new TRPCError({ code: 'PARSE_ERROR', message: '`id` is required', }), ); callOnError({ error, path: undefined, type: 'unknown', ctx, req, input: undefined, }); respond({ id, jsonrpc, error: errorShape({ error, type: 'unknown', path: undefined, input: undefined, }), }); return; } if (msg.method === 'subscription.stop') { clientSubscriptions.get(id)?.abort(); return; } const { path, lastEventId } = msg.params; let { input } = msg.params; const type = msg.method; if (lastEventId !== undefined) { if (isObject(input)) { input = { ...input, lastEventId: lastEventId, }; } else { input ??= { lastEventId: lastEventId, }; } } const abortController = new AbortController(); // `abort()` builds an AbortError with a stack trace even when the // signal is already aborted, which is the common case on a stop. const abort = () => { if (!abortController.signal.aborted) { abortController.abort(); } }; const reportError = (cause: unknown) => { const error = getTRPCErrorFromUnknown(cause); callOnError({ error, path, type, ctx, req, input }); return error; }; const respondWithError = (cause: unknown) => { const error = reportError(cause); respond({ id, jsonrpc, error: errorShape({ error, type, path, input }), }); }; if (type === 'subscription' && clientSubscriptions.has(id)) { // duplicate request ids for client respondWithError( new TRPCError({ message: `Duplicate id ${id}`, code: 'BAD_REQUEST', }), ); return; } const execute = async () => { // Registered before any await so that a stop or a disconnect during // context creation or the procedure call cancels the subscription. // The id is freed once this has settled, after the iterator's // disposal and right before the client is told it may reuse it. using _subscription = type === 'subscription' ? registerSubscription(id, abortController) : null; const res = await ctxResult; if (!res.ok) { throw res.error; } if (abortController.signal.aborted) { return; } const result = await callTRPCProcedure({ router, path, getRawInput: async () => input, ctx, type, signal: abortController.signal, batchIndex, }); const isIterableResult = isAsyncIterable(result) || isObservable(result); if (type !== 'subscription') { if (isIterableResult) { throw new TRPCError({ code: 'UNSUPPORTED_MEDIA_TYPE', message: `Cannot return an async iterable or observable from a ${type} procedure with WebSockets`, }); } // send the value as data if the method is not a subscription respond({ id, jsonrpc, result: { type: 'data', data: result, }, }); return; } if (!isIterableResult) { throw new TRPCError({ message: `Subscription ${path} did not return an observable or a AsyncGenerator`, code: 'INTERNAL_SERVER_ERROR', }); } const iterable = isObservable(result) ? observableToAsyncIterable(result, abortController.signal) : result; // A failed iterator cleanup is reported to the server only: the // client still gets `stopped`, so a subscription that completed or // was stopped doesn't turn into a failed one. if (abortController.signal.aborted) { try { // dispose the iterator without consuming it await using _iterator = iteratorResource(iterable); } catch (cause) { reportError(cause); } return; } respond({ id, jsonrpc, result: { type: 'started', }, }); let ended = false; try { await using iterator = iteratorResource(iterable); let completed = false; // Disposed before the iterator, so cleanup that waits for // cancellation is woken when iteration ends other than by `done`. using _abortUnlessCompleted = makeResource({}, () => { if (!completed) { abort(); } }); const abortPromise = new Promise<'abort'>((resolve) => { abortController.signal.onabort = () => resolve('abort'); }); // We need those declarations outside the loop for garbage collection reasons. If they // were declared inside, they would not be freed until the next value is present. let next: | null | TRPCError | Awaited< typeof abortPromise | ReturnType<(typeof iterator)['next']> >; let result: null | TRPCResultMessage<unknown>['result']; while (true) { next = await Unpromise.race([ nextUnlessStopped(iterator, abortController.signal, (error) => callOnError({ error, path, type, ctx, req, input }), ), abortPromise, ]); if (next === 'abort') { break; } if (next instanceof Error) { // Report the error only after the iterator's disposal settles. throw next; } if (next.done) { completed = true; break; } result = { type: 'data', data: next.value, }; if (isTrackedEnvelope(next.value)) { const [id, data] = next.value; result.id = id; result.data = { id, data, }; } respond({ id, jsonrpc, result, }); // free up references for garbage collection next = null; result = null; } ended = true; } catch (cause) { abort(); if (ended) { reportError(cause); } else if (isSuppressedError(cause)) { // the client gets the iteration error, the server both respondWithError(cause.suppressed); reportError(cause.error); } else { respondWithError(cause); } } }; const outcome = await settle(execute()); if (!outcome.ok) { const error = getTRPCErrorFromUnknown(outcome.error); if (!isCancelledSetup(error, abortController.signal)) { try { respondWithError(error); } catch (cause) { // the error reply couldn't be encoded or sent reportInternalError({ error: getTRPCErrorFromUnknown(cause), path, type, ctx, req, input, }); respondOrReport(() => ({ id, jsonrpc, error: fallbackErrorShape })); } return; } } if (type === 'subscription') { respond({ id, jsonrpc, result: { type: 'stopped' } }); } } client.on('message', (rawData, isBinary) => { // Handle PING/PONG as text regardless of encoder if (!isBinary) { // oxlint-disable-next-line typescript/no-base-to-string const msgStr = rawData.toString(); if (msgStr === 'PONG') { return; } if (msgStr === 'PING') { if (!opts.dangerouslyDisablePong) { client.send('PONG'); } return; } } // Convert rawData to a format our encoder accepts // ws gives us Buffer for both text and binary frames if (!Buffer.isBuffer(rawData)) { const error = new TRPCError({ code: 'UNPROCESSABLE_CONTENT', message: 'Unexpected WebSocket message format', }); respondOrReport(() => ({ id: null, error: errorShape({ error, type: 'unknown', path: undefined, input: undefined, }), })); return; } const data: string | Uint8Array = isBinary ? rawData : rawData.toString('utf8'); if (!ctxPromise) { // If the ctxPromise wasn't created immediately, we're expecting the first message to be a TRPCConnectionParamsMessage ctxPromise = createCtxPromise(() => { let msg; try { msg = encoder.decode(data) as TRPCConnectionParamsMessage; if (!isObject(msg)) { throw new Error('Message was not an object'); } } catch (cause) { throw new TRPCError({ code: 'PARSE_ERROR', message: `Malformed TRPCConnectionParamsMessage`, cause, }); } const connectionParams = parseConnectionParamsFromUnknown(msg.data); return connectionParams; }); return; } const parsedMsgs = run(() => { try { const msgJSON: unknown = encoder.decode(data); const msgs: unknown[] = Array.isArray(msgJSON) ? msgJSON : [msgJSON]; if ( Array.isArray(msgJSON) && typeof opts.maxBatchSize === 'number' && msgs.length > opts.maxBatchSize ) { const error = new TRPCError({ code: 'BAD_REQUEST', message: `Batch call exceeds maximum size`, }); callOnError({ error, path: undefined, type: 'unknown', ctx, req, input: undefined, }); const shape = errorShape({ error, type: 'unknown', path: undefined, input: undefined, }); // Checked before parsing so that no input is deserialized. // Every request in the batch gets the error, as a batched HTTP // call does, so that clients do not wait for a response forever. let responded = false; for (const raw of msgs) { if ( isObject(raw) && raw['method'] !== 'subscription.stop' && (typeof raw['id'] === 'number' || typeof raw['id'] === 'string') ) { const id = raw['id']; const jsonrpc = raw['jsonrpc'] === '2.0' ? '2.0' : undefined; respondOrReport(() => ({ id, jsonrpc, error: shape })); responded = true; } } if (!responded) { respondOrReport(() => ({ id: null, error: shape })); } return []; } return msgs.map((raw) => parseTRPCMessage(raw, transformer)); } catch (cause) { const error = new TRPCError({ code: 'PARSE_ERROR', cause, }); respondOrReport(() => ({ id: null, error: errorShape({ error, type: 'unknown', path: undefined, input: undefined, }), })); return []; } }); for (const [index, msg] of parsedMsgs.entries()) { handleRequest(msg, index, ctxPromise).catch(reportConnectionError); } }); // WebSocket errors should be handled, as otherwise unhandled exceptions will crash Node.js. // This line was introduced after the following error brought down production systems: // "RangeError: Invalid WebSocket frame: RSV2 and RSV3 must be clear" // Here is the relevant discussion: https://github.com/websockets/ws/issues/1354#issuecomment-774616962 client.on('error', (cause) => { callOnError({ ctx, error: getTRPCErrorFromUnknown(cause), input: undefined, path: undefined, type: 'unknown', req, }); }); client.once('close', () => { for (const sub of clientSubscriptions.values()) { sub.abort(); } clientSubscriptions.clear(); abortController.abort(); }); }; } /** * Pings a client after `pingMs` of quiet, and closes the connection if no * message comes within `pongWaitMs`. */ export function handleKeepAlive( client: WsWebSocket, pingMs = 30_000, pongWaitMs = 5_000, ) { let timeout: NodeJS.Timeout | undefined = undefined; let ping: NodeJS.Timeout | undefined = undefined; const schedulePing = () => { const scheduleTimeout = () => { timeout = setTimeout(() => { client.terminate(); }, pongWaitMs); }; ping = setTimeout(() => { client.send('PING'); scheduleTimeout(); }, pingMs); }; const onMessage = () => { clearTimeout(ping); clearTimeout(timeout); schedulePing(); }; client.on('message', onMessage); client.on('close', () => { clearTimeout(ping); clearTimeout(timeout); }); schedulePing(); } /** * Serves a router on a `ws` WebSocket server. Returns * `broadcastReconnectNotification()`, which tells every client to * reconnect, for example before a deploy. * @example * ```ts * const wss = new WebSocketServer({ port: 3001 }); * const handler = applyWSSHandler({ wss, router: appRouter, createContext }); * process.on('SIGTERM', () => { * handler.broadcastReconnectNotification(); * wss.close(); * }); * ``` * @see https://trpc.io/docs/server/websockets#creating-a-websocket-server */ export function applyWSSHandler<TRouter extends AnyRouter>( opts: WSSHandlerOptions<TRouter>, ) { const encoder = opts.experimental_encoder ?? jsonEncoder; const onConnection = getWSConnectionHandler(opts); opts.wss.on('connection', (client, req) => { if (opts.prefix && !req.url?.startsWith(opts.prefix)) { return; } onConnection(client, req); }); return { broadcastReconnectNotification: () => { const response: TRPCReconnectNotification = { id: null, method: 'reconnect', }; const data = encoder.encode(response); for (const client of opts.wss.clients) { if (client.readyState === WEBSOCKET_OPEN) { client.send(data); } } }, }; } export type { Encoder } from './wsEncoder'; export { jsonEncoder } from './wsEncoder';