@trpc/client
Version:
1,077 lines (1,076 loc) • 40 kB
JavaScript
const require_objectSpread2 = require("./objectSpread2-D-dJi1gK.cjs");
const require_TRPCClientError = require("./TRPCClientError-CPEwAI4E.cjs");
const require_unstable_internals = require("./unstable-internals.cjs");
let _trpc_server_observable = require("@trpc/server/observable");
let _trpc_server_unstable_core_do_not_import = require("@trpc/server/unstable-core-do-not-import");
//#region src/links/wsLink/wsClient/encoder.ts
/**
* The default WebSocket encoder. Sends messages as JSON text frames.
*/
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 TRPCWebSocketClosedError = class TRPCWebSocketClosedError extends Error {
constructor(opts) {
super(opts.message, { cause: opts.cause });
this.name = "TRPCWebSocketClosedError";
Object.setPrototypeOf(this, TRPCWebSocketClosedError.prototype);
}
};
/**
* Calls a user callback, including `connectionState` subscribers. A throw
* would skip the rest of the client's bookkeeping (e.g. end the reconnect
* loop, or, with event-emitter based WebSockets such as `ws`, skip the other
* listeners for that event), so it is rethrown asynchronously instead.
*/
function callUserCallback(callback) {
try {
callback();
} catch (error) {
setTimeout(() => {
throw error;
});
}
}
/**
* 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;
require_objectSpread2._defineProperty(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;
return {
promise: new Promise((res, rej) => {
resolve = res;
reject = rej;
}),
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;
return url + `${url.includes("?") ? "&" : "?"}connectionParams=1`;
}
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
/**
* 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() {
require_objectSpread2._defineProperty(this, "outgoingRequests", new Array());
require_objectSpread2._defineProperty(this, "pendingRequests", {});
require_objectSpread2._defineProperty(this, "drainingRequests", {});
}
/**
* 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,
ws: null,
successor: null,
overlapEventIds: null,
movedFrom: /* @__PURE__ */ 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.
*/
delete(message) {
var _this$pendingRequests;
this.outgoingRequests = this.outgoingRequests.filter((request) => request.message !== message);
const id = String(message.id);
if (((_this$pendingRequests = this.pendingRequests[id]) === null || _this$pendingRequests === void 0 ? void 0 : _this$pendingRequests.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.
*/
flush(ws = null) {
const requests = this.outgoingRequests;
this.outgoingRequests = [];
for (const request of requests) {
request.ws = ws;
request.successor = null;
request.movedFrom = /* @__PURE__ */ 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()`.
*/
requeue(message, id) {
const earlierId = String(message.id);
const request = this.pendingRequests[earlierId];
if ((request === null || request === void 0 ? void 0 : request.message) !== message) return;
delete this.pendingRequests[earlierId];
if (request.movedFrom.size) this.drainingRequests[earlierId] = request;
message.id = id;
this.outgoingRequests.push(require_objectSpread2._objectSpread2(require_objectSpread2._objectSpread2({}, request), {}, {
id: String(id),
ws: null,
successor: null,
overlapEventIds: null,
movedFrom: /* @__PURE__ */ new Set()
}));
}
/**
* 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 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.
*/
getDrainingRequest(messageId) {
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.
*/
getDrainingRequests() {
return Object.values(this.drainingRequests);
}
/**
* Forgets the earlier ids of subscriptions no longer draining any socket.
*/
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.
*/
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 a message is still waiting to be sent.
*/
isOutgoing(message) {
return this.outgoingRequests.some((request) => request.message === message);
}
/**
* 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
/**
* 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;
}
const pongCallbacks = /* @__PURE__ */ new WeakMap();
/**
* Sends "PING" on `ws` and calls `onPong`, if given, once its "PONG"
* arrives. The server answers in order, so by then everything it sent before
* it read this "PING" has arrived too.
*/
function sendPing(ws, onPong) {
let callbacks = pongCallbacks.get(ws);
if (!callbacks) {
const queue = [];
callbacks = queue;
pongCallbacks.set(ws, queue);
ws.addEventListener("message", ({ data }) => {
var _queue$shift;
if (data === "PONG") (_queue$shift = queue.shift()) === null || _queue$shift === void 0 || _queue$shift();
});
}
callbacks.push(onPong);
ws.send("PING");
}
/**
* 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(() => {
sendPing(ws, null);
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;
require_objectSpread2._defineProperty(this, "id", ++WsConnection.connectCount);
require_objectSpread2._defineProperty(this, "WebSocketPonyfill", void 0);
require_objectSpread2._defineProperty(this, "urlOptions", void 0);
require_objectSpread2._defineProperty(this, "keepAliveOpts", void 0);
require_objectSpread2._defineProperty(this, "encoder", void 0);
require_objectSpread2._defineProperty(this, "wsObservable", (0, _trpc_server_observable.behaviorSubject)(null));
require_objectSpread2._defineProperty(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() {
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() {
try {
await this.openPromise;
} finally {
var _this$ws;
(_this$ws = this.ws) === null || _this$ws === void 0 || _this$ws.close();
}
}
};
require_objectSpread2._defineProperty(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
/**
* 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;
require_objectSpread2._defineProperty(this, "connectionState", void 0);
require_objectSpread2._defineProperty(this, "allowReconnect", false);
require_objectSpread2._defineProperty(this, "requestManager", new RequestManager());
require_objectSpread2._defineProperty(this, "lastRequestId", 0);
require_objectSpread2._defineProperty(this, "connectionOptions", void 0);
require_objectSpread2._defineProperty(this, "activeConnection", void 0);
require_objectSpread2._defineProperty(this, "reconnectRetryDelay", void 0);
require_objectSpread2._defineProperty(this, "inactivityTimeout", void 0);
require_objectSpread2._defineProperty(this, "callbacks", void 0);
require_objectSpread2._defineProperty(this, "lazyMode", void 0);
require_objectSpread2._defineProperty(this, "encoder", void 0);
require_objectSpread2._defineProperty(this, "reconnecting", null);
require_objectSpread2._defineProperty(this, "nextConnection", null);
require_objectSpread2._defineProperty(this, "retiredConnection", null);
require_objectSpread2._defineProperty(this, "replacementError", null);
require_objectSpread2._defineProperty(this, "connectionAttemptIndex", 0);
require_objectSpread2._defineProperty(this, "connectionRejected", false);
require_objectSpread2._defineProperty(this, "started", /* @__PURE__ */ new WeakSet());
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 = require_objectSpread2._objectSpread2(require_objectSpread2._objectSpread2({}, 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.connectionOptions = {
WebSocketPonyfill: opts.WebSocket,
urlOptions: opts,
keepAlive: require_objectSpread2._objectSpread2(require_objectSpread2._objectSpread2({}, keepAliveDefaults), opts.keepAlive),
encoder: this.encoder
};
this.activeConnection = this.createConnection();
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);
}
createConnection() {
const connection = new WsConnection(this.connectionOptions);
connection.wsObservable.subscribe({ next: (ws) => {
if (!ws) return;
this.setupWebSocketListeners(ws);
} });
return connection;
}
/**
* Opens the WebSocket connection. Handles reconnection attempts and updates
* the connection state accordingly.
*/
async open() {
this.allowReconnect = true;
if (this.reconnecting) return this.reconnecting;
this.connectionAttemptIndex = 0;
if (this.connectionState.get().state === "idle") callUserCallback(() => 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
}));
await this.activeConnection.close().catch(() => null);
return this.reconnecting;
}
}
/**
* Closes the WebSocket connection and stops managing requests.
* Ensures all outgoing and pending requests are properly finalized.
*/
async close() {
var _this$nextConnection;
this.allowReconnect = false;
this.replacementError = null;
this.retiredConnection = null;
this.inactivityTimeout.stop();
const requestsToAwait = [];
for (const request of this.requestManager.getRequests()) if (request.message.method === "subscription") callUserCallback(() => request.callbacks.complete());
else if (request.state === "outgoing") callUserCallback(() => 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);
(_this$nextConnection = this.nextConnection) === null || _this$nextConnection === void 0 || (_this$nextConnection = _this$nextConnection.ws) === null || _this$nextConnection === void 0 || _this$nextConnection.close();
await this.activeConnection.close().catch(() => null);
callUserCallback(() => this.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 type, path, input and signal. Each subscription to the returned observable is sent with a new id, not `op.id`.
* @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: { type, path, input, signal }, transformer, lastEventId }) {
return (0, _trpc_server_observable.observable)((observer) => {
const message = {
id: ++this.lastRequestId,
method: type,
params: {
input: transformer.input.serialize(input),
path,
lastEventId
}
};
const abort = this.batchSend(message, require_objectSpread2._objectSpread2(require_objectSpread2._objectSpread2({}, 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 });
} }));
const onAbort = () => observer.complete();
if (signal === null || signal === void 0 ? void 0 : signal.aborted) onAbort();
else signal === null || signal === void 0 || signal.addEventListener("abort", onAbort, { once: true });
return () => {
const { id } = message;
const pending = this.requestManager.getPendingRequest(id);
const draining = this.requestManager.getDrainingRequests().filter((request) => request.message === message);
abort();
if (type === "subscription" && pending) {
this.stopSubscription(id, pending.ws);
this.stopSubscription(id, pending.successor);
}
this.closeIfDrained(pending === null || pending === void 0 ? void 0 : pending.ws);
for (const request of [pending, ...draining]) {
var _request$movedFrom;
for (const earlier of (_request$movedFrom = request === null || request === void 0 ? void 0 : request.movedFrom) !== null && _request$movedFrom !== void 0 ? _request$movedFrom : []) this.closeIfDrained(earlier);
}
signal === null || signal === void 0 || signal.removeEventListener("abort", onAbort);
};
});
}
/**
* The current WebSocket and its state, or `null` when there is no socket.
*/
get connection() {
var _this$nextConnection2;
return backwardCompatibility(this.activeConnection.ws ? this.activeConnection : (_this$nextConnection2 = this.nextConnection) !== null && _this$nextConnection2 !== void 0 ? _this$nextConnection2 : this.activeConnection);
}
/**
* Opens a new connection, retrying until one opens. While the current
* socket is open, it keeps serving every request, new ones included, until
* the new one is fully open, `connectionParams` included.
*
* @param closedError - Why the current socket was lost, or `null` when it's
* still open and serving requests, e.g. on a server-requested reconnect.
*/
reconnect(closedError) {
if (!this.allowReconnect) return;
if (closedError) {
var _this$replacementErro;
const error = (_this$replacementErro = this.replacementError) !== null && _this$replacementErro !== void 0 ? _this$replacementErro : closedError;
this.replacementError = null;
callUserCallback(() => this.connectionState.next({
type: "state",
state: "connecting",
error: require_TRPCClientError.TRPCClientError.from(error)
}));
}
if (this.reconnecting) return;
const tryReconnect = async (attemptIndex) => {
try {
await (0, _trpc_server_unstable_core_do_not_import.sleep)(this.reconnectRetryDelay(attemptIndex));
if (this.allowReconnect) {
const next = this.createConnection();
this.nextConnection = next;
try {
await next.open();
if (!next.isOpen()) throw new Error("WebSocket closed before it was ready");
} catch (error) {
await next.close().catch(() => null);
throw error;
} finally {
this.nextConnection = null;
}
if (this.allowReconnect) {
const previous = this.activeConnection;
this.activeConnection = next;
this.retiredConnection = previous.isOpen() ? previous : null;
this.connectionAttemptIndex = attemptIndex;
this.replacementError = null;
this.handOff(previous.ws);
} else await next.close();
}
this.reconnecting = null;
} catch (error) {
const closedError = new TRPCWebSocketClosedError({
message: "Initialization error",
cause: error
});
if (this.allowReconnect && this.activeConnection.isOpen()) this.replacementError = closedError;
else if (this.allowReconnect) callUserCallback(() => this.connectionState.next({
type: "state",
state: "connecting",
error: require_TRPCClientError.TRPCClientError.from(closedError)
}));
await tryReconnect(attemptIndex + 1);
}
};
this.reconnecting = tryReconnect(this.connectionRejected ? this.connectionAttemptIndex + 1 : 0);
}
/**
* Moves the pending subscriptions to the active socket, which has just
* opened: each is resubscribed with the id of the last event it delivered.
* A subscription whose old socket is still open keeps delivering from it
* until the new socket confirms it with `started`; see `confirmMove()`.
* Closes the previous socket once nothing is left on it.
*/
handOff(previous) {
if (this.connectionState.get().state !== "pending" && this.activeConnection.isOpen()) callUserCallback(() => this.connectionState.next({
type: "state",
state: "pending",
error: null
}));
const ws = this.activeConnection.ws;
const subscriptions = this.requestManager.getPendingRequests().filter((request) => request.message.method === "subscription" && request.ws !== ws);
if (subscriptions.length) {
this.send(subscriptions.map(({ message }) => message));
for (const request of subscriptions) {
this.stopSubscription(request.message.id, request.successor);
if (request.ws && request.ws.readyState === request.ws.OPEN) {
request.successor = ws;
request.overlapEventIds = /* @__PURE__ */ new Set();
} else {
this.started.delete(request.message);
request.ws = ws;
request.successor = null;
}
}
}
this.closeIfDrained(previous);
}
/**
* Completes a subscription's move once its new socket has confirmed it:
* stops it on the old socket, whose later messages for it are dropped
* apart from events without ids, and closes that socket once everything it
* sent before the stop has arrived and nothing else is left on it.
*/
confirmMove(request) {
const previous = request.ws;
request.ws = request.successor;
request.successor = null;
if (previous && this.stopSubscription(request.message.id, previous)) {
request.movedFrom.add(previous);
sendPing(previous, () => this.releaseMovedFrom(request, previous));
}
this.closeIfDrained(previous);
}
releaseMovedFrom(request, ws) {
if (!request.movedFrom.delete(ws)) return;
this.requestManager.deleteDrained();
this.closeIfDrained(ws);
}
hasRequestsOn(ws) {
return [...this.requestManager.getPendingRequests(), ...this.requestManager.getDrainingRequests()].some((request) => request.ws === ws || ws !== null && request.movedFrom.has(ws));
}
/**
* Closes `ws` once it's been replaced and nothing is left on it.
*/
closeIfDrained(ws) {
if (!ws || ws === this.activeConnection.ws) return;
if (this.hasRequestsOn(ws)) return;
ws.close();
}
/**
* Stops a subscription on the socket it was sent on, if that's still open.
*
* @returns Whether the stop was sent.
*/
stopSubscription(id, ws) {
if (!ws || ws.readyState !== ws.OPEN) return false;
ws.send(this.encoder.encode({
id,
method: "subscription.stop"
}));
return true;
}
/**
* Fails the pending queries and mutations sent on `ws`, and returns its
* pending subscriptions, which are kept to be sent again.
*/
failPendingRequests(ws, cause) {
const subscriptions = [];
for (const request of this.requestManager.getPendingRequests()) {
const { message, callbacks } = request;
if (request.ws !== ws) continue;
if (message.method === "subscription") {
subscriptions.push(request);
continue;
}
callUserCallback(() => callbacks.error(require_TRPCClientError.TRPCClientError.from(cause !== null && cause !== void 0 ? cause : new TRPCWebSocketClosedError({
message: "WebSocket closed",
cause
}))));
this.requestManager.delete(message);
}
return subscriptions;
}
setupWebSocketListeners(ws) {
this.connectionRejected = false;
/**
* Fails the queries and mutations sent on `ws`. Its subscriptions are
* sent again on the active socket if that's open, or else by
* `reconnect()` once the next one opens.
*/
const handleCloseOrError = (cause) => {
var _this$retiredConnecti;
if (ws === ((_this$retiredConnecti = this.retiredConnection) === null || _this$retiredConnecti === void 0 ? void 0 : _this$retiredConnecti.ws)) this.retiredConnection = null;
const retired = this.retiredConnection;
let restored = false;
if (ws === this.activeConnection.ws && (retired === null || retired === void 0 ? void 0 : retired.isOpen())) {
restored = true;
this.activeConnection = retired;
this.retiredConnection = null;
this.replacementError = new TRPCWebSocketClosedError({
message: "WebSocket closed",
cause
});
}
for (const request of this.requestManager.getPendingRequests()) {
if (request.successor === ws) {
request.successor = null;
request.overlapEventIds = null;
}
request.movedFrom.delete(ws);
}
for (const request of this.requestManager.getDrainingRequests()) request.movedFrom.delete(ws);
this.requestManager.deleteDrained();
const lost = this.failPendingRequests(ws, cause).filter((request) => {
if (!request.successor) return true;
request.ws = request.successor;
request.successor = null;
return false;
});
if (ws === this.activeConnection.ws || this.reconnecting && !this.activeConnection.isOpen()) return;
for (const { message, callbacks } of lost) {
this.started.delete(message);
this.requestManager.requeue(message, ++this.lastRequestId);
this.flushOutgoing(message, callbacks);
}
if (restored) this.reconnect(null);
};
ws.addEventListener("open", () => {
(0, _trpc_server_unstable_core_do_not_import.run)(async () => {
var _this$callbacks$onOpe, _this$callbacks;
if (this.lazyMode) this.inactivityTimeout.start();
(_this$callbacks$onOpe = (_this$callbacks = this.callbacks).onOpen) === null || _this$callbacks$onOpe === void 0 || _this$callbacks$onOpe.call(_this$callbacks);
if (this.connectionState.get().state === "pending" || ws !== this.activeConnection.ws && this.activeConnection.isOpen()) return;
callUserCallback(() => this.connectionState.next({
type: "state",
state: "pending",
error: null
}));
}).catch((error) => {
ws.close(3e3);
handleCloseOrError(error);
});
});
const onMessage = ({ data }) => {
var _this$nextConnection3;
this.inactivityTimeout.reset();
if (["PING", "PONG"].includes(data)) return;
const incomingMessage = this.encoder.decode(data);
if ("method" in incomingMessage) {
if (ws === this.activeConnection.ws) this.handleIncomingRequest(incomingMessage);
return;
}
const isRetired = ws !== this.activeConnection.ws && ws !== ((_this$nextConnection3 = this.nextConnection) === null || _this$nextConnection3 === void 0 ? void 0 : _this$nextConnection3.ws);
if (!isRetired && incomingMessage.id === null && "error" in incomingMessage) this.connectionRejected = true;
else if (!isRetired && "result" in incomingMessage) this.connectionRejected = false;
this.handleResponseMessage(incomingMessage, ws);
};
ws.addEventListener("message", (event) => {
callUserCallback(() => onMessage(event));
});
ws.addEventListener("close", (event) => {
handleCloseOrError(event);
callUserCallback(() => {
var _this$callbacks$onClo, _this$callbacks2;
return (_this$callbacks$onClo = (_this$callbacks2 = this.callbacks).onClose) === null || _this$callbacks$onClo === void 0 ? void 0 : _this$callbacks$onClo.call(_this$callbacks2, event);
});
if (ws === this.activeConnection.ws && (!this.lazyMode || this.requestManager.hasPendingSubscriptions())) this.reconnect(new TRPCWebSocketClosedError({
message: "WebSocket closed",
cause: event
}));
});
ws.addEventListener("error", (event) => {
handleCloseOrError(event);
callUserCallback(() => {
var _this$callbacks$onErr, _this$callbacks3;
return (_this$callbacks$onErr = (_this$callbacks3 = this.callbacks).onError) === null || _this$callbacks$onErr === void 0 ? void 0 : _this$callbacks$onErr.call(_this$callbacks3, event);
});
if (ws !== this.activeConnection.ws) return;
this.reconnect(new TRPCWebSocketClosedError({
message: "WebSocket closed",
cause: event
}));
});
}
handleResponseMessage(message, ws) {
var _this$requestManager$;
const request = (_this$requestManager$ = this.requestManager.getPendingRequest(message.id)) !== null && _this$requestManager$ !== void 0 ? _this$requestManager$ : this.requestManager.getDrainingRequest(message.id);
if (!request) return;
const result = "result" in message ? message.result : null;
if (request.successor === ws) this.confirmMove(request);
else if (request.ws !== ws) {
if (request.movedFrom.has(ws)) {
if ((result === null || result === void 0 ? void 0 : result.type) === "stopped") this.releaseMovedFrom(request, ws);
else if ((result === null || result === void 0 ? void 0 : result.type) === "data" && result.id === void 0) callUserCallback(() => request.callbacks.next(message));
}
return;
}
if ((result === null || result === void 0 ? void 0 : result.type) === "started" && this.started.has(request.message)) return;
if ((result === null || result === void 0 ? void 0 : result.type) === "data" && result.id !== void 0) {
var _request$overlapEvent2;
const eventId = String(result.id);
if (request.successor) {
var _request$overlapEvent;
(_request$overlapEvent = request.overlapEventIds) === null || _request$overlapEvent === void 0 || _request$overlapEvent.add(eventId);
} else if ((_request$overlapEvent2 = request.overlapEventIds) === null || _request$overlapEvent2 === void 0 ? void 0 : _request$overlapEvent2.delete(eventId)) return;
}
callUserCallback(() => 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;
else if (message.result.type === "started") this.started.add(request.message);
if (message.result.type !== "stopped") completed = false;
}
if (completed) {
callUserCallback(() => request.callbacks.complete());
this.requestManager.delete(request.message);
}
}
handleIncomingRequest(message) {
if (message.method === "reconnect") this.reconnect(null);
}
/**
* 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) {
this.inactivityTimeout.reset();
this.flushOutgoing(message, callbacks);
return this.requestManager.register(message, callbacks);
}
/**
* Sends `message` with the other outgoing requests once the connection is
* open, batching those queued in the same tick.
*/
flushOutgoing(message, callbacks) {
(0, _trpc_server_unstable_core_do_not_import.run)(async () => {
do {
if (!this.activeConnection.isOpen()) await this.open();
await (0, _trpc_server_unstable_core_do_not_import.sleep)(0);
} while (!this.activeConnection.isOpen() && this.reconnecting);
if (!this.requestManager.hasOutgoingRequests()) return;
if (!this.activeConnection.isOpen() && !this.requestManager.isOutgoing(message)) return;
const requests = this.requestManager.flush(this.activeConnection.ws);
try {
this.send(requests.map((request) => request.message));
} catch (err) {
for (const request of requests) {
this.requestManager.delete(request.message);
callUserCallback(() => request.callbacks.error(require_TRPCClientError.TRPCClientError.from(err)));
}
}
}).catch((err) => {
this.requestManager.delete(message);
callUserCallback(() => callbacks.error(require_TRPCClientError.TRPCClientError.from(err)));
});
}
};
//#endregion
//#region src/links/wsLink/createWsClient.ts
/**
* Opens and manages a WebSocket connection to a tRPC server. Pass it to
* `wsLink`.
*
* @see https://trpc.io/docs/client/links/wsLink#wslink--createwsclient-options
*/
function createWSClient(opts) {
return new WsClient(opts);
}
//#endregion
//#region src/links/wsLink/wsLink.ts
/**
* A terminating link that sends calls over a WebSocket.
*
* @see https://trpc.io/docs/client/links/wsLink
*/
function wsLink(opts) {
const { client } = opts;
const transformer = require_unstable_internals.getTransformer(opts.transformer);
return () => {
return ({ op }) => {
return (0, _trpc_server_observable.observable)((observer) => {
let connStateSubscription = null;
if (op.type === "subscription") {
let isSubscribing = true;
connStateSubscription = client.connectionState.subscribe({ next(result) {
const emit = () => observer.next({
result,
context: op.context
});
if (isSubscribing) callUserCallback(emit);
else emit();
} });
isSubscribing = false;
}
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;
}
});