UNPKG

@trpc/client

Version:

The tRPC client library

724 lines (714 loc) • 24.8 kB
const require_chunk = require('./chunk-DWy1uDak.cjs'); const require_objectSpread2$1 = require('./objectSpread2-Bsvh_OqM.cjs'); const require_TRPCClientError = require('./TRPCClientError-CQrTQLrk.cjs'); const require_unstable_internals = require('./unstable-internals-M84gUQCV.cjs'); const __trpc_server_observable = require_chunk.__toESM(require("@trpc/server/observable")); const __trpc_server_unstable_core_do_not_import = require_chunk.__toESM(require("@trpc/server/unstable-core-do-not-import")); //#region src/links/wsLink/wsClient/encoder.ts const jsonEncoder = { encode: (data) => JSON.stringify(data), decode: (data) => { if (typeof data !== "string") throw new Error("jsonEncoder received binary data. JSON uses text frames. Use a binary encoder for binary data."); return JSON.parse(data); } }; //#endregion //#region src/links/wsLink/wsClient/options.ts const lazyDefaults = { enabled: false, closeMs: 0 }; const keepAliveDefaults = { enabled: false, pongTimeoutMs: 1e3, intervalMs: 5e3 }; /** * Calculates a delay for exponential backoff based on the retry attempt index. * The delay starts at 0 for the first attempt and doubles for each subsequent attempt, * capped at 30 seconds. */ const exponentialBackoff = (attemptIndex) => { return attemptIndex === 0 ? 0 : Math.min(1e3 * 2 ** attemptIndex, 3e4); }; //#endregion //#region src/links/internals/urlWithConnectionParams.ts /** * Get the result of a value or function that returns a value * It also optionally accepts typesafe arguments for the function */ const resultOf = (value, ...args) => { return typeof value === "function" ? value(...args) : value; }; //#endregion //#region src/links/wsLink/wsClient/utils.ts var import_defineProperty$3 = require_chunk.__toESM(require_objectSpread2$1.require_defineProperty(), 1); var TRPCWebSocketClosedError = class TRPCWebSocketClosedError extends Error { constructor(opts) { super(opts.message, { cause: opts.cause }); this.name = "TRPCWebSocketClosedError"; Object.setPrototypeOf(this, TRPCWebSocketClosedError.prototype); } }; /** * Utility class for managing a timeout that can be started, stopped, and reset. * Useful for scenarios where the timeout duration is reset dynamically based on events. */ var ResettableTimeout = class { constructor(onTimeout, timeoutMs) { this.onTimeout = onTimeout; this.timeoutMs = timeoutMs; (0, import_defineProperty$3.default)(this, "timeout", void 0); } /** * Resets the current timeout, restarting it with the same duration. * Does nothing if no timeout is active. */ reset() { if (!this.timeout) return; clearTimeout(this.timeout); this.timeout = setTimeout(this.onTimeout, this.timeoutMs); } start() { clearTimeout(this.timeout); this.timeout = setTimeout(this.onTimeout, this.timeoutMs); } stop() { clearTimeout(this.timeout); this.timeout = void 0; } }; function withResolvers() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } /** * Resolves a WebSocket URL and optionally appends connection parameters. * * If connectionParams are provided, appends 'connectionParams=1' query parameter. */ async function prepareUrl(urlOptions) { const url = await resultOf(urlOptions.url); if (!urlOptions.connectionParams) return url; const prefix = url.includes("?") ? "&" : "?"; const connectionParams = `${prefix}connectionParams=1`; return url + connectionParams; } async function buildConnectionMessage(connectionParams, encoder) { const message = { method: "connectionParams", data: await resultOf(connectionParams) }; return encoder.encode(message); } //#endregion //#region src/links/wsLink/wsClient/requestManager.ts var import_defineProperty$2 = require_chunk.__toESM(require_objectSpread2$1.require_defineProperty(), 1); /** * 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. */ var RequestManager = class { constructor() { (0, import_defineProperty$2.default)(this, "outgoingRequests", new Array()); (0, import_defineProperty$2.default)(this, "pendingRequests", {}); } /** * 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. */ register(message, callbacks) { const { promise: end, resolve } = withResolvers(); this.outgoingRequests.push({ id: String(message.id), message, end, callbacks: { next: callbacks.next, complete: () => { callbacks.complete(); resolve(); }, error: (e) => { callbacks.error(e); resolve(); } } }); return () => { this.delete(message.id); callbacks.complete(); resolve(); }; } /** * Deletes a request from both the outgoing and pending collections, if it exists. */ delete(messageId) { if (messageId === null) return; this.outgoingRequests = this.outgoingRequests.filter(({ id }) => id !== String(messageId)); delete this.pendingRequests[String(messageId)]; } /** * 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. * * @returns The list of requests that were transitioned to the pending state. */ flush() { const requests = this.outgoingRequests; this.outgoingRequests = []; for (const request of requests) this.pendingRequests[request.id] = request; return requests; } /** * Retrieves all currently pending requests, which are in flight awaiting responses * or handling ongoing subscriptions. */ getPendingRequests() { return Object.values(this.pendingRequests); } /** * Retrieves a specific pending request by its message ID. */ getPendingRequest(messageId) { if (messageId === null) return null; return this.pendingRequests[String(messageId)]; } /** * Retrieves all outgoing requests, which are waiting to be sent. */ 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"). */ getRequests() { return [...this.getOutgoingRequests().map((request) => ({ state: "outgoing", message: request.message, end: request.end, callbacks: request.callbacks })), ...this.getPendingRequests().map((request) => ({ state: "pending", message: request.message, end: request.end, callbacks: request.callbacks }))]; } /** * Checks if there are any pending requests, including ongoing subscriptions. */ hasPendingRequests() { return this.getPendingRequests().length > 0; } /** * Checks if there are any pending subscriptions */ hasPendingSubscriptions() { return this.getPendingRequests().some((request) => request.message.method === "subscription"); } /** * Checks if there are any outgoing requests waiting to be sent. */ hasOutgoingRequests() { return this.outgoingRequests.length > 0; } }; //#endregion //#region src/links/wsLink/wsClient/wsConnection.ts var import_defineProperty$1 = require_chunk.__toESM(require_objectSpread2$1.require_defineProperty(), 1); /** * Opens a WebSocket connection asynchronously and returns a promise * that resolves when the connection is successfully established. * The promise rejects if an error occurs during the connection attempt. */ function asyncWsOpen(ws) { const { promise, resolve, reject } = withResolvers(); ws.addEventListener("open", () => { ws.removeEventListener("error", reject); resolve(); }); ws.addEventListener("error", reject); return promise; } /** * Sets up a periodic ping-pong mechanism to keep the WebSocket connection alive. * * - Sends "PING" messages at regular intervals defined by `intervalMs`. * - If a "PONG" response is not received within the `pongTimeoutMs`, the WebSocket is closed. * - The ping timer resets upon receiving any message to maintain activity. * - Automatically starts the ping process when the WebSocket connection is opened. * - Cleans up timers when the WebSocket is closed. * * @param ws - The WebSocket instance to manage. * @param options - Configuration options for ping-pong intervals and timeouts. */ function setupPingInterval(ws, { intervalMs, pongTimeoutMs }) { let pingTimeout; let pongTimeout; function start() { pingTimeout = setTimeout(() => { ws.send("PING"); pongTimeout = setTimeout(() => { ws.close(); }, pongTimeoutMs); }, intervalMs); } function reset() { clearTimeout(pingTimeout); start(); } function pong() { clearTimeout(pongTimeout); reset(); } ws.addEventListener("open", start); ws.addEventListener("message", ({ data }) => { clearTimeout(pingTimeout); start(); if (data === "PONG") pong(); }); ws.addEventListener("close", () => { clearTimeout(pingTimeout); clearTimeout(pongTimeout); }); } /** * Manages a WebSocket connection with support for reconnection, keep-alive mechanisms, * and observable state tracking. */ var WsConnection = class WsConnection { constructor(opts) { var _opts$WebSocketPonyfi; (0, import_defineProperty$1.default)(this, "id", ++WsConnection.connectCount); (0, import_defineProperty$1.default)(this, "WebSocketPonyfill", void 0); (0, import_defineProperty$1.default)(this, "urlOptions", void 0); (0, import_defineProperty$1.default)(this, "keepAliveOpts", void 0); (0, import_defineProperty$1.default)(this, "encoder", void 0); (0, import_defineProperty$1.default)(this, "wsObservable", (0, __trpc_server_observable.behaviorSubject)(null)); (0, import_defineProperty$1.default)(this, "openPromise", null); this.WebSocketPonyfill = (_opts$WebSocketPonyfi = opts.WebSocketPonyfill) !== null && _opts$WebSocketPonyfi !== void 0 ? _opts$WebSocketPonyfi : WebSocket; if (!this.WebSocketPonyfill) throw new Error("No WebSocket implementation found - you probably don't want to use this on the server, but if you do you need to pass a `WebSocket`-ponyfill"); this.urlOptions = opts.urlOptions; this.keepAliveOpts = opts.keepAlive; this.encoder = opts.encoder; } get ws() { return this.wsObservable.get(); } set ws(ws) { this.wsObservable.next(ws); } /** * Checks if the WebSocket connection is open and ready to communicate. */ isOpen() { return !!this.ws && this.ws.readyState === this.WebSocketPonyfill.OPEN && !this.openPromise; } /** * Checks if the WebSocket connection is closed or in the process of closing. */ isClosed() { return !!this.ws && (this.ws.readyState === this.WebSocketPonyfill.CLOSING || this.ws.readyState === this.WebSocketPonyfill.CLOSED); } async open() { var _this = this; if (_this.openPromise) return _this.openPromise; _this.id = ++WsConnection.connectCount; const wsPromise = prepareUrl(_this.urlOptions).then((url) => new _this.WebSocketPonyfill(url)); _this.openPromise = wsPromise.then(async (ws) => { _this.ws = ws; ws.binaryType = "arraybuffer"; ws.addEventListener("message", function({ data }) { if (data === "PING") this.send("PONG"); }); if (_this.keepAliveOpts.enabled) setupPingInterval(ws, _this.keepAliveOpts); ws.addEventListener("close", () => { if (_this.ws === ws) _this.ws = null; }); await asyncWsOpen(ws); if (_this.urlOptions.connectionParams) ws.send(await buildConnectionMessage(_this.urlOptions.connectionParams, _this.encoder)); }); try { await _this.openPromise; } finally { _this.openPromise = null; } } /** * Closes the WebSocket connection gracefully. * Waits for any ongoing open operation to complete before closing. */ async close() { var _this2 = this; try { await _this2.openPromise; } finally { var _this$ws; (_this$ws = _this2.ws) === null || _this$ws === void 0 || _this$ws.close(); } } }; (0, import_defineProperty$1.default)(WsConnection, "connectCount", 0); /** * Provides a backward-compatible representation of the connection state. */ function backwardCompatibility(connection) { if (connection.isOpen()) return { id: connection.id, state: "open", ws: connection.ws }; if (connection.isClosed()) return { id: connection.id, state: "closed", ws: connection.ws }; if (!connection.ws) return null; return { id: connection.id, state: "connecting", ws: connection.ws }; } //#endregion //#region src/links/wsLink/wsClient/wsClient.ts var import_defineProperty = require_chunk.__toESM(require_objectSpread2$1.require_defineProperty(), 1); var import_objectSpread2 = require_chunk.__toESM(require_objectSpread2$1.require_objectSpread2(), 1); /** * A WebSocket client for managing TRPC operations, supporting lazy initialization, * reconnection, keep-alive, and request management. */ var WsClient = class { constructor(opts) { var _opts$experimental_en, _opts$retryDelayMs; (0, import_defineProperty.default)(this, "connectionState", void 0); (0, import_defineProperty.default)(this, "allowReconnect", false); (0, import_defineProperty.default)(this, "requestManager", new RequestManager()); (0, import_defineProperty.default)(this, "activeConnection", void 0); (0, import_defineProperty.default)(this, "reconnectRetryDelay", void 0); (0, import_defineProperty.default)(this, "inactivityTimeout", void 0); (0, import_defineProperty.default)(this, "callbacks", void 0); (0, import_defineProperty.default)(this, "lazyMode", void 0); (0, import_defineProperty.default)(this, "encoder", void 0); (0, import_defineProperty.default)(this, "reconnecting", null); this.encoder = (_opts$experimental_en = opts.experimental_encoder) !== null && _opts$experimental_en !== void 0 ? _opts$experimental_en : jsonEncoder; this.callbacks = { onOpen: opts.onOpen, onClose: opts.onClose, onError: opts.onError }; const lazyOptions = (0, import_objectSpread2.default)((0, import_objectSpread2.default)({}, lazyDefaults), opts.lazy); this.inactivityTimeout = new ResettableTimeout(() => { if (this.requestManager.hasOutgoingRequests() || this.requestManager.hasPendingRequests()) { this.inactivityTimeout.reset(); return; } this.close().catch(() => null); }, lazyOptions.closeMs); this.activeConnection = new WsConnection({ WebSocketPonyfill: opts.WebSocket, urlOptions: opts, keepAlive: (0, import_objectSpread2.default)((0, import_objectSpread2.default)({}, keepAliveDefaults), opts.keepAlive), encoder: this.encoder }); this.activeConnection.wsObservable.subscribe({ next: (ws) => { if (!ws) return; this.setupWebSocketListeners(ws); } }); this.reconnectRetryDelay = (_opts$retryDelayMs = opts.retryDelayMs) !== null && _opts$retryDelayMs !== void 0 ? _opts$retryDelayMs : exponentialBackoff; this.lazyMode = lazyOptions.enabled; this.connectionState = (0, __trpc_server_observable.behaviorSubject)({ type: "state", state: lazyOptions.enabled ? "idle" : "connecting", error: null }); if (!this.lazyMode) this.open().catch(() => null); } /** * Opens the WebSocket connection. Handles reconnection attempts and updates * the connection state accordingly. */ async open() { var _this = this; _this.allowReconnect = true; if (_this.connectionState.get().state === "idle") _this.connectionState.next({ type: "state", state: "connecting", error: null }); try { await _this.activeConnection.open(); } catch (error) { _this.reconnect(new TRPCWebSocketClosedError({ message: "Initialization error", cause: error })); return _this.reconnecting; } } /** * Closes the WebSocket connection and stops managing requests. * Ensures all outgoing and pending requests are properly finalized. */ async close() { var _this2 = this; _this2.allowReconnect = false; _this2.inactivityTimeout.stop(); const requestsToAwait = []; for (const request of _this2.requestManager.getRequests()) if (request.message.method === "subscription") request.callbacks.complete(); else if (request.state === "outgoing") request.callbacks.error(require_TRPCClientError.TRPCClientError.from(new TRPCWebSocketClosedError({ message: "Closed before connection was established" }))); else requestsToAwait.push(request.end); await Promise.all(requestsToAwait).catch(() => null); await _this2.activeConnection.close().catch(() => null); _this2.connectionState.next({ type: "state", state: "idle", error: null }); } /** * Method to request the server. * Handles data transformation, batching of requests, and subscription lifecycle. * * @param op - The operation details including id, type, path, input and signal * @param transformer - Data transformer for serializing requests and deserializing responses * @param lastEventId - Optional ID of the last received event for subscriptions * * @returns An observable that emits operation results and handles cleanup */ request({ op: { id, type, path, input, signal }, transformer, lastEventId }) { return (0, __trpc_server_observable.observable)((observer) => { const abort = this.batchSend({ id, method: type, params: { input: transformer.input.serialize(input), path, lastEventId } }, (0, import_objectSpread2.default)((0, import_objectSpread2.default)({}, observer), {}, { next(event) { const transformed = (0, __trpc_server_unstable_core_do_not_import.transformResult)(event, transformer.output); if (!transformed.ok) { observer.error(require_TRPCClientError.TRPCClientError.from(transformed.error)); return; } observer.next({ result: transformed.result }); } })); return () => { abort(); if (type === "subscription" && this.activeConnection.isOpen()) this.send({ id, method: "subscription.stop" }); signal === null || signal === void 0 || signal.removeEventListener("abort", abort); }; }); } get connection() { return backwardCompatibility(this.activeConnection); } reconnect(closedError) { var _this3 = this; this.connectionState.next({ type: "state", state: "connecting", error: require_TRPCClientError.TRPCClientError.from(closedError) }); if (this.reconnecting) return; const tryReconnect = async (attemptIndex) => { try { await (0, __trpc_server_unstable_core_do_not_import.sleep)(_this3.reconnectRetryDelay(attemptIndex)); if (_this3.allowReconnect) { await _this3.activeConnection.close(); await _this3.activeConnection.open(); if (_this3.requestManager.hasPendingRequests()) _this3.send(_this3.requestManager.getPendingRequests().map(({ message }) => message)); } _this3.reconnecting = null; } catch (_unused) { await tryReconnect(attemptIndex + 1); } }; this.reconnecting = tryReconnect(0); } setupWebSocketListeners(ws) { var _this4 = this; const handleCloseOrError = (cause) => { const reqs = this.requestManager.getPendingRequests(); for (const { message, callbacks } of reqs) { if (message.method === "subscription") continue; callbacks.error(require_TRPCClientError.TRPCClientError.from(cause !== null && cause !== void 0 ? cause : new TRPCWebSocketClosedError({ message: "WebSocket closed", cause }))); this.requestManager.delete(message.id); } }; ws.addEventListener("open", () => { (0, __trpc_server_unstable_core_do_not_import.run)(async () => { var _this$callbacks$onOpe, _this$callbacks; if (_this4.lazyMode) _this4.inactivityTimeout.start(); (_this$callbacks$onOpe = (_this$callbacks = _this4.callbacks).onOpen) === null || _this$callbacks$onOpe === void 0 || _this$callbacks$onOpe.call(_this$callbacks); _this4.connectionState.next({ type: "state", state: "pending", error: null }); }).catch((error) => { ws.close(3e3); handleCloseOrError(error); }); }); ws.addEventListener("message", ({ data }) => { this.inactivityTimeout.reset(); if (["PING", "PONG"].includes(data)) return; const incomingMessage = this.encoder.decode(data); if ("method" in incomingMessage) { this.handleIncomingRequest(incomingMessage); return; } this.handleResponseMessage(incomingMessage); }); ws.addEventListener("close", (event) => { var _this$callbacks$onClo, _this$callbacks2; handleCloseOrError(event); (_this$callbacks$onClo = (_this$callbacks2 = this.callbacks).onClose) === null || _this$callbacks$onClo === void 0 || _this$callbacks$onClo.call(_this$callbacks2, event); if (!this.lazyMode || this.requestManager.hasPendingSubscriptions()) this.reconnect(new TRPCWebSocketClosedError({ message: "WebSocket closed", cause: event })); }); ws.addEventListener("error", (event) => { var _this$callbacks$onErr, _this$callbacks3; handleCloseOrError(event); (_this$callbacks$onErr = (_this$callbacks3 = this.callbacks).onError) === null || _this$callbacks$onErr === void 0 || _this$callbacks$onErr.call(_this$callbacks3, event); this.reconnect(new TRPCWebSocketClosedError({ message: "WebSocket closed", cause: event })); }); } handleResponseMessage(message) { const request = this.requestManager.getPendingRequest(message.id); if (!request) return; request.callbacks.next(message); let completed = true; if ("result" in message && request.message.method === "subscription") { if (message.result.type === "data") request.message.params.lastEventId = message.result.id; if (message.result.type !== "stopped") completed = false; } if (completed) { request.callbacks.complete(); this.requestManager.delete(message.id); } } handleIncomingRequest(message) { if (message.method === "reconnect") this.reconnect(new TRPCWebSocketClosedError({ message: "Server requested reconnect" })); } /** * Sends a message or batch of messages directly to the server. */ send(messageOrMessages) { if (!this.activeConnection.isOpen()) throw new Error("Active connection is not open"); const messages = messageOrMessages instanceof Array ? messageOrMessages : [messageOrMessages]; this.activeConnection.ws.send(this.encoder.encode(messages.length === 1 ? messages[0] : messages)); } /** * Groups requests for batch sending. * * @returns A function to abort the batched request. */ batchSend(message, callbacks) { var _this5 = this; this.inactivityTimeout.reset(); (0, __trpc_server_unstable_core_do_not_import.run)(async () => { if (!_this5.activeConnection.isOpen()) await _this5.open(); await (0, __trpc_server_unstable_core_do_not_import.sleep)(0); if (!_this5.requestManager.hasOutgoingRequests()) return; _this5.send(_this5.requestManager.flush().map(({ message: message$1 }) => message$1)); }).catch((err) => { this.requestManager.delete(message.id); callbacks.error(require_TRPCClientError.TRPCClientError.from(err)); }); return this.requestManager.register(message, callbacks); } }; //#endregion //#region src/links/wsLink/createWsClient.ts function createWSClient(opts) { return new WsClient(opts); } //#endregion //#region src/links/wsLink/wsLink.ts function wsLink(opts) { const { client } = opts; const transformer = require_unstable_internals.getTransformer(opts.transformer); return () => { return ({ op }) => { return (0, __trpc_server_observable.observable)((observer) => { const connStateSubscription = op.type === "subscription" ? client.connectionState.subscribe({ next(result) { observer.next({ result, context: op.context }); } }) : null; const requestSubscription = client.request({ op, transformer }).subscribe(observer); return () => { requestSubscription.unsubscribe(); connStateSubscription === null || connStateSubscription === void 0 || connStateSubscription.unsubscribe(); }; }); }; }; } //#endregion Object.defineProperty(exports, 'createWSClient', { enumerable: true, get: function () { return createWSClient; } }); Object.defineProperty(exports, 'jsonEncoder', { enumerable: true, get: function () { return jsonEncoder; } }); Object.defineProperty(exports, 'resultOf', { enumerable: true, get: function () { return resultOf; } }); Object.defineProperty(exports, 'wsLink', { enumerable: true, get: function () { return wsLink; } });