@orpc/server 1.14.9 → 1.14.11

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 (78) hide show
  1. package/README.md +52 -98
  2. package/dist/adapters/crossws/index.d.mts +45 -21
  3. package/dist/adapters/crossws/index.d.ts +45 -21
  4. package/dist/adapters/crossws/index.mjs +40 -18
  5. package/dist/adapters/fetch/index.d.mts +64 -96
  6. package/dist/adapters/fetch/index.d.ts +64 -96
  7. package/dist/adapters/fetch/index.mjs +34 -139
  8. package/dist/adapters/message-port/index.d.mts +49 -34
  9. package/dist/adapters/message-port/index.d.ts +49 -34
  10. package/dist/adapters/message-port/index.mjs +68 -37
  11. package/dist/adapters/node/index.d.mts +63 -71
  12. package/dist/adapters/node/index.d.ts +63 -71
  13. package/dist/adapters/node/index.mjs +36 -113
  14. package/dist/adapters/standard/index.d.mts +16 -18
  15. package/dist/adapters/standard/index.d.ts +16 -18
  16. package/dist/adapters/standard/index.mjs +5 -5
  17. package/dist/adapters/standard-peer/index.d.mts +12 -14
  18. package/dist/adapters/standard-peer/index.d.ts +12 -14
  19. package/dist/adapters/standard-peer/index.mjs +2 -21
  20. package/dist/adapters/websocket/index.d.mts +53 -38
  21. package/dist/adapters/websocket/index.d.ts +53 -38
  22. package/dist/adapters/websocket/index.mjs +59 -42
  23. package/dist/extensions/callable.d.mts +10 -0
  24. package/dist/extensions/callable.d.ts +10 -0
  25. package/dist/extensions/callable.mjs +11 -0
  26. package/dist/helpers/index.d.mts +4 -4
  27. package/dist/helpers/index.d.ts +4 -4
  28. package/dist/helpers/index.mjs +13 -5
  29. package/dist/index.d.mts +166 -770
  30. package/dist/index.d.ts +166 -770
  31. package/dist/index.mjs +306 -399
  32. package/dist/plugins/index.d.mts +214 -121
  33. package/dist/plugins/index.d.ts +214 -121
  34. package/dist/plugins/index.mjs +576 -214
  35. package/dist/shared/server.15O7oC1p.d.mts +61 -0
  36. package/dist/shared/server.B4BnEWWm.d.mts +67 -0
  37. package/dist/shared/server.BhsVw7m7.d.ts +104 -0
  38. package/dist/shared/server.BoiFpNT7.mjs +224 -0
  39. package/dist/shared/server.COPVXhDG.mjs +271 -0
  40. package/dist/shared/server.CX4vUnDk.mjs +11 -0
  41. package/dist/shared/server.CcR4kgje.d.mts +206 -0
  42. package/dist/shared/server.CcR4kgje.d.ts +206 -0
  43. package/dist/shared/server.CnN50DHH.d.ts +67 -0
  44. package/dist/shared/server.DBs0rrf9.mjs +41 -0
  45. package/dist/shared/server.DDFizwfU.d.mts +104 -0
  46. package/dist/shared/server.D_QauotT.mjs +30 -0
  47. package/dist/shared/server.DofEOwf-.mjs +237 -0
  48. package/dist/shared/{server.DZ5BIITo.mjs → server.W91HSRkE.mjs} +2 -2
  49. package/dist/shared/server.m3szVJGU.d.ts +61 -0
  50. package/package.json +26 -56
  51. package/dist/adapters/aws-lambda/index.d.mts +0 -46
  52. package/dist/adapters/aws-lambda/index.d.ts +0 -46
  53. package/dist/adapters/aws-lambda/index.mjs +0 -40
  54. package/dist/adapters/bun-ws/index.d.mts +0 -36
  55. package/dist/adapters/bun-ws/index.d.ts +0 -36
  56. package/dist/adapters/bun-ws/index.mjs +0 -47
  57. package/dist/adapters/fastify/index.d.mts +0 -53
  58. package/dist/adapters/fastify/index.d.ts +0 -53
  59. package/dist/adapters/fastify/index.mjs +0 -52
  60. package/dist/adapters/ws/index.d.mts +0 -31
  61. package/dist/adapters/ws/index.d.ts +0 -31
  62. package/dist/adapters/ws/index.mjs +0 -41
  63. package/dist/hibernation/index.d.mts +0 -44
  64. package/dist/hibernation/index.d.ts +0 -44
  65. package/dist/hibernation/index.mjs +0 -65
  66. package/dist/shared/server.7cEtMB30.d.ts +0 -74
  67. package/dist/shared/server.B8gYOD5g.d.mts +0 -12
  68. package/dist/shared/server.BqadksTP.d.mts +0 -74
  69. package/dist/shared/server.C8_sRzQB.d.mts +0 -42
  70. package/dist/shared/server.ChUyt5-i.d.mts +0 -32
  71. package/dist/shared/server.ChyoA9XY.d.ts +0 -42
  72. package/dist/shared/server.DEBcqOjg.mjs +0 -418
  73. package/dist/shared/server.EfTOZ2Q7.d.ts +0 -12
  74. package/dist/shared/server.TEVCLCFC.mjs +0 -39
  75. package/dist/shared/server.ZxHCEN1h.mjs +0 -226
  76. package/dist/shared/server.qKsRrdxW.d.mts +0 -193
  77. package/dist/shared/server.qKsRrdxW.d.ts +0 -193
  78. package/dist/shared/server.yoEB3Fx4.d.ts +0 -32
