@orpc/client 2.0.0-beta.4 → 2.0.0-beta.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +97 -94
  2. package/dist/adapters/fetch/index.d.mts +16 -33
  3. package/dist/adapters/fetch/index.d.ts +16 -33
  4. package/dist/adapters/fetch/index.mjs +33 -30
  5. package/dist/adapters/message-port/index.d.mts +13 -8
  6. package/dist/adapters/message-port/index.d.ts +13 -8
  7. package/dist/adapters/message-port/index.mjs +27 -17
  8. package/dist/adapters/standard/index.d.mts +9 -10
  9. package/dist/adapters/standard/index.d.ts +9 -10
  10. package/dist/adapters/standard/index.mjs +5 -5
  11. package/dist/adapters/websocket/index.d.mts +33 -18
  12. package/dist/adapters/websocket/index.d.ts +33 -18
  13. package/dist/adapters/websocket/index.mjs +45 -24
  14. package/dist/index.d.mts +62 -61
  15. package/dist/index.d.ts +62 -61
  16. package/dist/index.mjs +47 -54
  17. package/dist/plugins/index.d.mts +174 -13
  18. package/dist/plugins/index.d.ts +174 -13
  19. package/dist/plugins/index.mjs +580 -198
  20. package/dist/shared/{client.BdItY5DT.d.mts → client.BPa_Mmuh.d.mts} +25 -4
  21. package/dist/shared/{client.BdItY5DT.d.ts → client.BPa_Mmuh.d.ts} +25 -4
  22. package/dist/shared/{client.Cby_-GGh.d.mts → client.BjbdW5Rj.d.mts} +2 -2
  23. package/dist/shared/{client.DXhchJ84.mjs → client.Bstqyaro.mjs} +25 -30
  24. package/dist/shared/{client.Dnfj8jnT.mjs → client.DD2eNne_.mjs} +20 -15
  25. package/dist/shared/{client.CPF3hX6O.d.ts → client.DiHfJmHK.d.ts} +2 -2
  26. package/dist/shared/client.DpkGsZt-.mjs +486 -0
  27. package/dist/shared/{client.8ug8I-zu.d.mts → client.Dw9_40WO.d.mts} +57 -16
  28. package/dist/shared/{client.8ug8I-zu.d.ts → client.Dw9_40WO.d.ts} +57 -16
  29. package/package.json +22 -8
  30. package/dist/shared/client.DMXKFDyV.mjs +0 -343
@@ -1,15 +1,16 @@
1
- import { toArray, value, splitInHalf, stringifyJSON, isAsyncIteratorObject, defer, loadBytes, allAbortSignal, replicateAsyncIterator, replicateReadableStream, override, AsyncIteratorClass, sleep } from '@orpc/shared';
2
- import { parseStandardUrl, getEventMeta, flattenStandardHeader } from '@standardserver/core';
3
- import { ClientPeer, isServerPeerSendMessage, decodePeerMessage } from '@standardserver/peer';
4
- import { C as COMMON_ERROR_STATUS_MAP } from '../shared/client.Dnfj8jnT.mjs';
1
+ import { toArray, value, splitInHalf, promiseWithResolvers, safeEncodeURIComponent, stringifyJSON, once, isAsyncIteratorObject, defer, captureAsyncContext, loadBytes, allAbortSignal, runWithSignal, replicateAsyncIterator, replicateReadableStream, wrapAsyncIterator, throwIfAborted, isCompressibleContentType, override, AsyncIteratorClass, sleep, AbortError, anyAbortSignal } from '@orpc/shared';
2
+ import { parseStandardUrl, flattenStandardHeader, generateContentDisposition, getEventMeta } from '@standard-server/core';
3
+ import { ClientPeer, isServerPeerSendMessage, decodePeerMessage } from '@standard-server/peer';
4
+ import { toFetchHeaders, toStandardBody } from '@standard-server/fetch';
5
+ import { C as COMMON_ERROR_STATUS_MAP } from '../shared/client.DD2eNne_.mjs';
5
6
 
