@orpc/client
Version:
<div align="center"> <image align="center" src="https://orpc.unnoq.com/logo.webp" width=280 alt="oRPC logo" /> </div>
401 lines (395 loc) • 13.8 kB
JavaScript
import { isAsyncIteratorObject, defer, value, splitInHalf, toArray, stringifyJSON } from '@orpc/shared';
import { toBatchRequest, parseBatchResponse, toBatchAbortSignal } from '@orpc/standard-server/batch';
import { replicateStandardLazyResponse, getEventMeta } from '@orpc/standard-server';
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;
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 === true) {
throw error;
}
} finally {
callback?.(!currentError);
callback = void 0;
}
}
};
const output = await next();
if (!isAsyncIteratorObject(output)) {
return output;
}
return async function* () {
let current = output;
try {
while (true) {
try {
const item = await current.next();
const meta = getEventMeta(item.value);
lastEventId = meta?.id ?? lastEventId;
lastEventRetry = meta?.retry ?? lastEventRetry;
if (item.done) {
return item.value;
}
yield item.value;
} 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;
}
}
} finally {
await current.return?.();
}
}();
});
}
}
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, SimpleCsrfProtectionLinkPlugin };