@@ -1,124 +1,206 @@
1
- import { runWithSpan, value, setSpanError, isAsyncIteratorObject, AsyncIteratorClass, clone } from '@orpc/shared';
2
- import { flattenHeader } from '@orpc/standard-server';
3
- import { parseBatchRequest, toBatchResponse } from '@orpc/standard-server/batch';
4
- import { toFetchHeaders } from '@orpc/standard-server-fetch';
1
+ import { toArray, value, isCompressibleContentType, isAsyncIteratorObject, stringifyJSON } from '@orpc/shared';
2
+ import { flattenStandardHeader, parseStandardUrl, generateContentDisposition, mergeStandardHeaders } from '@standardserver/core';
3
+ import { isClientPeerSendMessage, ServerPeer, encodePeerMessage } from '@standardserver/peer';
4
+ export { C as CSRFGuardHandlerPlugin } from '../shared/server.D_QauotT.mjs';
5
+ import { toFetchHeaders, toStandardBody, toStandardHeaders } from '@standardserver/fetch';
5
6
  import { ORPCError } from '@orpc/client';
6
- export { S as StrictGetMethodPlugin } from '../shared/server.TEVCLCFC.mjs';
7
- import '@orpc/contract';
8
7
 
9
8
  class BatchHandlerPlugin {
9
+ name = "~batch";
10
+ /**
11
+ * Run batch interceptors before OpenTelemetry interceptors
12
+ * so each subrequest gets its own span instead of sharing one batch-level span.
13
+ */
14
+ after = ["~opentelemetry"];
10
15
  maxSize;
11
- mapRequestItem;
16
+ mapSubrequest;
12
17
  successStatus;
13
18
  headers;
14
- order = 5e6;
19
+ keepAliveEnabled;
20
+ keepAliveInterval;
15
21
  constructor(options = {}) {
16
22
  this.maxSize = options.maxSize ?? 10;
17
- this.mapRequestItem = options.mapRequestItem ?? ((request, { request: batchRequest }) => ({
18
- ...request,
23
+ this.mapSubrequest = options.mapSubrequest ?? ((subRequest, { request: batchRequest }) => ({
24
+ ...subRequest,
19
25
  headers: {
20
26
  ...batchRequest.headers,
21
- ...request.headers
27
+ ...subRequest.headers,
28
+ "orpc-batch": void 0
29
+ // useful in case batch plugin is used multiple times
22
30
  }
23
31
  }));
24
32
  this.successStatus = options.successStatus ?? 207;
25
33
  this.headers = options.headers ?? {};
34
+ this.keepAliveEnabled = options.keepAlive?.enabled ?? true;
35
+ this.keepAliveInterval = options.keepAlive?.interval ?? 15e3;
26
36
  }
27
37
  init(options) {
28
- options.rootInterceptors ??= [];
29
- options.rootInterceptors.unshift(async (options2) => {
30
- const xHeader = flattenHeader(options2.request.headers["x-orpc-batch"]);
31
- if (xHeader === void 0) {
32
- return options2.next();
38
+ const routingInterceptor = async (interceptorOptions) => {
39
+ const batchHeader = flattenStandardHeader(interceptorOptions.request.headers["orpc-batch"]);
40
+ if (batchHeader === void 0) {
41
+ return interceptorOptions.next();
33
42
  }
34
- let isParsing = false;
43
+ const mode = batchHeader === "buffered" ? "buffered" : "streaming";
44
+ let messages;
35
45
  try {
36
- return await runWithSpan({ name: "handle_batch_request" }, async (span) => {
37
- const mode = xHeader === "buffered" ? "buffered" : "streaming";
38
- isParsing = true;
39
- const parsed = parseBatchRequest({ ...options2.request, body: await options2.request.body() });
40
- isParsing = false;
41
- span?.setAttribute("batch.mode", mode);
42
- span?.setAttribute("batch.size", parsed.length);
43
- const maxSize = await value(this.maxSize, options2);
44
- if (parsed.length > maxSize) {
45
- const message = "Batch request size exceeds the maximum allowed size";
46
- setSpanError(span, message);
46
+ if (interceptorOptions.request.method === "GET") {
47
+ const [, search] = parseStandardUrl(interceptorOptions.request.url);
48
+ const params = new URLSearchParams(search);
49
+ const data = params.getAll("data").at(-1);
50
+ if (!data) {
47
51
  return {
48
52
  matched: true,
49
- response: {
50
- status: 413,
51
- headers: {},
52
- body: message
53
- }
53
+ response: { status: 400, headers: {}, body: "Missing data parameter for batch request" }
54
54
  };
55
55
  }
56
- const responses = parsed.map(
57
- (request, index) => {
58
- const mapped = this.mapRequestItem(request, options2);
59
- return options2.next({ ...options2, request: { ...mapped, body: () => Promise.resolve(mapped.body) } }).then(({ response: response2, matched }) => {
60
- span?.addEvent(`response.${index}.${matched ? "success" : "not_matched"}`);
61
- if (matched) {
62
- if (response2.body instanceof Blob || response2.body instanceof FormData || isAsyncIteratorObject(response2.body)) {
63
- return {
64
- index,
65
- status: 500,
66
- headers: {},
67
- body: "Batch responses do not support file/blob, or event-iterator. Please call this procedure separately outside of the batch request."
68
- };
69
- }
70
- return { ...response2, index };
71
- }
72
- return { index, status: 404, headers: {}, body: "No procedure matched" };
73
- }).catch((err) => {
74
- Promise.reject(err);
75
- return { index, status: 500, headers: {}, body: "Internal server error" };
76
- });
77
- }
78
- );
79
- await Promise.race(responses);
80
- const status = await value(this.successStatus, responses, options2);
81
- const headers = await value(this.headers, responses, options2);
82
- const promises = [...responses];
83
- const response = await toBatchResponse({
84
- status,
85
- headers,
86
- mode,
87
- body: new AsyncIteratorClass(
88
- async () => {
89
- const handling = promises.filter((p) => p !== void 0);
90
- if (handling.length <= 0) {
91
- return { done: true, value: void 0 };
92
- }
93
- const value2 = await Promise.race(handling);
94
- promises[value2.index] = void 0;
95
- return { done: false, value: value2 };
96
- },
97
- async () => {
98
- }
99
- )
100
- });
101
- return {
102
- matched: true,
103
- response
104
- };
56
+ const mightBeMessages = JSON.parse(data);
57
+ if (!Array.isArray(mightBeMessages) || mightBeMessages.some((m) => !isClientPeerSendMessage(m))) {
58
+ return {
59
+ matched: true,
60
+ response: { status: 400, headers: {}, body: "Invalid batch request data parameter" }
61
+ };
62
+ }
63
+ messages = mightBeMessages;
64
+ } else {
65
+ const mightBeMessages = await interceptorOptions.request.resolveBody();
66
+ if (!Array.isArray(mightBeMessages)) {
67
+ return {
68
+ matched: true,
69
+ response: { status: 400, headers: {}, body: "Invalid batch request body" }
70
+ };
71
+ }
72
+ messages = mightBeMessages;
73
+ }
74
+ } catch {
75
+ return {
76
+ matched: true,
77
+ response: { status: 400, headers: {}, body: "Invalid batch request" }
78
+ };
79
+ }
80
+ const maxSize = await value(this.maxSize, interceptorOptions);
81
+ if (messages.length > maxSize) {
82
+ return {
83
+ matched: true,
84
+ response: { status: 413, headers: {}, body: "Batch request size exceeds the maximum allowed size" }
85
+ };
86
+ }
87
+ const handleIndividualRequest = async (request) => {
88
+ try {
89
+ request = this.mapSubrequest(request, interceptorOptions);
90
+ const { matched, response } = await interceptorOptions.next({ ...interceptorOptions, request });
91
+ if (!matched) {
92
+ return { status: 404, headers: {}, body: "No procedure matched" };
93
+ }
94
+ return response;
95
+ } catch (err) {
96
+ Promise.reject(err);
97
+ return { status: 500, headers: {}, body: "Internal server error" };
98
+ }
99
+ };
100
+ const status = await value(this.successStatus, interceptorOptions);
101
+ const headers = await value(this.headers, interceptorOptions);
102
+ if (mode === "buffered") {
103
+ const responseMessages = [];
104
+ const peer2 = new ServerPeer(async (message) => {
105
+ responseMessages.push(message);
105
106
  });
106
- } catch (cause) {
107
- if (isParsing) {
107
+ await Promise.all(messages.map((msg) => peer2.message(msg, handleIndividualRequest)));
108
+ await peer2.close();
109
+ if (responseMessages.some((msg) => msg.binary !== void 0)) {
110
+ const chunks = [];
111
+ for (const message of responseMessages) {
112
+ const encoded = await encodePeerMessage(message);
113
+ const bytes = typeof encoded === "string" ? new TextEncoder().encode(encoded) : encoded;
114
+ const lengthBuffer = new ArrayBuffer(4);
115
+ new DataView(lengthBuffer).setUint32(0, bytes.byteLength, false);
116
+ chunks.push(new Uint8Array(lengthBuffer));
117
+ chunks.push(bytes);
118
+ }
108
119
  return {
109
120
  matched: true,
110
- response: { status: 400, headers: {}, body: "Invalid batch request, this could be caused by a malformed request body or a missing header" }
121
+ response: {
122
+ status,
123
+ headers,
124
+ body: new Blob(chunks, { type: "application/octet-stream" })
125
+ }
111
126
  };
112
127
  }
113
- throw cause;
128
+ return {
129
+ matched: true,
130
+ response: { status, headers, body: responseMessages }
131
+ };
114
132
  }
115
- });
133
+ let streamController;
134
+ let keepAliveTimer;
135
+ const clearKeepAlive = () => {
136
+ if (keepAliveTimer !== void 0) {
137
+ clearInterval(keepAliveTimer);
138
+ keepAliveTimer = void 0;
139
+ }
140
+ };
141
+ const scheduleKeepAlive = () => {
142
+ if (!this.keepAliveEnabled) {
143
+ return;
144
+ }
145
+ clearKeepAlive();
146
+ keepAliveTimer = setInterval(() => {
147
+ try {
148
+ const lengthBuffer = new ArrayBuffer(4);
149
+ new DataView(lengthBuffer).setUint32(0, 0, false);
150
+ streamController.enqueue(new Uint8Array(lengthBuffer));
151
+ } catch {
152
+ clearKeepAlive();
153
+ }
154
+ }, this.keepAliveInterval);
155
+ };
156
+ const stream = new ReadableStream({
157
+ start(controller) {
158
+ streamController = controller;
159
+ scheduleKeepAlive();
160
+ },
161
+ cancel() {
162
+ clearKeepAlive();
163
+ }
164
+ });
165
+ const peer = new ServerPeer(async (message) => {
166
+ const encoded = await encodePeerMessage(message);
167
+ const bytes = typeof encoded === "string" ? new TextEncoder().encode(encoded) : encoded;
168
+ const lengthBuffer = new ArrayBuffer(4);
169
+ new DataView(lengthBuffer).setUint32(0, bytes.byteLength, false);
170
+ streamController.enqueue(new Uint8Array(lengthBuffer));
171
+ streamController.enqueue(bytes);
172
+ scheduleKeepAlive();
173
+ });
174
+ Promise.all(messages.map((msg) => peer.message(msg, handleIndividualRequest))).then(async () => {
175
+ clearKeepAlive();
176
+ streamController.close();
177
+ await peer.close();
178
+ }).catch(async (error) => {
179
+ clearKeepAlive();
180
+ streamController.error(error);
181
+ await peer.close(error);
182
+ });
183
+ return {
184
+ matched: true,
185
+ response: { status, headers, body: stream }
186
+ };
187
+ };
188
+ return {
189
+ ...options,
190
+ routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)]
191
+ };
116
192
  }
117
193
  }
118
194
 
119
- class CORSPlugin {
195
+ class CORSHandlerPlugin {
120
196
  options;
121
- order = 9e6;
197
+ name = "~cors";
198
+ /**
199
+ * - Do not create spans for CORS preflight requests.
200
+ * - Run CORS interceptors before batch interceptors so headers are applied to
201
+ * the actual response rather than sub-responses.
202
+ */
203
+ after = ["~opentelemetry", "~batch"];
122
204
  constructor(options = {}) {
123
205
  const defaults = {
124
206
  origin: (origin) => origin,
@@ -130,19 +212,51 @@ class CORSPlugin {
130
212
  };
131
213
  }
132
214
  init(options) {
133
- options.rootInterceptors ??= [];
134
- options.rootInterceptors.unshift(async (interceptorOptions) => {
215
+ const corsHeadersInterceptor = async (interceptorOptions) => {
216
+ const result = await interceptorOptions.next();
217
+ if (!result.matched) {
218
+ return result;
219
+ }
220
+ const resHeaders = { ...result.response.headers };
221
+ const origin = flattenStandardHeader(interceptorOptions.request.headers.origin) ?? "";
222
+ const allowedOrigins = toArray(await value(this.options.origin, origin, interceptorOptions));
223
+ if (allowedOrigins.includes("*")) {
224
+ resHeaders["access-control-allow-origin"] = "*";
225
+ } else {
226
+ if (allowedOrigins.includes(origin)) {
227
+ resHeaders["access-control-allow-origin"] = origin;
228
+ }
229
+ const existingVary = flattenStandardHeader(resHeaders.vary);
230
+ if (!existingVary?.split(",").some((v) => v.trim().toLowerCase() === "origin")) {
231
+ resHeaders.vary = existingVary ? `${existingVary}, Origin` : "Origin";
232
+ }
233
+ }
234
+ const allowedTimingOrigins = toArray(await value(this.options.timingOrigin, origin, interceptorOptions));
235
+ if (allowedTimingOrigins.includes("*")) {
236
+ resHeaders["timing-allow-origin"] = "*";
237
+ } else if (allowedTimingOrigins.includes(origin)) {
238
+ resHeaders["timing-allow-origin"] = origin;
239
+ }
240
+ if (this.options.credentials) {
241
+ resHeaders["access-control-allow-credentials"] = "true";
242
+ }
243
+ if (this.options.exposeHeaders?.length) {
244
+ resHeaders["access-control-expose-headers"] = flattenStandardHeader(this.options.exposeHeaders);
245
+ }
246
+ return { ...result, response: { ...result.response, headers: resHeaders } };
247
+ };
248
+ const preflightInterceptor = async (interceptorOptions) => {
135
249
  if (interceptorOptions.request.method === "OPTIONS") {
136
250
  const resHeaders = {};
137
251
  if (this.options.maxAge !== void 0) {
138
252
  resHeaders["access-control-max-age"] = this.options.maxAge.toString();
139
253
  }
140
254
  if (this.options.allowMethods?.length) {
141
- resHeaders["access-control-allow-methods"] = flattenHeader(this.options.allowMethods);
255
+ resHeaders["access-control-allow-methods"] = flattenStandardHeader(this.options.allowMethods);
142
256
  }
143
257
  const allowHeaders = this.options.allowHeaders ?? interceptorOptions.request.headers["access-control-request-headers"];
144
258
  if (typeof allowHeaders === "string" || allowHeaders?.length) {
145
- resHeaders["access-control-allow-headers"] = flattenHeader(allowHeaders);
259
+ resHeaders["access-control-allow-headers"] = flattenStandardHeader(allowHeaders);
146
260
  }
147
261
  return {
148
262
  matched: true,
@@ -154,106 +268,385 @@ class CORSPlugin {
154
268
  };
155
269
  }
156
270
  return interceptorOptions.next();
157
- });
158
- options.rootInterceptors.unshift(async (interceptorOptions) => {
159
- const result = await interceptorOptions.next();
160
- if (!result.matched) {
161
- return result;
162
- }
163
- const origin = flattenHeader(interceptorOptions.request.headers.origin) ?? "";
164
- const allowedOrigin = await value(this.options.origin, origin, interceptorOptions);
165
- const allowedOriginArr = Array.isArray(allowedOrigin) ? allowedOrigin : [allowedOrigin];
166
- if (allowedOriginArr.includes("*")) {
167
- result.response.headers["access-control-allow-origin"] = "*";
168
- } else {
169
- if (allowedOriginArr.includes(origin)) {
170
- result.response.headers["access-control-allow-origin"] = origin;
171
- }
172
- const existingVary = flattenHeader(result.response.headers.vary);
173
- if (!existingVary?.split(",").some((v) => v.trim().toLowerCase() === "origin")) {
174
- result.response.headers.vary = existingVary ? `${existingVary}, Origin` : "Origin";
175
- }
176
- }
177
- const allowedTimingOrigin = await value(this.options.timingOrigin, origin, interceptorOptions);
178
- const allowedTimingOriginArr = Array.isArray(allowedTimingOrigin) ? allowedTimingOrigin : [allowedTimingOrigin];
179
- if (allowedTimingOriginArr.includes("*")) {
180
- result.response.headers["timing-allow-origin"] = "*";
181
- } else if (allowedTimingOriginArr.includes(origin)) {
182
- result.response.headers["timing-allow-origin"] = origin;
183
- }
184
- if (this.options.credentials) {
185
- result.response.headers["access-control-allow-credentials"] = "true";
186
- }
187
- if (this.options.exposeHeaders?.length) {
188
- result.response.headers["access-control-expose-headers"] = flattenHeader(this.options.exposeHeaders);
189
- }
190
- return result;
191
- });
271
+ };
272
+ return {
273
+ ...options,
274
+ routingInterceptors: [
275
+ corsHeadersInterceptor,
276
+ preflightInterceptor,
277
+ ...toArray(options.routingInterceptors)
278
+ ]
279
+ };
192
280
  }
193
281
  }
194
282
 
195
- class RequestHeadersPlugin {
283
+ class RequestCompressionHandlerPlugin {
284
+ name = "~request-compression";
285
+ /**
286
+ * Should decompress the original batch request body instead of sub-requests.
287
+ */
288
+ after = ["~batch"];
196
289
  init(options) {
197
- options.rootInterceptors ??= [];
198
- options.rootInterceptors.push((interceptorOptions) => {
199
- const reqHeaders = interceptorOptions.context.reqHeaders ?? toFetchHeaders(interceptorOptions.request.headers);
200
- return interceptorOptions.next({
290
+ const routingInterceptor = async ({ next, ...interceptorOptions }) => {
291
+ const encodings = parseContentEncodings(
292
+ flattenStandardHeader(interceptorOptions.request.headers["content-encoding"])
293
+ );
294
+ if (encodings.length === 0 || !encodings.every(isSupportedEncoding)) {
295
+ return next();
296
+ }
297
+ const decompressedHeaders = {
298
+ ...interceptorOptions.request.headers,
299
+ "content-length": void 0,
300
+ "content-encoding": void 0
301
+ };
302
+ return next({
201
303
  ...interceptorOptions,
202
- context: {
203
- ...interceptorOptions.context,
204
- reqHeaders
304
+ request: {
305
+ ...interceptorOptions.request,
306
+ headers: decompressedHeaders,
307
+ async resolveBody(hint) {
308
+ const stream = await interceptorOptions.request.resolveBody("octet-stream");
309
+ if (!(stream instanceof ReadableStream)) {
310
+ return stream;
311
+ }
312
+ let decompressedStream = stream;
313
+ for (let i = encodings.length - 1; i >= 0; i--) {
314
+ decompressedStream = decompressedStream.pipeThrough(
315
+ new DecompressionStream(encodings[i])
316
+ );
317
+ }
318
+ const response = new Response(decompressedStream, {
319
+ headers: toFetchHeaders(decompressedHeaders)
320
+ });
321
+ return toStandardBody(response, { hint });
322
+ }
205
323
  }
206
324
  });
207
- });
325
+ };
326
+ return {
327
+ ...options,
328
+ routingInterceptors: [
329
+ routingInterceptor,
330
+ ...toArray(options.routingInterceptors)
331
+ ]
332
+ };
208
333
  }
209
334
  }
335
+ const SUPPORTED_ENCODINGS = ["gzip", "deflate", "deflate-raw"];
336
+ function isSupportedEncoding(encoding) {
337
+ return SUPPORTED_ENCODINGS.includes(encoding);
338
+ }
339
+ function parseContentEncodings(header) {
340
+ if (header === void 0) {
341
+ return [];
342
+ }
343
+ return header.split(",").map((part) => part.trim().toLowerCase());
344
+ }
210
345
 
211
- class ResponseHeadersPlugin {
346
+ class RequestHeadersHandlerPlugin {
347
+ name = "~request-headers";
212
348
  init(options) {
213
- options.rootInterceptors ??= [];
214
- options.rootInterceptors.push(async (interceptorOptions) => {
215
- const resHeaders = interceptorOptions.context.resHeaders ?? new Headers();
216
- const result = await interceptorOptions.next({
349
+ return {
350
+ ...options,
351
+ interceptors: [
352
+ (interceptorOptions) => {
353
+ const reqHeaders = interceptorOptions.context.reqHeaders ?? toFetchHeaders(interceptorOptions.request.headers);
354
+ return interceptorOptions.next({
355
+ ...interceptorOptions,
356
+ context: {
357
+ ...interceptorOptions.context,
358
+ reqHeaders
359
+ }
360
+ });
361
+ },
362
+ ...toArray(options.interceptors)
363
+ ]
364
+ };
365
+ }
366
+ }
367
+
368
+ class RequestLimitHandlerPlugin {
369
+ name = "~request-limit";
370
+ /**
371
+ * Should limit the original batch request body instead of sub-requests.
372
+ */
373
+ after = ["~batch"];
374
+ /**
375
+ * Should limit the final body size instead of the compressed one.
376
+ */
377
+ before = ["~request-compression"];
378
+ maxBodySize;
379
+ constructor(options) {
380
+ this.maxBodySize = options.maxBodySize;
381
+ }
382
+ init(options) {
383
+ const maxBodySize = this.maxBodySize;
384
+ const routingInterceptor = async ({ next, ...interceptorOptions }) => {
385
+ return next({
217
386
  ...interceptorOptions,
218
- context: {
219
- ...interceptorOptions.context,
220
- resHeaders
387
+ request: {
388
+ ...interceptorOptions.request,
389
+ async resolveBody(hint) {
390
+ const contentLength = Number(
391
+ flattenStandardHeader(interceptorOptions.request.headers["content-length"])
392
+ );
393
+ if (Number.isFinite(contentLength) && contentLength > maxBodySize) {
394
+ throw new ORPCError("PAYLOAD_TOO_LARGE");
395
+ }
396
+ const stream = await interceptorOptions.request.resolveBody("octet-stream");
397
+ if (!(stream instanceof ReadableStream)) {
398
+ return stream;
399
+ }
400
+ let currentBodySize = 0;
401
+ const limitedStream = stream.pipeThrough(
402
+ new TransformStream({
403
+ transform(chunk, controller) {
404
+ currentBodySize += chunk.byteLength;
405
+ if (currentBodySize > maxBodySize) {
406
+ controller.error(new ORPCError("PAYLOAD_TOO_LARGE"));
407
+ return;
408
+ }
409
+ controller.enqueue(chunk);
410
+ }
411
+ })
412
+ );
413
+ const response = new Response(limitedStream, {
414
+ headers: toFetchHeaders(interceptorOptions.request.headers)
415
+ });
416
+ return toStandardBody(response, { hint });
417
+ }
221
418
  }
222
419
  });
420
+ };
421
+ return {
422
+ ...options,
423
+ routingInterceptors: [
424
+ routingInterceptor,
425
+ ...toArray(options.routingInterceptors)
426
+ ]
427
+ };
428
+ }
429
+ }
430
+
431
+ const AVG_BYTES_PER_CHAR = 1.2;
432
+ class ResponseCompressionHandlerPlugin {
433
+ name = "~response-compression";
434
+ /**
435
+ * Compression should be done after batching, to compress the final response.
436
+ * Compression should also be done after response headers are set, to access final headers like Content-Type and Cache-Control.
437
+ */
438
+ after = ["~batch", "~response-headers"];
439
+ encodings;
440
+ threshold;
441
+ constructor(options = {}) {
442
+ this.encodings = options.encodings ?? ["gzip", "deflate"];
443
+ this.threshold = options.threshold ?? 1024;
444
+ }
445
+ init(options) {
446
+ const routingInterceptor = async ({ next, ...interceptorOptions }) => {
447
+ const result = await next();
223
448
  if (!result.matched) {
224
449
  return result;
225
450
  }
226
- const responseHeaders = clone(result.response.headers);
227
- for (const [key, value] of resHeaders) {
228
- if (Array.isArray(responseHeaders[key])) {
229
- responseHeaders[key].push(value);
230
- } else if (responseHeaders[key] !== void 0) {
231
- responseHeaders[key] = [responseHeaders[key], value];
232
- } else {
233
- responseHeaders[key] = value;
234
- }
451
+ const response = result.response;
452
+ const contentEncoding = flattenStandardHeader(response.headers["content-encoding"])?.trim()?.toLowerCase();
453
+ if (contentEncoding !== void 0) {
454
+ return result;
235
455
  }
236
- return {
237
- ...result,
238
- response: {
239
- ...result.response,
240
- headers: responseHeaders
456
+ if (isNoTransformCacheControl(flattenStandardHeader(response.headers["cache-control"]))) {
457
+ return result;
458
+ }
459
+ const acceptEncodings = parseAcceptEncodings(
460
+ flattenStandardHeader(interceptorOptions.request.headers["accept-encoding"])
461
+ );
462
+ const encoding = this.encodings.find((enc) => acceptEncodings.includes(enc));
463
+ if (encoding === void 0) {
464
+ return result;
465
+ }
466
+ const body = response.body;
467
+ const headers = response.headers;
468
+ if (body instanceof ReadableStream) {
469
+ const contentLength = Number(flattenStandardHeader(headers["content-length"]));
470
+ if ((!Number.isFinite(contentLength) || contentLength >= this.threshold) && isCompressibleContentType(flattenStandardHeader(headers["content-type"]))) {
471
+ return {
472
+ ...result,
473
+ response: {
474
+ ...response,
475
+ body: body.pipeThrough(new CompressionStream(encoding)),
476
+ headers: {
477
+ ...headers,
478
+ "standard-server": "octet-stream",
479
+ "content-length": [],
480
+ "content-encoding": encoding
481
+ }
482
+ }
483
+ };
241
484
  }
242
- };
243
- });
485
+ } else if (body instanceof Blob) {
486
+ if ((!Number.isFinite(body.size) || body.size >= this.threshold) && isCompressibleContentType(body.type)) {
487
+ const contentDisposition = headers["content-disposition"] ?? generateContentDisposition(
488
+ body instanceof File ? body.name : "blob"
489
+ );
490
+ return {
491
+ ...result,
492
+ response: {
493
+ ...response,
494
+ body: body.stream().pipeThrough(new CompressionStream(encoding)),
495
+ headers: {
496
+ ...headers,
497
+ "standard-server": "file",
498
+ "content-type": body.type,
499
+ "content-length": [],
500
+ "content-disposition": contentDisposition,
501
+ "content-encoding": encoding
502
+ }
503
+ }
504
+ };
505
+ }
506
+ } else if (body instanceof FormData) {
507
+ const PART_OVERHEAD = 64;
508
+ let contentLength = 0;
509
+ for (const [key, value] of body) {
510
+ contentLength += PART_OVERHEAD + key.length;
511
+ if (value instanceof Blob) {
512
+ if (!Number.isFinite(value.size)) {
513
+ if (!isCompressibleContentType(value.type)) {
514
+ contentLength = -Infinity;
515
+ break;
516
+ }
517
+ contentLength = Infinity;
518
+ } else {
519
+ contentLength += isCompressibleContentType(value.type) ? value.size : -value.size;
520
+ }
521
+ } else {
522
+ contentLength += value.length * AVG_BYTES_PER_CHAR;
523
+ }
524
+ }
525
+ if (contentLength >= this.threshold) {
526
+ const res = new Response(body);
527
+ const compressedStream = res.body.pipeThrough(new CompressionStream(encoding));
528
+ return {
529
+ ...result,
530
+ response: {
531
+ ...response,
532
+ body: compressedStream,
533
+ headers: {
534
+ ...headers,
535
+ "standard-server": [],
536
+ "content-type": res.headers.get("content-type"),
537
+ "content-length": [],
538
+ "content-encoding": encoding
539
+ }
540
+ }
541
+ };
542
+ }
543
+ } else if (body instanceof URLSearchParams) {
544
+ const string = body.toString();
545
+ if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
546
+ return {
547
+ ...result,
548
+ response: {
549
+ ...response,
550
+ body: new Blob([string]).stream().pipeThrough(new CompressionStream(encoding)),
551
+ headers: {
552
+ ...headers,
553
+ "standard-server": [],
554
+ "content-type": "application/x-www-form-urlencoded",
555
+ "content-length": [],
556
+ "content-encoding": encoding
557
+ }
558
+ }
559
+ };
560
+ }
561
+ } else if (body !== void 0 && !isAsyncIteratorObject(body)) {
562
+ const string = stringifyJSON(body);
563
+ if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
564
+ return {
565
+ ...result,
566
+ response: {
567
+ ...response,
568
+ body: new Blob([string]).stream().pipeThrough(new CompressionStream(encoding)),
569
+ headers: {
570
+ ...headers,
571
+ "standard-server": [],
572
+ "content-type": "application/json",
573
+ "content-length": [],
574
+ "content-encoding": encoding
575
+ }
576
+ }
577
+ };
578
+ }
579
+ }
580
+ return result;
581
+ };
582
+ return {
583
+ ...options,
584
+ routingInterceptors: [
585
+ routingInterceptor,
586
+ ...toArray(options.routingInterceptors)
587
+ ]
588
+ };
589
+ }
590
+ }
591
+ function parseAcceptEncodings(header) {
592
+ if (header === void 0) {
593
+ return [];
244
594
  }
595
+ return header.split(",").map((part) => part.trim().split(";")[0].trim().toLowerCase()).filter(Boolean);
596
+ }
597
+ const CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*no-transform\s*(?:,|$)/i;
598
+ function isNoTransformCacheControl(cacheControl) {
599
+ if (cacheControl === void 0) {
600
+ return false;
601
+ }
602
+ return CACHE_CONTROL_NO_TRANSFORM_REGEX.test(cacheControl);
245
603
  }
246
604
 
247
- class experimental_RethrowHandlerPlugin {
605
+ class ResponseHeadersHandlerPlugin {
606
+ name = "~response-headers";
607
+ /**
608
+ * Interceptors should run after batch interceptors so headers are applied to each sub-response.
609
+ */
610
+ before = ["~batch"];
611
+ init(options) {
612
+ return {
613
+ ...options,
614
+ routingInterceptors: [
615
+ async (interceptorOptions) => {
616
+ const resHeaders = new Headers(interceptorOptions.context.resHeaders);
617
+ const result = await interceptorOptions.next({
618
+ ...interceptorOptions,
619
+ context: {
620
+ ...interceptorOptions.context,
621
+ resHeaders
622
+ }
623
+ });
624
+ if (!result.response) {
625
+ return result;
626
+ }
627
+ return {
628
+ ...result,
629
+ response: {
630
+ ...result.response,
631
+ headers: mergeStandardHeaders(result.response.headers, toStandardHeaders(resHeaders))
632
+ }
633
+ };
634
+ },
635
+ ...toArray(options.routingInterceptors)
636
+ ]
637
+ };
638
+ }
639
+ }
640
+
641
+ class RethrowHandlerPlugin {
642
+ name = "~rethrow";
248
643
  filter;
249
644
  CONTEXT_SYMBOL = Symbol("ORPC_RETHROW_HANDLER_PLUGIN_CONTEXT");
250
645
  constructor(options) {
251
646
  this.filter = options.filter;
252
647
  }
253
648
  init(options) {
254
- options.rootInterceptors ??= [];
255
- options.interceptors ??= [];
256
- options.rootInterceptors.push(async (options2) => {
649
+ const routingInterceptor = async (options2) => {
257
650
  const pluginContext = {};
258
651
  const result = await options2.next({
259
652
  ...options2,
@@ -266,8 +659,8 @@ class experimental_RethrowHandlerPlugin {
266
659
  throw pluginContext.error.value;
267
660
  }
268
661
  return result;
269
- });
270
- options.interceptors.unshift(async (options2) => {
662
+ };
663
+ const interceptor = async (options2) => {
271
664
  const pluginContext = options2.context[this.CONTEXT_SYMBOL];
272
665
  if (!pluginContext) {
273
666
  throw new TypeError("[RethrowHandlerPlugin] Rethrow handler context has been corrupted or modified by another plugin or interceptor");
@@ -275,57 +668,26 @@ class experimental_RethrowHandlerPlugin {
275
668
  try {
276
669
  return await options2.next();
277
670
  } catch (error) {
278
- if (value(this.filter, error, options2)) {
671
+ if (this.filter(error, options2)) {
279
672
  pluginContext.error = { value: error };
280
- return { matched: false, response: void 0 };
281
673
  }
282
674
  throw error;
283
675
  }
284
- });
285
- }
286
- }
287
-
288
- const SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL = Symbol("SIMPLE_CSRF_PROTECTION_CONTEXT");
289
- class SimpleCsrfProtectionHandlerPlugin {
290
- headerName;
291
- headerValue;
292
- exclude;
293
- error;
294
- constructor(options = {}) {
295
- this.headerName = options.headerName ?? "x-csrf-token";
296
- this.headerValue = options.headerValue ?? "orpc";
297
- this.exclude = options.exclude ?? false;
298
- this.error = options.error ?? new ORPCError("CSRF_TOKEN_MISMATCH", {
299
- status: 403,
300
- message: "Invalid CSRF token"
301
- });
302
- }
303
- order = 8e6;
304
- init(options) {
305
- options.rootInterceptors ??= [];
306
- options.clientInterceptors ??= [];
307
- options.rootInterceptors.unshift(async (options2) => {
308
- const headerName = await value(this.headerName, options2);
309
- const headerValue = await value(this.headerValue, options2);
310
- return options2.next({
311
- ...options2,
312
- context: {
313
- ...options2.context,
314
- [SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL]: options2.request.headers[headerName] === headerValue
315
- }
316
- });
317
- });
318
- options.clientInterceptors.unshift(async (options2) => {
319
- if (typeof options2.context[SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL] !== "boolean") {
320
- throw new TypeError("[SimpleCsrfProtectionHandlerPlugin] CSRF protection context has been corrupted or modified by another plugin or interceptor");
321
- }
322
- const excluded = await value(this.exclude, options2);
323
- if (!excluded && !options2.context[SIMPLE_CSRF_PROTECTION_CONTEXT_SYMBOL]) {
324
- throw this.error;
325
- }
326
- return options2.next();
327
- });
676
+ };
677
+ return {
678
+ ...options,
679
+ routingInterceptors: [
680
+ ...toArray(options.routingInterceptors),
681
+ routingInterceptor
682
+ // rethrow as early as possible and keep it close to `interceptor` to avoid a corrupted or modified context
683
+ ],
684
+ interceptors: [
685
+ interceptor,
686
+ // catch as many errors as possible
687
+ ...toArray(options.interceptors)
688
+ ]
689
+ };
328
690
  }
329
691
  }
330
692
 
331
- export { BatchHandlerPlugin, CORSPlugin, RequestHeadersPlugin, ResponseHeadersPlugin, SimpleCsrfProtectionHandlerPlugin, experimental_RethrowHandlerPlugin };
693
+ export { BatchHandlerPlugin, CORSHandlerPlugin, RequestCompressionHandlerPlugin, RequestHeadersHandlerPlugin, RequestLimitHandlerPlugin, ResponseCompressionHandlerPlugin, ResponseHeadersHandlerPlugin, RethrowHandlerPlugin };