UNPKG

@orpc/client

Version:

<div align="center"> <image align="center" src="https://orpc.dev/logo.webp" width=280 alt="oRPC logo" /> </div>

486 lines (479 loc) • 16.6 kB
import { isAsyncIteratorObject, defer, value, splitInHalf, toArray, stringifyJSON, overlayProxy, AsyncIteratorClass } from '@orpc/shared'; import { toBatchRequest, parseBatchResponse, toBatchAbortSignal } from '@orpc/standard-server/batch'; import { replicateStandardLazyResponse, getEventMeta, flattenHeader } from '@orpc/standard-server'; import { C as COMMON_ORPC_ERROR_DEFS } from '../shared/client.BwSYEMrK.mjs'; class BatchLinkPlugin { groups; maxSize; batchUrl; maxUrlLength; batchHeaders; mapRequestItem; exclude; mode; pending; order = 5e6; constructor(options) { this.groups = options.groups; this.pending = /* @__PURE__ */ new Map(); this.maxSize = options.maxSize ?? 10; this.maxUrlLength = options.maxUrlLength ?? 2083; this.mode = options.mode ?? "streaming"; this.batchUrl = options.url ?? (([options2]) => `${options2.request.url.origin}${options2.request.url.pathname}/__batch__`); this.batchHeaders = options.headers ?? (([options2, ...rest]) => { const headers = {}; for (const [key, value2] of Object.entries(options2.request.headers)) { if (rest.every((item) => item.request.headers[key] === value2)) { headers[key] = value2; } } return headers; }); this.mapRequestItem = options.mapRequestItem ?? (({ request, batchHeaders }) => { const headers = {}; for (const [key, value2] of Object.entries(request.headers)) { if (batchHeaders[key] !== value2) { headers[key] = value2; } } return { method: request.method, url: request.url, headers, body: request.body, signal: request.signal }; }); this.exclude = options.exclude ?? (() => false); } init(options) { options.clientInterceptors ??= []; options.clientInterceptors.push((options2) => { if (options2.request.headers["x-orpc-batch"] !== "1") { return options2.next(); } return options2.next({ ...options2, request: { ...options2.request, headers: { ...options2.request.headers, "x-orpc-batch": void 0 } } }); }); options.clientInterceptors.push((options2) => { if (this.exclude(options2) || options2.request.body instanceof Blob || options2.request.body instanceof FormData || isAsyncIteratorObject(options2.request.body) || options2.request.signal?.aborted) { return options2.next(); } const group = this.groups.find((group2) => group2.condition(options2)); if (!group) { return options2.next(); } return new Promise((resolve, reject) => { this.#enqueueRequest(group, options2, resolve, reject); defer(() => this.#processPendingBatches()); }); }); } #enqueueRequest(group, options, resolve, reject) { const items = this.pending.get(group); if (items) { items.push([options, resolve, reject]); } else { this.pending.set(group, [[options, resolve, reject]]); } } async #processPendingBatches() { const pending = this.pending; this.pending = /* @__PURE__ */ new Map(); for (const [group, items] of pending) { const getItems = items.filter(([options]) => options.request.method === "GET"); const restItems = items.filter(([options]) => options.request.method !== "GET"); this.#executeBatch("GET", group, getItems); this.#executeBatch("POST", group, restItems); } } async #executeBatch(method, group, groupItems) { if (!groupItems.length) { return; } const batchItems = groupItems; if (batchItems.length === 1) { batchItems[0][0].next().then(batchItems[0][1]).catch(batchItems[0][2]); return; } try { const options = batchItems.map(([options2]) => options2); const maxSize = await value(this.maxSize, options); if (batchItems.length > maxSize) { const [first, second] = splitInHalf(batchItems); this.#executeBatch(method, group, first); this.#executeBatch(method, group, second); return; } const batchUrl = new URL(await value(this.batchUrl, options)); const batchHeaders = await value(this.batchHeaders, options); const mappedItems = batchItems.map(([options2]) => this.mapRequestItem({ ...options2, batchUrl, batchHeaders })); const batchRequest = toBatchRequest({ method, url: batchUrl, headers: batchHeaders, requests: mappedItems }); const maxUrlLength = await value(this.maxUrlLength, options); if (batchRequest.url.toString().length > maxUrlLength) { const [first, second] = splitInHalf(batchItems); this.#executeBatch(method, group, first); this.#executeBatch(method, group, second); return; } const mode = value(this.mode, options); try { const lazyResponse = await options[0].next({ request: { ...batchRequest, headers: { ...batchRequest.headers, "x-orpc-batch": mode } }, signal: batchRequest.signal, context: group.context, input: group.input, path: toArray(group.path) }); const parsed = parseBatchResponse({ ...lazyResponse, body: await lazyResponse.body() }); for await (const item of parsed) { batchItems[item.index]?.[1]({ ...item, body: () => Promise.resolve(item.body) }); } } catch (err) { if (batchRequest.signal?.aborted && batchRequest.signal.reason === err) { for (const [{ signal }, , reject] of batchItems) { if (signal?.aborted) { reject(signal.reason); } } } throw err; } throw new Error("Something went wrong make batch response not contains enough responses. This can be a bug please report it."); } catch (error) { for (const [, , reject] of batchItems) { reject(error); } } } } class DedupeRequestsPlugin { #groups; #filter; order = 4e6; // make sure execute before batch plugin #queue = /* @__PURE__ */ new Map(); constructor(options) { this.#groups = options.groups; this.#filter = options.filter ?? (({ request }) => request.method === "GET"); } init(options) { options.clientInterceptors ??= []; options.clientInterceptors.push((options2) => { if (options2.request.body instanceof Blob || options2.request.body instanceof FormData || options2.request.body instanceof URLSearchParams || isAsyncIteratorObject(options2.request.body) || !this.#filter(options2)) { return options2.next(); } const group = this.#groups.find((group2) => group2.condition(options2)); if (!group) { return options2.next(); } return new Promise((resolve, reject) => { this.#enqueue(group, options2, resolve, reject); defer(() => this.#dequeue()); }); }); } #enqueue(group, options, resolve, reject) { let queue = this.#queue.get(group); if (!queue) { this.#queue.set(group, queue = []); } const matched = queue.find((item) => { const requestString1 = stringifyJSON({ body: item.options.request.body, headers: item.options.request.headers, method: item.options.request.method, url: item.options.request.url }); const requestString2 = stringifyJSON({ body: options.request.body, headers: options.request.headers, method: options.request.method, url: options.request.url }); return requestString1 === requestString2; }); if (matched) { matched.signals.push(options.request.signal); matched.resolves.push(resolve); matched.rejects.push(reject); } else { queue.push({ options, signals: [options.request.signal], resolves: [resolve], rejects: [reject] }); } } async #dequeue() { const promises = []; for (const [group, items] of this.#queue) { for (const { options, signals, resolves, rejects } of items) { promises.push( this.#execute(group, options, signals, resolves, rejects) ); } } this.#queue.clear(); await Promise.all(promises); } async #execute(group, options, signals, resolves, rejects) { try { const dedupedRequest = { ...options.request, signal: toBatchAbortSignal(signals) }; const response = await options.next({ ...options, request: dedupedRequest, signal: dedupedRequest.signal, context: group.context }); const replicatedResponses = replicateStandardLazyResponse(response, resolves.length); for (const resolve of resolves) { resolve(replicatedResponses.shift()); } } catch (error) { for (const reject of rejects) { reject(error); } } } } class ClientRetryPluginInvalidEventIteratorRetryResponse extends Error { } class ClientRetryPlugin { defaultRetry; defaultRetryDelay; defaultShouldRetry; defaultOnRetry; order = 18e5; constructor(options = {}) { this.defaultRetry = options.default?.retry ?? 0; this.defaultRetryDelay = options.default?.retryDelay ?? ((o) => o.lastEventRetry ?? 2e3); this.defaultShouldRetry = options.default?.shouldRetry ?? true; this.defaultOnRetry = options.default?.onRetry; } init(options) { options.interceptors ??= []; options.interceptors.push(async (interceptorOptions) => { const maxAttempts = await value( interceptorOptions.context.retry ?? this.defaultRetry, interceptorOptions ); const retryDelay = interceptorOptions.context.retryDelay ?? this.defaultRetryDelay; const shouldRetry = interceptorOptions.context.shouldRetry ?? this.defaultShouldRetry; const onRetry = interceptorOptions.context.onRetry ?? this.defaultOnRetry; if (maxAttempts <= 0) { return interceptorOptions.next(); } let lastEventId = interceptorOptions.lastEventId; let lastEventRetry; let callback; let attemptIndex = 0; const next = async (initialError) => { let currentError = initialError; while (true) { const updatedInterceptorOptions = { ...interceptorOptions, lastEventId }; if (currentError) { if (attemptIndex >= maxAttempts) { throw currentError.error; } const attemptOptions = { ...updatedInterceptorOptions, attemptIndex, error: currentError.error, lastEventRetry }; const shouldRetryBool = await value( shouldRetry, attemptOptions ); if (!shouldRetryBool) { throw currentError.error; } callback = onRetry?.(attemptOptions); const retryDelayMs = await value(retryDelay, attemptOptions); await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); attemptIndex++; } try { currentError = void 0; return await interceptorOptions.next(updatedInterceptorOptions); } catch (error) { currentError = { error }; if (updatedInterceptorOptions.signal?.aborted) { throw error; } } finally { callback?.(!currentError); callback = void 0; } } }; const output = await next(); if (!isAsyncIteratorObject(output)) { return output; } let current = output; let isIteratorAborted = false; return overlayProxy(() => current, new AsyncIteratorClass( async () => { while (true) { try { const item = await current.next(); const meta = getEventMeta(item.value); lastEventId = meta?.id ?? lastEventId; lastEventRetry = meta?.retry ?? lastEventRetry; return item; } catch (error) { const meta = getEventMeta(error); lastEventId = meta?.id ?? lastEventId; lastEventRetry = meta?.retry ?? lastEventRetry; const maybeEventIterator = await next({ error }); if (!isAsyncIteratorObject(maybeEventIterator)) { throw new ClientRetryPluginInvalidEventIteratorRetryResponse( "RetryPlugin: Expected an Event Iterator, got a non-Event Iterator" ); } current = maybeEventIterator; if (isIteratorAborted) { await current.return?.(); throw error; } } } }, async (reason) => { isIteratorAborted = true; if (reason !== "next") { await current.return?.(); } } )); }); } } class RetryAfterPlugin { condition; maxAttempts; timeout; order = 19e5; constructor(options = {}) { this.condition = options.condition ?? ((response) => response.status === COMMON_ORPC_ERROR_DEFS.TOO_MANY_REQUESTS.status || response.status === COMMON_ORPC_ERROR_DEFS.SERVICE_UNAVAILABLE.status); this.maxAttempts = options.maxAttempts ?? 3; this.timeout = options.timeout ?? 5 * 60 * 1e3; } init(options) { options.clientInterceptors ??= []; options.clientInterceptors.push(async (interceptorOptions) => { const startTime = Date.now(); let attemptCount = 0; while (true) { attemptCount++; const response = await interceptorOptions.next(); if (!value(this.condition, response, interceptorOptions)) { return response; } const retryAfterHeader = flattenHeader(response.headers["retry-after"]); const retryAfterMs = this.parseRetryAfterHeader(retryAfterHeader); if (retryAfterMs === void 0) { return response; } if (attemptCount >= value(this.maxAttempts, response, interceptorOptions)) { return response; } const timeoutMs = value(this.timeout, response, interceptorOptions); const elapsedTime = Date.now() - startTime; if (elapsedTime + retryAfterMs > timeoutMs) { return response; } await this.delayExecution(retryAfterMs, interceptorOptions.signal); if (interceptorOptions.signal?.aborted) { return response; } } }); } parseRetryAfterHeader(value2) { value2 = value2?.trim(); if (!value2) { return void 0; } const seconds = Number(value2); if (Number.isFinite(seconds)) { return Math.max(0, seconds * 1e3); } const retryDate = Date.parse(value2); if (!Number.isNaN(retryDate)) { return Math.max(0, retryDate - Date.now()); } return void 0; } delayExecution(ms, signal) { return new Promise((resolve) => { if (signal?.aborted) { resolve(); return; } let timeout; const onAbort = () => { clearTimeout(timeout); timeout = void 0; resolve(); }; signal?.addEventListener("abort", onAbort, { once: true }); timeout = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); }); } } class SimpleCsrfProtectionLinkPlugin { headerName; headerValue; exclude; constructor(options = {}) { this.headerName = options.headerName ?? "x-csrf-token"; this.headerValue = options.headerValue ?? "orpc"; this.exclude = options.exclude ?? false; } order = 8e6; init(options) { options.clientInterceptors ??= []; options.clientInterceptors.push(async (options2) => { const excluded = await value(this.exclude, options2); if (excluded) { return options2.next(); } const headerName = await value(this.headerName, options2); const headerValue = await value(this.headerValue, options2); return options2.next({ ...options2, request: { ...options2.request, headers: { ...options2.request.headers, [headerName]: headerValue } } }); }); } } export { BatchLinkPlugin, ClientRetryPlugin, ClientRetryPluginInvalidEventIteratorRetryResponse, DedupeRequestsPlugin, RetryAfterPlugin, SimpleCsrfProtectionLinkPlugin };