6
- class BatchLinkPluginError extends TypeError {
7
- }
8
7
  class BatchLinkPlugin {
9
8
  name = "~batch";
10
9
  groups;
11
10
  filter;
11
+ scope;
12
12
  maxSize;
13
+ wait;
13
14
  mode;
14
15
  batchUrl;
15
16
  maxUrlLength;
@@ -20,7 +21,9 @@ class BatchLinkPlugin {
20
21
  constructor(options) {
21
22
  this.groups = options.groups;
22
23
  this.filter = options.filter ?? (() => true);
24
+ this.scope = options.scope ?? (() => void 0);
23
25
  this.maxSize = options.maxSize ?? 10;
26
+ this.wait = options.wait ?? 0;
24
27
  this.mode = options.mode ?? "streaming";
25
28
  this.batchUrl = options.url ?? ((options2) => {
26
29
  const [pathname] = parseStandardUrl(options2[0].request.url);
@@ -31,7 +34,8 @@ class BatchLinkPlugin {
31
34
  const headersList = options2.map((o) => o.request.headers);
32
35
  const commonHeaders = {};
33
36
  for (const headers of headersList) {
34
- for (const [key, value2] of Object.entries(headers)) {
37
+ for (const key of Object.keys(headers)) {
38
+ const value2 = headers[key];
35
39
  if (headersList.every((h) => h[key] === value2)) {
36
40
  commonHeaders[key] = value2;
37
41
  }
@@ -41,7 +45,8 @@ class BatchLinkPlugin {
41
45
  });
42
46
  this.mapSubrequest = options.mapSubrequest ?? (({ request }, { headers }) => {
43
47
  const subHeaders = { ...request.headers };
44
- for (const [key, value2] of Object.entries(headers)) {
48
+ for (const key of Object.keys(headers)) {
49
+ const value2 = headers[key];
45
50
  if (subHeaders[key] === value2) {
46
51
  subHeaders[key] = void 0;
47
52
  }
@@ -51,7 +56,7 @@ class BatchLinkPlugin {
51
56
  headers: subHeaders
52
57
  };
53
58
  });
54
- this.mapSubresponse = (subResponse, batchResponse) => {
59
+ this.mapSubresponse = options.mapSubresponse ?? ((subResponse, batchResponse) => {
55
60
  return {
56
61
  ...subResponse,
57
62
  headers: {
@@ -60,24 +65,34 @@ class BatchLinkPlugin {
60
65
  ...subResponse.headers
61
66
  }
62
67
  };
63
- };
68
+ });
64
69
  }
65
70
  init(options) {
66
71
  const transportInterceptor = async (interceptorOptions) => {
67
- if (interceptorOptions.request.body instanceof Blob || interceptorOptions.request.body instanceof ReadableStream || isAsyncIteratorObject(interceptorOptions.request.body) || interceptorOptions.request.signal?.aborted || !value(this.filter, interceptorOptions)) {
72
+ const { body, signal } = interceptorOptions.request;
73
+ if (body instanceof Blob || body instanceof FormData || body instanceof ReadableStream || isAsyncIteratorObject(body) || signal?.aborted || !value(this.filter, interceptorOptions)) {
68
74
  return interceptorOptions.next();
69
75
  }
70
76
  const group = this.groups.find((group2) => value(group2.condition, interceptorOptions));
71
77
  if (!group) {
72
78
  return interceptorOptions.next();
73
79
  }
80
+ const scope = this.scope(interceptorOptions);
74
81
  return new Promise((resolve, reject) => {
75
- const queue = this.queue.get(group) ?? [];
76
- if (!this.queue.has(group)) {
77
- this.queue.set(group, queue);
82
+ if (!this.queue.size) {
83
+ defer(() => this.processPendingBatches(), this.wait);
84
+ }
85
+ let groups = this.queue.get(scope);
86
+ if (!groups) {
87
+ groups = /* @__PURE__ */ new Map();
88
+ this.queue.set(scope, groups);
89
+ }
90
+ let queue = groups.get(group);
91
+ if (!queue) {
92
+ queue = [];
93
+ groups.set(group, queue);
78
94
  }
79
- queue.push([interceptorOptions, resolve, reject]);
80
- defer(() => this.processPendingBatches());
95
+ queue.push([interceptorOptions, resolve, reject, captureAsyncContext()]);
81
96
  });
82
97
  };
83
98
  return {
@@ -85,108 +100,169 @@ class BatchLinkPlugin {
85
100
  transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor]
86
101
  };
87
102
  }
88
- async processPendingBatches() {
89
- const pending = new Map(this.queue);
103
+ processPendingBatches() {
104
+ const pending = [...this.queue.values()];
90
105
  this.queue.clear();
91
- for (const [group, items] of pending) {
92
- const getItems = items.filter(([options]) => options.request.method === "GET");
93
- const restItems = items.filter(([options]) => options.request.method !== "GET");
94
- this.executeBatch("GET", group, getItems);
95
- this.executeBatch("POST", group, restItems);
106
+ for (const groups of pending) {
107
+ for (const [group, items] of groups) {
108
+ const getItems = items.filter(([options]) => options.request.method === "GET");
109
+ const queryItems = items.filter(([options]) => options.request.method === "QUERY");
110
+ const unsafeItems = items.filter(([options]) => options.request.method !== "GET" && options.request.method !== "QUERY");
111
+ this.executeBatchInOwnContext("GET", group, getItems);
112
+ this.executeBatchInOwnContext("QUERY", group, queryItems);
113
+ this.executeBatchInOwnContext("POST", group, unsafeItems);
114
+ }
96
115
  }
97
116
  }
117
+ /**
118
+ * Runs the batch in the async context of its first request instead of the timer's,
119
+ * so the transport and batch options see that caller's request state.
120
+ * `executeBatch` always runs in the context of its first request.
121
+ */
122
+ async executeBatchInOwnContext(method, group, groupItems) {
123
+ await groupItems[0]?.[3](() => this.executeBatch(method, group, groupItems));
124
+ }
98
125
  async executeBatch(method, group, groupItems) {
99
- if (!groupItems.length) {
100
- return;
101
- }
102
- if (groupItems.length === 1) {
103
- const [options, resolve, reject] = groupItems[0];
104
- options.next().then(resolve).catch(reject);
105
- return;
106
- }
107
- const subOptionsList = groupItems.map(([options]) => options);
108
- const maxSize = await value(this.maxSize, subOptionsList);
109
- if (groupItems.length > maxSize) {
110
- const [first, second] = splitInHalf(groupItems);
111
- await Promise.all([
112
- this.executeBatch(method, group, first),
113
- this.executeBatch(method, group, second)
114
- ]);
115
- return;
116
- }
117
- const url = await value(this.batchUrl, subOptionsList);
118
- const headers = await value(this.batchHeaders, subOptionsList);
119
- const mode = value(this.mode, subOptionsList);
120
- let suppressErrorFromCurrentBatch = false;
121
- const controller = new AbortController();
122
- const pendingMessages = [];
123
- let batchResponse;
124
- const peer = new ClientPeer(async (message) => {
125
- pendingMessages.push(message);
126
- if (message.kind === "cancel" && pendingMessages.filter((m) => m.kind === "cancel").length === groupItems.length) {
127
- controller.abort();
126
+ try {
127
+ if (groupItems.length === 1) {
128
+ const [options, resolve, reject] = groupItems[0];
129
+ options.next().then(resolve).catch(reject);
130
+ return;
128
131
  }
129
- if (message.kind === "request" && pendingMessages.filter((m) => m.kind === "request").length === groupItems.length) {
130
- (async () => {
131
- try {
132
- const request = {
133
- url,
134
- method,
135
- headers: { ...headers, "orpc-batch": mode },
136
- signal: controller.signal
137
- };
138
- if (method === "GET") {
139
- const [pathname, search, hash] = parseStandardUrl(url);
140
- const dataParam = `data=${encodeURIComponent(stringifyJSON(pendingMessages))}`;
141
- const newUrl = search ? `${pathname}${search}&${dataParam}${hash ?? ""}` : `${pathname}?${dataParam}${hash ?? ""}`;
142
- const maxUrlLength = await value(this.maxUrlLength, subOptionsList);
143
- if (newUrl.length > maxUrlLength) {
144
- const [first, second] = splitInHalf(groupItems);
145
- suppressErrorFromCurrentBatch = true;
146
- await Promise.all([
147
- this.executeBatch(method, group, first),
148
- this.executeBatch(method, group, second),
149
- peer.close()
150
- ]);
151
- return;
152
- }
153
- request.url = newUrl;
154
- } else {
155
- request.body = pendingMessages;
156
- }
157
- batchResponse = await groupItems[0][0].next({
158
- ...subOptionsList[0],
159
- context: value(group.context, subOptionsList),
160
- path: value(group.path, subOptionsList) ?? [],
161
- request,
162
- signal: controller.signal
163
- });
164
- const body = await batchResponse.resolveBody();
165
- if (Array.isArray(body) && body.every((v) => isServerPeerSendMessage(v))) {
166
- for (const message2 of body) {
167
- await peer.message(message2);
168
- }
169
- } else if (body instanceof Blob) {
170
- await decodeLengthPrefixedBlob(body, peer);
171
- } else if (body instanceof ReadableStream) {
172
- await decodeLengthPrefixedStream(body, peer);
173
- } else {
174
- throw new BatchLinkPluginError("Invalid batch response format.");
175
- }
176
- await peer.close(new BatchLinkPluginError("Batch response is incomplete."));
177
- } catch (error) {
178
- await peer.close(error);
179
- }
180
- })();
132
+ const subOptionsList = groupItems.map(([options]) => options);
133
+ const maxSize = await value(this.maxSize, subOptionsList);
134
+ if (groupItems.length > maxSize) {
135
+ const [first, second] = splitInHalf(groupItems);
136
+ await Promise.all([
137
+ this.executeBatch(method, group, first),
138
+ this.executeBatchInOwnContext(method, group, second)
139
+ ]);
140
+ return;
181
141
  }
182
- });
183
- groupItems.forEach(([subOptions, resolve, reject]) => {
184
- peer.request(this.mapSubrequest(subOptions, { url, headers })).then((subResponse) => resolve(this.mapSubresponse(subResponse, batchResponse, subOptions))).catch((error) => {
185
- if (!suppressErrorFromCurrentBatch) {
186
- reject(error);
142
+ const url = await value(this.batchUrl, subOptionsList);
143
+ const headers = await value(this.batchHeaders, subOptionsList);
144
+ const mode = value(this.mode, subOptionsList);
145
+ let suppressErrorFromCurrentBatch = false;
146
+ const controller = new AbortController();
147
+ const pendingMessages = [];
148
+ const subrequests = groupItems.map(([subOptions]) => this.mapSubrequest(subOptions, { url, headers }));
149
+ let batchResponse;
150
+ let markRequestSent;
151
+ const openRequestIds = /* @__PURE__ */ new Set();
152
+ const cancelledRunningRequestIds = /* @__PURE__ */ new Set();
153
+ let isBatchSent = false;
154
+ const abortIfOnlyCancelledRemain = () => {
155
+ if (openRequestIds.size === 0 && cancelledRunningRequestIds.size > 0) {
156
+ controller.abort();
157
+ }
158
+ };
159
+ const peer = new ClientPeer(async (message) => {
160
+ pendingMessages.push(message);
161
+ if (message.kind === "request") {
162
+ openRequestIds.add(message.id);
163
+ markRequestSent?.();
164
+ } else if (message.kind === "cancel" && openRequestIds.delete(message.id) && isBatchSent) {
165
+ cancelledRunningRequestIds.add(message.id);
166
+ abortIfOnlyCancelledRemain();
187
167
  }
188
168
  });
189
- });
169
+ for (const [index, [subOptions, resolve, reject]] of groupItems.entries()) {
170
+ const sent = promiseWithResolvers();
171
+ markRequestSent = sent.resolve;
172
+ peer.request(subrequests[index]).then((subResponse) => resolve(this.mapSubresponse(subResponse, batchResponse, subOptions))).catch((error) => {
173
+ if (!suppressErrorFromCurrentBatch) {
174
+ reject(error);
175
+ }
176
+ }).then(sent.resolve);
177
+ await sent.promise;
178
+ }
179
+ if (openRequestIds.size === 0) {
180
+ return;
181
+ }
182
+ isBatchSent = true;
183
+ try {
184
+ const request = {
185
+ url,
186
+ method,
187
+ headers: { ...headers, "orpc-batch": mode },
188
+ signal: controller.signal
189
+ };
190
+ if (method === "GET") {
191
+ const [pathname, search, hash] = parseStandardUrl(url);
192
+ const dataParam = `data=${safeEncodeURIComponent(stringifyJSON(pendingMessages))}`;
193
+ const newUrl = search ? `${pathname}${search}&${dataParam}${hash ?? ""}` : `${pathname}?${dataParam}${hash ?? ""}`;
194
+ const maxUrlLength = await value(this.maxUrlLength, subOptionsList);
195
+ if (newUrl.length > maxUrlLength) {
196
+ const [first, second] = splitInHalf(groupItems);
197
+ suppressErrorFromCurrentBatch = true;
198
+ await Promise.all([
199
+ this.executeBatch(method, group, first),
200
+ this.executeBatchInOwnContext(method, group, second),
201
+ peer.close()
202
+ ]);
203
+ return;
204
+ }
205
+ request.url = newUrl;
206
+ } else {
207
+ request.body = pendingMessages;
208
+ }
209
+ batchResponse = await groupItems[0][0].next({
210
+ ...subOptionsList[0],
211
+ context: value(group.context, subOptionsList),
212
+ path: value(group.path, subOptionsList) ?? [],
213
+ request,
214
+ signal: controller.signal
215
+ });
216
+ if (batchResponse.status >= 400) {
217
+ const resolveBody = once(() => batchResponse.resolveBody());
218
+ const errorResponse = { ...batchResponse, resolveBody };
219
+ groupItems.forEach(([subOptions, resolve]) => {
220
+ resolve(this.mapSubresponse(errorResponse, batchResponse, subOptions));
221
+ });
222
+ suppressErrorFromCurrentBatch = true;
223
+ await peer.close();
224
+ return;
225
+ }
226
+ const body = await batchResponse.resolveBody();
227
+ if (Array.isArray(body) && body.every((v) => isServerPeerSendMessage(v))) {
228
+ for (const message of body) {
229
+ await peer.message(message);
230
+ }
231
+ } else if (body instanceof Blob) {
232
+ await decodeLengthPrefixedBlob(body, peer);
233
+ } else if (body instanceof ReadableStream) {
234
+ await decodeLengthPrefixedStream(body, async (message) => {
235
+ await peer.message(message);
236
+ if (isLastServerMessage(message)) {
237
+ openRequestIds.delete(message.id);
238
+ cancelledRunningRequestIds.delete(message.id);
239
+ abortIfOnlyCancelledRemain();
240
+ }
241
+ });
242
+ } else {
243
+ throw new TypeError("Invalid batch response format.");
244
+ }
245
+ await peer.close(new TypeError("Batch response is incomplete."));
246
+ } catch (error) {
247
+ await peer.close(error);
248
+ }
249
+ } catch (error) {
250
+ groupItems.forEach(([, , reject]) => reject(error));
251
+ }
252
+ }
253
+ }
254
+ function isLastServerMessage(message) {
255
+ switch (message.kind) {
256
+ case "response":
257
+ return message.json.body !== void 0 || message.binary !== void 0 || message.json.headers?.["content-type"] === void 0 && message.json.headers?.["standard-server"] === void 0;
258
+ case "event-stream":
259
+ return message.json.event === "close" || message.json.event === "error";
260
+ case "octet-stream":
261
+ return message.json.close === true;
262
+ case "cancel":
263
+ return true;
264
+ default:
265
+ return false;
190
266
  }
191
267
  }
192
268
  async function decodeLengthPrefixedBlob(blob, peer) {
@@ -194,24 +270,24 @@ async function decodeLengthPrefixedBlob(blob, peer) {
194
270
  let offset = 0;
195
271
  while (offset < buffer.length) {
196
272
  if (offset + 4 > buffer.length) {
197
- throw new BatchLinkPluginError("Invalid batch response: incomplete length header.");
273
+ throw new TypeError("Invalid batch response: incomplete length header.");
198
274
  }
199
275
  const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4);
200
276
  const length = view.getUint32(0, false);
201
277
  offset += 4;
202
278
  if (offset + length > buffer.length) {
203
- throw new BatchLinkPluginError("Invalid batch response: incomplete message.");
279
+ throw new TypeError("Invalid batch response: incomplete message.");
204
280
  }
205
281
  const messageBytes = buffer.subarray(offset, offset + length);
206
282
  offset += length;
207
283
  const result = decodePeerMessage(messageBytes);
208
284
  if (!result.matched || !isServerPeerSendMessage(result.message)) {
209
- throw new BatchLinkPluginError("Invalid batch response: invalid message.");
285
+ throw new TypeError("Invalid batch response: invalid message.");
210
286
  }
211
287
  await peer.message(result.message);
212
288
  }
213
289
  }
214
- async function decodeLengthPrefixedStream(stream, peer) {
290
+ async function decodeLengthPrefixedStream(stream, receive) {
215
291
  const reader = stream.getReader();
216
292
  let buffer = new Uint8Array(0);
217
293
  try {
@@ -226,6 +302,10 @@ async function decodeLengthPrefixedStream(stream, peer) {
226
302
  while (buffer.length >= 4) {
227
303
  const view = new DataView(buffer.buffer, buffer.byteOffset, 4);
228
304
  const length = view.getUint32(0, false);
305
+ if (length === 0) {
306
+ buffer = buffer.subarray(4);
307
+ continue;
308
+ }
229
309
  if (buffer.length < 4 + length) {
230
310
  break;
231
311
  }
@@ -233,9 +313,9 @@ async function decodeLengthPrefixedStream(stream, peer) {
233
313
  buffer = buffer.subarray(4 + length);
234
314
  const result = decodePeerMessage(messageBytes);
235
315
  if (!result.matched || !isServerPeerSendMessage(result.message)) {
236
- throw new BatchLinkPluginError("Invalid batch response: invalid message.");
316
+ throw new TypeError("Invalid batch response: invalid message.");
237
317
  }
238
- await peer.message(result.message);
318
+ await receive(result.message);
239
319
  }
240
320
  if (done) {
241
321
  break;
@@ -251,10 +331,14 @@ class DedupeLinkPlugin {
251
331
  before = ["~batch"];
252
332
  groups;
253
333
  filter;
334
+ scope;
335
+ wait;
254
336
  queue = /* @__PURE__ */ new Map();
255
337
  constructor(options) {
256
338
  this.groups = options.groups;
257
- this.filter = options.filter ?? (({ request }) => request.method === "GET");
339
+ this.filter = options.filter ?? (({ request }) => request.method === "GET" || request.method === "QUERY");
340
+ this.scope = options.scope ?? (() => void 0);
341
+ this.wait = options.wait ?? 0;
258
342
  }
259
343
  init(options) {
260
344
  const transportInterceptor = (interceptorOptions) => {
@@ -265,78 +349,81 @@ class DedupeLinkPlugin {
265
349
  if (!group) {
266
350
  return interceptorOptions.next();
267
351
  }
268
- return new Promise((resolve, reject) => {
269
- this.enqueue(group, interceptorOptions, resolve, reject);
270
- defer(() => {
271
- this.processPendingRequests();
272
- });
273
- });
352
+ const scope = this.scope(interceptorOptions);
353
+ return runWithSignal(interceptorOptions.request.signal, () => new Promise((resolve, reject) => {
354
+ if (!this.queue.size) {
355
+ defer(() => this.processPendingRequests(), this.wait);
356
+ }
357
+ this.enqueue(scope, group, { options: interceptorOptions, resolve, reject, runInOwnContext: captureAsyncContext() });
358
+ }));
274
359
  };
275
360
  return {
276
361
  ...options,
277
362
  transportInterceptors: [...toArray(options.transportInterceptors), transportInterceptor]
278
363
  };
279
364
  }
280
- enqueue(group, options, resolve, reject) {
281
- let queue = this.queue.get(group);
365
+ enqueue(scope, group, caller) {
366
+ let groups = this.queue.get(scope);
367
+ if (!groups) {
368
+ groups = /* @__PURE__ */ new Map();
369
+ this.queue.set(scope, groups);
370
+ }
371
+ let queue = groups.get(group);
282
372
  if (!queue) {
283
- queue = [];
284
- this.queue.set(group, queue);
373
+ queue = /* @__PURE__ */ new Map();
374
+ groups.set(group, queue);
285
375
  }
286
- const requestKey = createRequestKey(options.path, options.request);
287
- const matched = queue.find((item) => item.requestKey === requestKey);
376
+ const requestKey = createRequestKey(caller.options.path, caller.options.request);
377
+ const matched = queue.get(requestKey);
288
378
  if (matched) {
289
- matched.matchedOptions.push(options);
290
- matched.signals.push(options.request.signal);
291
- matched.resolves.push(resolve);
292
- matched.rejects.push(reject);
379
+ matched.push(caller);
293
380
  return;
294
381
  }
295
- queue.push({
296
- requestKey,
297
- options,
298
- matchedOptions: [options],
299
- signals: [options.request.signal],
300
- resolves: [resolve],
301
- rejects: [reject]
302
- });
382
+ queue.set(requestKey, [caller]);
303
383
  }
304
384
  async processPendingRequests() {
305
- const pending = new Map(this.queue);
385
+ const pending = [...this.queue.values()];
306
386
  this.queue.clear();
307
387
  const executions = [];
308
- for (const [group, items] of pending) {
309
- for (const item of items) {
310
- executions.push(this.execute(group, item));
388
+ for (const groups of pending) {
389
+ for (const [group, items] of groups) {
390
+ for (const callers of items.values()) {
391
+ const activeCallers = callers.filter((caller) => !caller.options.request.signal?.aborted);
392
+ const [first] = activeCallers;
393
+ if (first) {
394
+ executions.push(first.runInOwnContext(() => this.execute(group, activeCallers)));
395
+ }
396
+ }
311
397
  }
312
398
  }
313
399
  await Promise.all(executions);
314
400
  }
315
- async execute(group, item) {
401
+ async execute(group, activeCallers) {
402
+ const first = activeCallers[0];
403
+ const matchedOptions = activeCallers.map((caller) => caller.options);
316
404
  try {
317
- if (!shouldDedupe(item.matchedOptions)) {
318
- const response2 = await item.options.next(item.options);
319
- item.resolves[0]?.(response2);
405
+ if (!shouldDedupe(matchedOptions)) {
406
+ const response2 = await first.options.next(first.options);
407
+ first.resolve(response2);
320
408
  return;
321
409
  }
322
- const context = value(group.context, item.matchedOptions);
410
+ const context = value(group.context, matchedOptions);
411
+ const signals = matchedOptions.map((options) => options.request.signal);
323
412
  const request = {
324
- ...item.options.request,
325
- signal: allAbortSignal(item.signals)
413
+ ...first.options.request,
414
+ signal: allAbortSignal(signals)
326
415
  };
327
- const response = await item.options.next({
328
- ...item.options,
416
+ const response = await first.options.next({
417
+ ...first.options,
329
418
  request,
330
419
  signal: request.signal,
331
420
  context
332
421
  });
333
- const replicatedResponses = replicateLazyResponse(response, item.resolves.length);
334
- for (const resolve of item.resolves) {
335
- resolve(replicatedResponses.pop());
336
- }
422
+ const replicatedResponses = replicateLazyResponse(response, signals);
423
+ activeCallers.forEach((caller, index) => caller.resolve(replicatedResponses[index]));
337
424
  } catch (error) {
338
- for (const reject of item.rejects) {
339
- reject(error);
425
+ for (const caller of activeCallers) {
426
+ caller.reject(error);
340
427
  }
341
428
  }
342
429
  }
@@ -353,42 +440,310 @@ function createRequestKey(path, request) {
353
440
  url: request.url
354
441
  });
355
442
  }
356
- function replicateLazyResponse(response, count) {
357
- const replicated = [];
358
- let bodyPromise;
359
- let replicatedAsyncIterators;
360
- let replicatedReadableStream;
361
- for (let i = 0; i < count; i++) {
362
- let resolvedBody;
363
- replicated.push({
364
- ...response,
365
- resolveBody: async (hint) => {
366
- if (resolvedBody) {
367
- return resolvedBody.body;
368
- }
369
- bodyPromise ??= response.resolveBody(hint);
370
- const body = await bodyPromise;
443
+ function replicateLazyResponse(response, signals) {
444
+ let bodiesPromise;
445
+ return signals.map((signal, i) => ({
446
+ ...response,
447
+ resolveBody: (hint) => runWithSignal(signal, async () => {
448
+ bodiesPromise ??= response.resolveBody(hint).then((body) => {
371
449
  if (isAsyncIteratorObject(body)) {
372
- replicatedAsyncIterators ??= replicateAsyncIterator(body, count);
373
- resolvedBody = { body: replicatedAsyncIterators.pop() };
374
- } else if (body instanceof ReadableStream) {
375
- replicatedReadableStream ??= replicateReadableStream(body, count);
376
- resolvedBody = { body: replicatedReadableStream.pop() };
450
+ return replicateAsyncIterator(body, signals.length).map((replica, index) => closeAsyncIteratorOnAbort(replica, signals[index]));
451
+ }
452
+ if (body instanceof ReadableStream) {
453
+ return replicateReadableStream(body, signals.length).map((replica, index) => closeReadableStreamOnAbort(replica, signals[index]));
454
+ }
455
+ return signals.map(() => body);
456
+ });
457
+ return (await bodiesPromise)[i];
458
+ })
459
+ }));
460
+ }
461
+ function closeAsyncIteratorOnAbort(replica, signal) {
462
+ if (!signal) {
463
+ return replica;
464
+ }
465
+ const abort = () => replica.return().catch(() => {
466
+ });
467
+ if (signal.aborted) {
468
+ void abort();
469
+ } else {
470
+ signal.addEventListener("abort", abort, { once: true });
471
+ }
472
+ return wrapAsyncIterator(replica, {
473
+ mapResult(result) {
474
+ throwIfAborted(signal);
475
+ return result;
476
+ },
477
+ onFinish: () => signal.removeEventListener("abort", abort)
478
+ });
479
+ }
480
+ function closeReadableStreamOnAbort(replica, signal) {
481
+ if (!signal) {
482
+ return replica;
483
+ }
484
+ const reader = replica.getReader();
485
+ const abort = () => reader.cancel(signal.reason).catch(() => {
486
+ });
487
+ if (signal.aborted) {
488
+ void abort();
489
+ } else {
490
+ signal.addEventListener("abort", abort, { once: true });
491
+ }
492
+ return new ReadableStream({
493
+ async pull(controller) {
494
+ try {
495
+ const result = await reader.read();
496
+ throwIfAborted(signal);
497
+ if (result.done) {
498
+ signal.removeEventListener("abort", abort);
499
+ controller.close();
377
500
  } else {
378
- resolvedBody = { body };
501
+ controller.enqueue(result.value);
379
502
  }
380
- return resolvedBody.body;
503
+ } catch (error) {
504
+ signal.removeEventListener("abort", abort);
505
+ throw error;
381
506
  }
382
- });
383
- }
384
- return replicated;
507
+ },
508
+ async cancel(reason) {
509
+ signal.removeEventListener("abort", abort);
510
+ await reader.cancel(reason);
511
+ }
512
+ }, { highWaterMark: 0 });
385
513
  }
386
514
  function shouldDedupe(items) {
387
515
  return items.length >= 2;
388
516
  }
389
517
 
390
- class RetryLinkPluginInvalidEventIteratorRetryResponse extends Error {
518
+ const AVG_BYTES_PER_CHAR = 1.2;
519
+ class RequestCompressionLinkPlugin {
520
+ name = "~request-compression";
521
+ /**
522
+ * Compression should be done after batching, to compress the final request
523
+ */
524
+ after = ["~batch"];
525
+ encoding;
526
+ threshold;
527
+ isCompressibleContentType;
528
+ constructor(options = {}) {
529
+ this.encoding = options.encoding ?? "gzip";
530
+ this.threshold = options.threshold ?? 1024;
531
+ this.isCompressibleContentType = options.isCompressibleContentType ?? isCompressibleContentType;
532
+ }
533
+ init(options) {
534
+ const transportInterceptor = async ({ next, ...interceptorOptions }) => {
535
+ const request = interceptorOptions.request;
536
+ const contentEncoding = flattenStandardHeader(request.headers["content-encoding"])?.trim()?.toLowerCase();
537
+ if (contentEncoding !== void 0) {
538
+ return next();
539
+ }
540
+ if (request.body instanceof ReadableStream) {
541
+ const contentLength = Number(flattenStandardHeader(request.headers["content-length"]));
542
+ if ((!Number.isFinite(contentLength) || contentLength >= this.threshold) && this.isCompressibleContentType(flattenStandardHeader(request.headers["content-type"]), interceptorOptions)) {
543
+ const compressedStream = request.body.pipeThrough(new CompressionStream(this.encoding));
544
+ return next({
545
+ ...interceptorOptions,
546
+ request: {
547
+ ...interceptorOptions.request,
548
+ body: compressedStream,
549
+ headers: {
550
+ ...request.headers,
551
+ "standard-server": "octet-stream",
552
+ "content-length": [],
553
+ "content-encoding": this.encoding
554
+ }
555
+ }
556
+ });
557
+ }
558
+ } else if (request.body instanceof Blob) {
559
+ if ((!Number.isFinite(request.body.size) || request.body.size >= this.threshold) && this.isCompressibleContentType(request.body.type, interceptorOptions)) {
560
+ const compressedStream = request.body.stream().pipeThrough(new CompressionStream(this.encoding));
561
+ const contentDisposition = request.headers["content-disposition"] ?? generateContentDisposition(
562
+ request.body instanceof File ? request.body.name : "blob"
563
+ );
564
+ return next({
565
+ ...interceptorOptions,
566
+ request: {
567
+ ...interceptorOptions.request,
568
+ body: compressedStream,
569
+ headers: {
570
+ ...request.headers,
571
+ "standard-server": "file",
572
+ "content-type": request.body.type,
573
+ "content-length": [],
574
+ "content-disposition": contentDisposition,
575
+ "content-encoding": this.encoding
576
+ }
577
+ }
578
+ });
579
+ }
580
+ } else if (request.body instanceof FormData) {
581
+ const PART_OVERHEAD = 64;
582
+ let contentLength = 0;
583
+ for (const [key, value] of request.body) {
584
+ contentLength += PART_OVERHEAD + key.length;
585
+ if (value instanceof Blob) {
586
+ if (!Number.isFinite(value.size)) {
587
+ if (!this.isCompressibleContentType(value.type, interceptorOptions)) {
588
+ contentLength = -Infinity;
589
+ break;
590
+ }
591
+ contentLength = Infinity;
592
+ } else {
593
+ contentLength += this.isCompressibleContentType(value.type, interceptorOptions) ? value.size : -value.size;
594
+ }
595
+ } else {
596
+ contentLength += value.length * AVG_BYTES_PER_CHAR;
597
+ }
598
+ }
599
+ if (contentLength >= this.threshold) {
600
+ const res = new Response(request.body);
601
+ const compressedStream = res.body.pipeThrough(new CompressionStream(this.encoding));
602
+ return next({
603
+ ...interceptorOptions,
604
+ request: {
605
+ ...interceptorOptions.request,
606
+ body: compressedStream,
607
+ headers: {
608
+ ...request.headers,
609
+ "standard-server": [],
610
+ "content-type": res.headers.get("content-type"),
611
+ "content-length": [],
612
+ "content-encoding": this.encoding
613
+ }
614
+ }
615
+ });
616
+ }
617
+ } else if (request.body instanceof URLSearchParams) {
618
+ const string = request.body.toString();
619
+ if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
620
+ const compressedStream = new Blob([string]).stream().pipeThrough(new CompressionStream(this.encoding));
621
+ return next({
622
+ ...interceptorOptions,
623
+ request: {
624
+ ...interceptorOptions.request,
625
+ body: compressedStream,
626
+ headers: {
627
+ ...request.headers,
628
+ "standard-server": [],
629
+ "content-type": "application/x-www-form-urlencoded",
630
+ "content-length": [],
631
+ "content-encoding": this.encoding
632
+ }
633
+ }
634
+ });
635
+ }
636
+ } else if (request.body !== void 0 && !isAsyncIteratorObject(request.body)) {
637
+ const string = stringifyJSON(request.body);
638
+ if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
639
+ const compressedStream = new Blob([string]).stream().pipeThrough(new CompressionStream(this.encoding));
640
+ return next({
641
+ ...interceptorOptions,
642
+ request: {
643
+ ...interceptorOptions.request,
644
+ body: compressedStream,
645
+ headers: {
646
+ ...request.headers,
647
+ "standard-server": [],
648
+ "content-type": "application/json",
649
+ "content-length": [],
650
+ "content-encoding": this.encoding
651
+ }
652
+ }
653
+ });
654
+ }
655
+ }
656
+ return next();
657
+ };
658
+ return {
659
+ ...options,
660
+ transportInterceptors: [
661
+ ...toArray(options.transportInterceptors),
662
+ transportInterceptor
663
+ ]
664
+ };
665
+ }
666
+ }
667
+
668
+ const MAX_CONTENT_ENCODINGS = 5;
669
+ class ResponseCompressionLinkPlugin {
670
+ name = "~response-compression";
671
+ /**
672
+ * Decompression should wrap the final batch response instead of sub-responses.
673
+ */
674
+ after = ["~batch"];
675
+ encodings;
676
+ constructor(options = {}) {
677
+ this.encodings = options.encodings ?? ["gzip", "deflate"];
678
+ }
679
+ init(options) {
680
+ const acceptEncodingHeader = this.encodings.join(", ");
681
+ const transportInterceptor = async ({ next, ...interceptorOptions }) => {
682
+ const response = await next({
683
+ ...interceptorOptions,
684
+ request: {
685
+ ...interceptorOptions.request,
686
+ headers: {
687
+ ...interceptorOptions.request.headers,
688
+ "accept-encoding": acceptEncodingHeader
689
+ }
690
+ }
691
+ });
692
+ const encodings = parseContentEncodings(
693
+ flattenStandardHeader(response.headers["content-encoding"])
694
+ );
695
+ if (encodings.length === 0 || !encodings.every(isSupportedEncoding)) {
696
+ return response;
697
+ }
698
+ const decompressedHeaders = {
699
+ ...response.headers,
700
+ "content-length": void 0,
701
+ "content-encoding": void 0
702
+ };
703
+ return {
704
+ ...response,
705
+ headers: decompressedHeaders,
706
+ async resolveBody(hint) {
707
+ const stream = await response.resolveBody("octet-stream");
708
+ if (!(stream instanceof ReadableStream)) {
709
+ return stream;
710
+ }
711
+ if (encodings.length > MAX_CONTENT_ENCODINGS) {
712
+ throw new TypeError("Too many content encodings.");
713
+ }
714
+ let decompressedStream = stream;
715
+ for (let i = encodings.length - 1; i >= 0; i--) {
716
+ decompressedStream = decompressedStream.pipeThrough(
717
+ new DecompressionStream(encodings[i])
718
+ );
719
+ }
720
+ const fetchResponse = new Response(decompressedStream, {
721
+ headers: toFetchHeaders(decompressedHeaders)
722
+ });
723
+ return toStandardBody(fetchResponse, { hint });
724
+ }
725
+ };
726
+ };
727
+ return {
728
+ ...options,
729
+ transportInterceptors: [
730
+ ...toArray(options.transportInterceptors),
731
+ transportInterceptor
732
+ ]
733
+ };
734
+ }
735
+ }
736
+ const SUPPORTED_ENCODINGS = ["gzip", "deflate", "deflate-raw"];
737
+ function isSupportedEncoding(encoding) {
738
+ return SUPPORTED_ENCODINGS.includes(encoding);
391
739
  }
740
+ function parseContentEncodings(header) {
741
+ if (header === void 0) {
742
+ return [];
743
+ }
744
+ return header.split(",").map((part) => part.trim().toLowerCase());
745
+ }
746
+
392
747
  class RetryLinkPlugin {
393
748
  defaultRetry;
394
749
  defaultRetryDelay;
@@ -422,6 +777,7 @@ class RetryLinkPlugin {
422
777
  let currentError = initialError;
423
778
  while (true) {
424
779
  const updatedCallOptions = { ...callOptions, lastEventId };
780
+ let retryDelayMs = 0;
425
781
  if (currentError) {
426
782
  if (attempt > maxAttempts) {
427
783
  throw currentError.error;
@@ -439,16 +795,11 @@ class RetryLinkPlugin {
439
795
  if (!shouldRetryBool) {
440
796
  throw currentError.error;
441
797
  }
798
+ retryDelayMs = await value(retryDelay, attemptOptions);
442
799
  callback = onRetry?.(attemptOptions);
443
800
  }
444
801
  try {
445
802
  if (currentError) {
446
- const retryDelayMs = await value(retryDelay, {
447
- ...updatedCallOptions,
448
- attempt,
449
- error: currentError.error,
450
- lastEventRetry
451
- });
452
803
  await sleep(retryDelayMs, { signal: updatedCallOptions.signal });
453
804
  attempt++;
454
805
  }
@@ -484,13 +835,13 @@ class RetryLinkPlugin {
484
835
  const meta = getEventMeta(error);
485
836
  lastEventId = meta?.id ?? lastEventId;
486
837
  lastEventRetry = meta?.retry ?? lastEventRetry;
487
- const maybeEventIterator = await callNext({ error });
488
- if (!isAsyncIteratorObject(maybeEventIterator)) {
489
- throw new RetryLinkPluginInvalidEventIteratorRetryResponse(
490
- "RetryLinkPlugin: Expected an Event Iterator, got a non-Event Iterator"
838
+ const asyncIteratorObject = await callNext({ error });
839
+ if (!isAsyncIteratorObject(asyncIteratorObject)) {
840
+ throw new TypeError(
841
+ "RetryLinkPlugin: Expected an AsyncIteratorObject, got a different type."
491
842
  );
492
843
  }
493
- current = maybeEventIterator;
844
+ current = asyncIteratorObject;
494
845
  if (isIteratorAborted) {
495
846
  await current.return?.();
496
847
  throw error;
@@ -571,4 +922,35 @@ function parseRetryAfterHeader(value2) {
571
922
  return void 0;
572
923
  }
573
924
 
574
- export { BatchLinkPlugin, BatchLinkPluginError, DedupeLinkPlugin, RetryAfterLinkPlugin, RetryLinkPlugin, RetryLinkPluginInvalidEventIteratorRetryResponse };
925
+ class TimeoutLinkPlugin {
926
+ timeout;
927
+ name = "~timeout";
928
+ /**
929
+ * Should abort if the total retry time exceeds the configured timeout
930
+ */
931
+ after = ["~retry"];
932
+ constructor(options) {
933
+ this.timeout = options.timeout;
934
+ }
935
+ init(options) {
936
+ const interceptor = async (interceptorOptions) => {
937
+ const timeoutMs = value(this.timeout, interceptorOptions);
938
+ if (timeoutMs === null || timeoutMs === void 0) {
939
+ return interceptorOptions.next();
940
+ }
941
+ const controller = new AbortController();
942
+ const timeoutId = setTimeout(() => {
943
+ controller.abort(new AbortError(`Request timed out after ${timeoutMs}ms`));
944
+ }, timeoutMs);
945
+ const signal = anyAbortSignal([interceptorOptions.signal, controller.signal]);
946
+ try {
947
+ return await interceptorOptions.next({ ...interceptorOptions, signal });
948
+ } finally {
949
+ clearTimeout(timeoutId);
950
+ }
951
+ };
952
+ return { ...options, interceptors: [interceptor, ...toArray(options.interceptors)] };
953
+ }
954
+ }
955
+
956
+ export { BatchLinkPlugin, RetryLinkPlugin as ClientRetryPlugin, DedupeLinkPlugin, DedupeLinkPlugin as DedupeRequestsPlugin, RequestCompressionLinkPlugin, ResponseCompressionLinkPlugin, RetryAfterLinkPlugin, RetryAfterLinkPlugin as RetryAfterPlugin, RetryLinkPlugin, TimeoutLinkPlugin };