@orpc/server 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.
- package/README.md +97 -94
- package/dist/adapters/aws-lambda/index.d.mts +50 -0
- package/dist/adapters/aws-lambda/index.d.ts +50 -0
- package/dist/adapters/aws-lambda/index.mjs +39 -0
- package/dist/adapters/crossws/index.d.mts +21 -12
- package/dist/adapters/crossws/index.d.ts +21 -12
- package/dist/adapters/crossws/index.mjs +14 -10
- package/dist/adapters/fastify/index.d.mts +42 -0
- package/dist/adapters/fastify/index.d.ts +42 -0
- package/dist/adapters/fastify/index.mjs +39 -0
- package/dist/adapters/fetch/index.d.mts +21 -114
- package/dist/adapters/fetch/index.d.ts +21 -114
- package/dist/adapters/fetch/index.mjs +20 -182
- package/dist/adapters/message-port/index.d.mts +23 -20
- package/dist/adapters/message-port/index.d.ts +23 -20
- package/dist/adapters/message-port/index.mjs +31 -34
- package/dist/adapters/node/index.d.mts +19 -96
- package/dist/adapters/node/index.d.ts +19 -96
- package/dist/adapters/node/index.mjs +19 -157
- package/dist/adapters/standard/index.d.mts +91 -17
- package/dist/adapters/standard/index.d.ts +91 -17
- package/dist/adapters/standard/index.mjs +4 -4
- package/dist/adapters/standard-peer/index.d.mts +6 -11
- package/dist/adapters/standard-peer/index.d.ts +6 -11
- package/dist/adapters/standard-peer/index.mjs +10 -2
- package/dist/adapters/websocket/index.d.mts +45 -29
- package/dist/adapters/websocket/index.d.ts +45 -29
- package/dist/adapters/websocket/index.mjs +39 -28
- package/dist/extensions/callable.d.mts +5 -5
- package/dist/extensions/callable.d.ts +5 -5
- package/dist/extensions/callable.mjs +2 -2
- package/dist/helpers/index.d.mts +23 -6
- package/dist/helpers/index.d.ts +23 -6
- package/dist/helpers/index.mjs +13 -5
- package/dist/index.d.mts +332 -37
- package/dist/index.d.ts +332 -37
- package/dist/index.mjs +141 -65
- package/dist/plugins/index.d.mts +255 -31
- package/dist/plugins/index.d.ts +255 -31
- package/dist/plugins/index.mjs +559 -38
- package/dist/shared/server.1ExWFMze.mjs +69 -0
- package/dist/shared/{server.CjOb6ItT.mjs → server.AVQWL-DI.mjs} +1 -1
- package/dist/shared/{server.BL22TloH.d.mts → server.BEdOJs6S.d.mts} +41 -62
- package/dist/shared/{server.BL22TloH.d.ts → server.BEdOJs6S.d.ts} +41 -62
- package/dist/shared/{server.GDpX6Df8.mjs → server.BFEVk-EI.mjs} +164 -70
- package/dist/shared/{server.BwHnWUuN.mjs → server.BgvqVW_v.mjs} +25 -20
- package/dist/shared/server.BllfVJLm.d.mts +34 -0
- package/dist/shared/server.COfbQ0D8.d.ts +34 -0
- package/dist/shared/{server.DBANIheG.d.ts → server.CVO0hWyj.d.mts} +24 -13
- package/dist/shared/{server.BsNNjG5J.d.mts → server.DD1roZl9.d.mts} +32 -20
- package/dist/shared/server.DFEuNUt4.d.ts +14 -0
- package/dist/shared/{server.DJIkub_t.d.mts → server.DJNkZPfD.d.ts} +24 -13
- package/dist/shared/{server.Pa0F03f_.d.ts → server.DZeVx8K9.d.ts} +32 -20
- package/dist/shared/{server.CrlKQucM.mjs → server.FEqy7Hrj.mjs} +25 -53
- package/dist/shared/server.UG_xdTub.d.mts +14 -0
- package/package.json +48 -14
- package/dist/shared/server.CX4vUnDk.mjs +0 -11
- package/dist/shared/server.Cj5lgPuG.d.mts +0 -66
- package/dist/shared/server.D_QauotT.mjs +0 -30
- package/dist/shared/server.d_2NS3g5.d.ts +0 -66
package/dist/plugins/index.mjs
CHANGED
|
@@ -1,34 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
import { toFetchHeaders, toStandardHeaders } from '@
|
|
6
|
-
|
|
1
|
+
import { ORPCError } from '@orpc/client';
|
|
2
|
+
import { toArray, value, isPlainObject, isTypescriptObject, isAsyncIteratorObject, override, wrapAsyncIterator, isCompressibleContentType, isNoTransformCacheControl, parseAcceptEncodingQualities, isAcceptableEncoding, varyByAcceptEncoding, stringifyJSON, anyAbortSignal, wrapReadableStream, AbortError } from '@orpc/shared';
|
|
3
|
+
import { flattenStandardHeader, parseStandardUrl, generateContentDisposition, mergeStandardHeaders } from '@standard-server/core';
|
|
4
|
+
import { isClientPeerSendMessage, ServerPeer, encodePeerMessage } from '@standard-server/peer';
|
|
5
|
+
import { toFetchHeaders, toStandardBody, toStandardHeaders } from '@standard-server/fetch';
|
|
6
|
+
export { R as RequestLimitHandlerPlugin } from '../shared/server.1ExWFMze.mjs';
|
|
7
7
|
|
|
8
|
+
const BATCH_CONTENT_TYPE = "application/vnd.orpc.batch";
|
|
8
9
|
class BatchHandlerPlugin {
|
|
9
10
|
name = "~batch";
|
|
10
11
|
/**
|
|
11
|
-
* Run batch interceptors
|
|
12
|
+
* Run batch interceptors inside the tracing interceptors
|
|
12
13
|
* so each subrequest gets its own span instead of sharing one batch-level span.
|
|
13
14
|
*/
|
|
14
|
-
after = ["~
|
|
15
|
+
after = ["~tracing"];
|
|
15
16
|
maxSize;
|
|
16
17
|
mapSubrequest;
|
|
17
18
|
successStatus;
|
|
18
19
|
headers;
|
|
20
|
+
keepAliveEnabled;
|
|
21
|
+
keepAliveInterval;
|
|
19
22
|
constructor(options = {}) {
|
|
20
23
|
this.maxSize = options.maxSize ?? 10;
|
|
21
24
|
this.mapSubrequest = options.mapSubrequest ?? ((subRequest, { request: batchRequest }) => ({
|
|
22
25
|
...subRequest,
|
|
23
26
|
headers: {
|
|
24
|
-
...batchRequest.headers,
|
|
25
27
|
...subRequest.headers,
|
|
28
|
+
/**
|
|
29
|
+
* The batch request headers win over the subrequest ones. They are the only ones the
|
|
30
|
+
* transport actually saw, so headers the browser injects on its own, such as `cookie`
|
|
31
|
+
* or `origin`, cannot be overridden by a subrequest, which is just request payload.
|
|
32
|
+
*/
|
|
33
|
+
...batchRequest.headers,
|
|
26
34
|
"orpc-batch": void 0
|
|
27
35
|
// useful in case batch plugin is used multiple times
|
|
28
36
|
}
|
|
29
37
|
}));
|
|
30
38
|
this.successStatus = options.successStatus ?? 207;
|
|
31
39
|
this.headers = options.headers ?? {};
|
|
40
|
+
this.keepAliveEnabled = options.keepAlive?.enabled ?? true;
|
|
41
|
+
this.keepAliveInterval = options.keepAlive?.interval ?? 15e3;
|
|
32
42
|
}
|
|
33
43
|
init(options) {
|
|
34
44
|
const routingInterceptor = async (interceptorOptions) => {
|
|
@@ -59,7 +69,7 @@ class BatchHandlerPlugin {
|
|
|
59
69
|
messages = mightBeMessages;
|
|
60
70
|
} else {
|
|
61
71
|
const mightBeMessages = await interceptorOptions.request.resolveBody();
|
|
62
|
-
if (!Array.isArray(mightBeMessages)) {
|
|
72
|
+
if (!Array.isArray(mightBeMessages) || mightBeMessages.some((m) => !isClientPeerSendMessage(m))) {
|
|
63
73
|
return {
|
|
64
74
|
matched: true,
|
|
65
75
|
response: { status: 400, headers: {}, body: "Invalid batch request body" }
|
|
@@ -67,10 +77,21 @@ class BatchHandlerPlugin {
|
|
|
67
77
|
}
|
|
68
78
|
messages = mightBeMessages;
|
|
69
79
|
}
|
|
70
|
-
} catch {
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return {
|
|
82
|
+
matched: true,
|
|
83
|
+
response: {
|
|
84
|
+
status: 400,
|
|
85
|
+
headers: {},
|
|
86
|
+
body: error instanceof ORPCError ? error.message : "Invalid batch request"
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const outerMethod = interceptorOptions.request.method;
|
|
91
|
+
if ((outerMethod === "GET" || outerMethod === "QUERY") && messages.some((message) => message.kind === "request" && message.json.method !== outerMethod)) {
|
|
71
92
|
return {
|
|
72
93
|
matched: true,
|
|
73
|
-
response: { status: 400, headers: {}, body:
|
|
94
|
+
response: { status: 400, headers: {}, body: `${outerMethod} batch requests only accept ${outerMethod} sub-requests` }
|
|
74
95
|
};
|
|
75
96
|
}
|
|
76
97
|
const maxSize = await value(this.maxSize, interceptorOptions);
|
|
@@ -93,6 +114,20 @@ class BatchHandlerPlugin {
|
|
|
93
114
|
return { status: 500, headers: {}, body: "Internal server error" };
|
|
94
115
|
}
|
|
95
116
|
};
|
|
117
|
+
const runSubrequests = async (peer2) => {
|
|
118
|
+
const promise = Promise.all(messages.map((msg) => peer2.message(msg, handleIndividualRequest)));
|
|
119
|
+
const signal = interceptorOptions.request.signal;
|
|
120
|
+
const closePeer = () => peer2.close(signal?.reason);
|
|
121
|
+
if (signal?.aborted) {
|
|
122
|
+
closePeer();
|
|
123
|
+
}
|
|
124
|
+
signal?.addEventListener("abort", closePeer);
|
|
125
|
+
try {
|
|
126
|
+
await promise;
|
|
127
|
+
} finally {
|
|
128
|
+
signal?.removeEventListener("abort", closePeer);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
96
131
|
const status = await value(this.successStatus, interceptorOptions);
|
|
97
132
|
const headers = await value(this.headers, interceptorOptions);
|
|
98
133
|
if (mode === "buffered") {
|
|
@@ -100,7 +135,7 @@ class BatchHandlerPlugin {
|
|
|
100
135
|
const peer2 = new ServerPeer(async (message) => {
|
|
101
136
|
responseMessages.push(message);
|
|
102
137
|
});
|
|
103
|
-
await
|
|
138
|
+
await runSubrequests(peer2);
|
|
104
139
|
await peer2.close();
|
|
105
140
|
if (responseMessages.some((msg) => msg.binary !== void 0)) {
|
|
106
141
|
const chunks = [];
|
|
@@ -117,7 +152,7 @@ class BatchHandlerPlugin {
|
|
|
117
152
|
response: {
|
|
118
153
|
status,
|
|
119
154
|
headers,
|
|
120
|
-
body: new Blob(chunks, { type:
|
|
155
|
+
body: new Blob(chunks, { type: BATCH_CONTENT_TYPE })
|
|
121
156
|
}
|
|
122
157
|
};
|
|
123
158
|
}
|
|
@@ -127,11 +162,28 @@ class BatchHandlerPlugin {
|
|
|
127
162
|
};
|
|
128
163
|
}
|
|
129
164
|
let streamController;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
165
|
+
let keepAliveTimer;
|
|
166
|
+
const clearKeepAlive = () => {
|
|
167
|
+
if (keepAliveTimer !== void 0) {
|
|
168
|
+
clearInterval(keepAliveTimer);
|
|
169
|
+
keepAliveTimer = void 0;
|
|
133
170
|
}
|
|
134
|
-
}
|
|
171
|
+
};
|
|
172
|
+
const scheduleKeepAlive = () => {
|
|
173
|
+
if (!this.keepAliveEnabled) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
clearKeepAlive();
|
|
177
|
+
keepAliveTimer = setInterval(() => {
|
|
178
|
+
try {
|
|
179
|
+
const lengthBuffer = new ArrayBuffer(4);
|
|
180
|
+
new DataView(lengthBuffer).setUint32(0, 0, false);
|
|
181
|
+
streamController.enqueue(new Uint8Array(lengthBuffer));
|
|
182
|
+
} catch {
|
|
183
|
+
clearKeepAlive();
|
|
184
|
+
}
|
|
185
|
+
}, this.keepAliveInterval);
|
|
186
|
+
};
|
|
135
187
|
const peer = new ServerPeer(async (message) => {
|
|
136
188
|
const encoded = await encodePeerMessage(message);
|
|
137
189
|
const bytes = typeof encoded === "string" ? new TextEncoder().encode(encoded) : encoded;
|
|
@@ -139,17 +191,34 @@ class BatchHandlerPlugin {
|
|
|
139
191
|
new DataView(lengthBuffer).setUint32(0, bytes.byteLength, false);
|
|
140
192
|
streamController.enqueue(new Uint8Array(lengthBuffer));
|
|
141
193
|
streamController.enqueue(bytes);
|
|
194
|
+
scheduleKeepAlive();
|
|
195
|
+
});
|
|
196
|
+
const stream = new ReadableStream({
|
|
197
|
+
start(controller) {
|
|
198
|
+
streamController = controller;
|
|
199
|
+
scheduleKeepAlive();
|
|
200
|
+
},
|
|
201
|
+
async cancel(reason) {
|
|
202
|
+
clearKeepAlive();
|
|
203
|
+
await peer.close(reason);
|
|
204
|
+
}
|
|
142
205
|
});
|
|
143
|
-
|
|
206
|
+
runSubrequests(peer).then(async () => {
|
|
207
|
+
clearKeepAlive();
|
|
144
208
|
streamController.close();
|
|
145
209
|
await peer.close();
|
|
146
210
|
}).catch(async (error) => {
|
|
211
|
+
clearKeepAlive();
|
|
147
212
|
streamController.error(error);
|
|
148
213
|
await peer.close(error);
|
|
149
214
|
});
|
|
150
215
|
return {
|
|
151
216
|
matched: true,
|
|
152
|
-
response: {
|
|
217
|
+
response: {
|
|
218
|
+
status,
|
|
219
|
+
headers: { ...headers, "content-type": BATCH_CONTENT_TYPE },
|
|
220
|
+
body: stream
|
|
221
|
+
}
|
|
153
222
|
};
|
|
154
223
|
};
|
|
155
224
|
return {
|
|
@@ -167,11 +236,11 @@ class CORSHandlerPlugin {
|
|
|
167
236
|
* - Run CORS interceptors before batch interceptors so headers are applied to
|
|
168
237
|
* the actual response rather than sub-responses.
|
|
169
238
|
*/
|
|
170
|
-
after = ["~
|
|
239
|
+
after = ["~tracing", "~batch"];
|
|
171
240
|
constructor(options = {}) {
|
|
172
241
|
const defaults = {
|
|
173
|
-
origin:
|
|
174
|
-
allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"]
|
|
242
|
+
origin: "*",
|
|
243
|
+
allowMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH", "QUERY"]
|
|
175
244
|
};
|
|
176
245
|
this.options = {
|
|
177
246
|
...defaults,
|
|
@@ -185,21 +254,20 @@ class CORSHandlerPlugin {
|
|
|
185
254
|
return result;
|
|
186
255
|
}
|
|
187
256
|
const resHeaders = { ...result.response.headers };
|
|
188
|
-
const origin = flattenStandardHeader(interceptorOptions.request.headers.origin)
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
resHeaders.vary = interceptorOptions.request.headers.vary ?? "origin";
|
|
257
|
+
const origin = flattenStandardHeader(interceptorOptions.request.headers.origin);
|
|
258
|
+
const allowOrigin = await this.resolveOrigin(this.options.origin, origin, interceptorOptions);
|
|
259
|
+
const timingAllowOrigin = await this.resolveOrigin(this.options.timingOrigin, origin, interceptorOptions);
|
|
260
|
+
if (allowOrigin.value !== void 0) {
|
|
261
|
+
resHeaders["access-control-allow-origin"] = allowOrigin.value;
|
|
262
|
+
}
|
|
263
|
+
if (timingAllowOrigin.value !== void 0) {
|
|
264
|
+
resHeaders["timing-allow-origin"] = timingAllowOrigin.value;
|
|
197
265
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
266
|
+
if (allowOrigin.varies || timingAllowOrigin.varies) {
|
|
267
|
+
const existingVary = flattenStandardHeader(resHeaders.vary);
|
|
268
|
+
if (!existingVary?.split(",").some((v) => v.trim().toLowerCase() === "origin")) {
|
|
269
|
+
resHeaders.vary = existingVary ? `${existingVary}, Origin` : "Origin";
|
|
270
|
+
}
|
|
203
271
|
}
|
|
204
272
|
if (this.options.credentials) {
|
|
205
273
|
resHeaders["access-control-allow-credentials"] = "true";
|
|
@@ -242,6 +310,235 @@ class CORSHandlerPlugin {
|
|
|
242
310
|
]
|
|
243
311
|
};
|
|
244
312
|
}
|
|
313
|
+
async resolveOrigin(option, origin, interceptorOptions) {
|
|
314
|
+
if (typeof option === "function") {
|
|
315
|
+
if (origin === void 0) {
|
|
316
|
+
return { value: void 0, varies: true };
|
|
317
|
+
}
|
|
318
|
+
const allowed2 = toArray(await option(origin, interceptorOptions));
|
|
319
|
+
if (allowed2.includes("*")) {
|
|
320
|
+
return { value: "*", varies: true };
|
|
321
|
+
}
|
|
322
|
+
return { value: allowed2.includes(origin) ? origin : void 0, varies: true };
|
|
323
|
+
}
|
|
324
|
+
const allowed = toArray(await option);
|
|
325
|
+
if (allowed.includes("*")) {
|
|
326
|
+
return { value: "*", varies: false };
|
|
327
|
+
}
|
|
328
|
+
if (origin !== void 0 && allowed.includes(origin)) {
|
|
329
|
+
return { value: origin, varies: true };
|
|
330
|
+
}
|
|
331
|
+
return { value: void 0, varies: allowed.length > 0 };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
class GetMethodCsrfProtectionHandlerPlugin {
|
|
336
|
+
name = "~get-method-csrf-protection";
|
|
337
|
+
init(options) {
|
|
338
|
+
const interceptor = (interceptorOptions) => {
|
|
339
|
+
if (this.isAllowed(interceptorOptions)) {
|
|
340
|
+
return interceptorOptions.next();
|
|
341
|
+
}
|
|
342
|
+
throw new ORPCError("FORBIDDEN", { message: "Request blocked by CSRF protection." });
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
...options,
|
|
346
|
+
interceptors: [interceptor, ...toArray(options.interceptors)]
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
isAllowed({ request }) {
|
|
350
|
+
if (request.method !== "GET") {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
const site = flattenStandardHeader(request.headers["sec-fetch-site"])?.toLowerCase();
|
|
354
|
+
if (site === void 0 || site === "same-origin" || site === "same-site") {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
const mode = flattenStandardHeader(request.headers["sec-fetch-mode"])?.toLowerCase();
|
|
358
|
+
const dest = flattenStandardHeader(request.headers["sec-fetch-dest"])?.toLowerCase();
|
|
359
|
+
if (mode === void 0 || dest === void 0) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
return mode !== "navigate" || dest !== "document";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
class MethodOverrideHandlerPlugin {
|
|
367
|
+
name = "~method-override";
|
|
368
|
+
/**
|
|
369
|
+
* Should override batch sub-request methods, not the original batch request.
|
|
370
|
+
*/
|
|
371
|
+
before = ["~batch"];
|
|
372
|
+
param;
|
|
373
|
+
methods;
|
|
374
|
+
constructor(options = {}) {
|
|
375
|
+
this.param = options.param ?? "method";
|
|
376
|
+
this.methods = new Set((options.methods ?? ["PUT", "PATCH", "DELETE"]).map((method) => method.toUpperCase()));
|
|
377
|
+
}
|
|
378
|
+
init(options) {
|
|
379
|
+
const routingInterceptor = async ({ next, ...interceptorOptions }) => {
|
|
380
|
+
const { request } = interceptorOptions;
|
|
381
|
+
if (request.method !== "POST") {
|
|
382
|
+
return next();
|
|
383
|
+
}
|
|
384
|
+
const [pathname, search, hash] = parseStandardUrl(request.url);
|
|
385
|
+
const params = new URLSearchParams(search);
|
|
386
|
+
const raw = params.getAll(this.param).at(-1);
|
|
387
|
+
if (raw === void 0) {
|
|
388
|
+
return next();
|
|
389
|
+
}
|
|
390
|
+
const method = raw.toUpperCase();
|
|
391
|
+
if (!this.methods.has(method)) {
|
|
392
|
+
return next();
|
|
393
|
+
}
|
|
394
|
+
params.delete(this.param);
|
|
395
|
+
const url = `${pathname}${params.size ? `?${params}` : ""}${hash ?? ""}`;
|
|
396
|
+
return next({
|
|
397
|
+
...interceptorOptions,
|
|
398
|
+
request: { ...request, method, url }
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
return {
|
|
402
|
+
...options,
|
|
403
|
+
routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)]
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
class PrototypePollutionProtectionHandlerPlugin {
|
|
409
|
+
name = "~prototype-pollution-protection";
|
|
410
|
+
init(options) {
|
|
411
|
+
const interceptor = (interceptorOptions) => {
|
|
412
|
+
const input = interceptorOptions.input;
|
|
413
|
+
if (isAsyncIteratorObject(input)) {
|
|
414
|
+
const guardedInput = override(input, wrapAsyncIterator(input, {
|
|
415
|
+
mapResult: (result) => {
|
|
416
|
+
this.rejectPollutingInput(result.value);
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
}));
|
|
420
|
+
return interceptorOptions.next({ ...interceptorOptions, input: guardedInput });
|
|
421
|
+
}
|
|
422
|
+
this.rejectPollutingInput(input);
|
|
423
|
+
return interceptorOptions.next();
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
...options,
|
|
427
|
+
clientInterceptors: [interceptor, ...toArray(options.clientInterceptors)]
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
rejectPollutingInput(input) {
|
|
431
|
+
if (this.containsPollutingKey(input)) {
|
|
432
|
+
throw new ORPCError("BAD_REQUEST", { message: "Request blocked by prototype pollution protection." });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Walks the containers the built-in codecs can produce: arrays, maps, sets, and plain
|
|
437
|
+
* objects, including null-prototype ones. Arrays get the same key checks as plain objects,
|
|
438
|
+
* since structured clone, as used over MessagePort, keeps named keys like `__proto__` on
|
|
439
|
+
* them. Other objects, such as files and dates, carry no attacker-authored keys and are
|
|
440
|
+
* left alone. The walk is iterative, so input nested deeper than the call stack allows
|
|
441
|
+
* still gets the intended verdict instead of a `RangeError`.
|
|
442
|
+
*/
|
|
443
|
+
containsPollutingKey(root) {
|
|
444
|
+
const visited = /* @__PURE__ */ new WeakSet();
|
|
445
|
+
const stack = [root];
|
|
446
|
+
while (stack.length !== 0) {
|
|
447
|
+
const value = stack.pop();
|
|
448
|
+
if (typeof value !== "object" || value === null || visited.has(value)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
visited.add(value);
|
|
452
|
+
if (value instanceof Map || value instanceof Set) {
|
|
453
|
+
for (const entry of value) {
|
|
454
|
+
stack.push(entry);
|
|
455
|
+
}
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (Object.hasOwn(value, "__proto__")) {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
if (Object.hasOwn(value, "constructor") && isTypescriptObject(value.constructor) && Object.hasOwn(value.constructor, "prototype")) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
for (const item of Object.values(value)) {
|
|
468
|
+
if (typeof item === "object" && item !== null) {
|
|
469
|
+
stack.push(item);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const MAX_CONTENT_ENCODINGS = 5;
|
|
478
|
+
class RequestCompressionHandlerPlugin {
|
|
479
|
+
name = "~request-compression";
|
|
480
|
+
/**
|
|
481
|
+
* Should decompress the original batch request body instead of sub-requests.
|
|
482
|
+
*/
|
|
483
|
+
after = ["~batch"];
|
|
484
|
+
init(options) {
|
|
485
|
+
const routingInterceptor = async ({ next, ...interceptorOptions }) => {
|
|
486
|
+
const encodings = parseContentEncodings(
|
|
487
|
+
flattenStandardHeader(interceptorOptions.request.headers["content-encoding"])
|
|
488
|
+
);
|
|
489
|
+
if (encodings.length === 0 || !encodings.every(isSupportedEncoding)) {
|
|
490
|
+
return next();
|
|
491
|
+
}
|
|
492
|
+
const decompressedHeaders = {
|
|
493
|
+
...interceptorOptions.request.headers,
|
|
494
|
+
"content-length": void 0,
|
|
495
|
+
"content-encoding": void 0
|
|
496
|
+
};
|
|
497
|
+
return next({
|
|
498
|
+
...interceptorOptions,
|
|
499
|
+
request: {
|
|
500
|
+
...interceptorOptions.request,
|
|
501
|
+
headers: decompressedHeaders,
|
|
502
|
+
async resolveBody(hint) {
|
|
503
|
+
const stream = await interceptorOptions.request.resolveBody("octet-stream");
|
|
504
|
+
if (!(stream instanceof ReadableStream)) {
|
|
505
|
+
return stream;
|
|
506
|
+
}
|
|
507
|
+
if (encodings.length > MAX_CONTENT_ENCODINGS) {
|
|
508
|
+
throw new ORPCError("UNSUPPORTED_MEDIA_TYPE", { message: "Too many content encodings." });
|
|
509
|
+
}
|
|
510
|
+
let decompressedStream = stream;
|
|
511
|
+
for (let i = encodings.length - 1; i >= 0; i--) {
|
|
512
|
+
decompressedStream = decompressedStream.pipeThrough(
|
|
513
|
+
new DecompressionStream(encodings[i])
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const response = new Response(decompressedStream, {
|
|
517
|
+
headers: toFetchHeaders(decompressedHeaders)
|
|
518
|
+
});
|
|
519
|
+
return toStandardBody(response, { hint });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
return {
|
|
525
|
+
...options,
|
|
526
|
+
routingInterceptors: [
|
|
527
|
+
routingInterceptor,
|
|
528
|
+
...toArray(options.routingInterceptors)
|
|
529
|
+
]
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const SUPPORTED_ENCODINGS = ["gzip", "deflate", "deflate-raw"];
|
|
534
|
+
function isSupportedEncoding(encoding) {
|
|
535
|
+
return SUPPORTED_ENCODINGS.includes(encoding);
|
|
536
|
+
}
|
|
537
|
+
function parseContentEncodings(header) {
|
|
538
|
+
if (header === void 0) {
|
|
539
|
+
return [];
|
|
540
|
+
}
|
|
541
|
+
return header.split(",").map((part) => part.trim().toLowerCase());
|
|
245
542
|
}
|
|
246
543
|
|
|
247
544
|
class RequestHeadersHandlerPlugin {
|
|
@@ -266,6 +563,181 @@ class RequestHeadersHandlerPlugin {
|
|
|
266
563
|
}
|
|
267
564
|
}
|
|
268
565
|
|
|
566
|
+
const AVG_BYTES_PER_CHAR = 1.2;
|
|
567
|
+
class ResponseCompressionHandlerPlugin {
|
|
568
|
+
name = "~response-compression";
|
|
569
|
+
/**
|
|
570
|
+
* Compression should be done after batching, to compress the final response.
|
|
571
|
+
* Compression should also be done after response headers are set, to access final headers like Content-Type and Cache-Control.
|
|
572
|
+
*/
|
|
573
|
+
after = ["~batch", "~response-headers"];
|
|
574
|
+
encodings;
|
|
575
|
+
threshold;
|
|
576
|
+
isCompressibleContentType;
|
|
577
|
+
constructor(options = {}) {
|
|
578
|
+
this.encodings = options.encodings ?? ["gzip", "deflate"];
|
|
579
|
+
this.threshold = options.threshold ?? 1024;
|
|
580
|
+
this.isCompressibleContentType = options.isCompressibleContentType ?? isCompressibleContentType;
|
|
581
|
+
}
|
|
582
|
+
init(options) {
|
|
583
|
+
const routingInterceptor = async ({ next, ...interceptorOptions }) => {
|
|
584
|
+
const result = await next();
|
|
585
|
+
if (!result.matched) {
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
const response = result.response;
|
|
589
|
+
const encoding = negotiateResponseCompressionEncoding(response, interceptorOptions.request.headers, this.encodings);
|
|
590
|
+
if (encoding === void 0) {
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
const body = response.body;
|
|
594
|
+
const headers = response.headers;
|
|
595
|
+
if (body instanceof ReadableStream) {
|
|
596
|
+
const contentLength = Number(flattenStandardHeader(headers["content-length"]));
|
|
597
|
+
if ((!Number.isFinite(contentLength) || contentLength >= this.threshold) && this.isCompressibleContentType(flattenStandardHeader(headers["content-type"]), interceptorOptions)) {
|
|
598
|
+
return {
|
|
599
|
+
...result,
|
|
600
|
+
response: {
|
|
601
|
+
...response,
|
|
602
|
+
body: body.pipeThrough(new CompressionStream(encoding)),
|
|
603
|
+
headers: {
|
|
604
|
+
...toCompressedHeaders(headers, encoding),
|
|
605
|
+
"standard-server": "octet-stream"
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
} else if (body instanceof Blob) {
|
|
611
|
+
if ((!Number.isFinite(body.size) || body.size >= this.threshold) && this.isCompressibleContentType(body.type, interceptorOptions)) {
|
|
612
|
+
const contentDisposition = headers["content-disposition"] ?? generateContentDisposition(
|
|
613
|
+
body instanceof File ? body.name : "blob"
|
|
614
|
+
);
|
|
615
|
+
return {
|
|
616
|
+
...result,
|
|
617
|
+
response: {
|
|
618
|
+
...response,
|
|
619
|
+
body: body.stream().pipeThrough(new CompressionStream(encoding)),
|
|
620
|
+
headers: {
|
|
621
|
+
...toCompressedHeaders(headers, encoding),
|
|
622
|
+
"standard-server": "file",
|
|
623
|
+
"content-type": body.type,
|
|
624
|
+
"content-disposition": contentDisposition
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
} else if (body instanceof FormData) {
|
|
630
|
+
const PART_OVERHEAD = 64;
|
|
631
|
+
let contentLength = 0;
|
|
632
|
+
for (const [key, value] of body) {
|
|
633
|
+
contentLength += PART_OVERHEAD + key.length;
|
|
634
|
+
if (value instanceof Blob) {
|
|
635
|
+
if (!Number.isFinite(value.size)) {
|
|
636
|
+
if (!this.isCompressibleContentType(value.type, interceptorOptions)) {
|
|
637
|
+
contentLength = -Infinity;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
contentLength = Infinity;
|
|
641
|
+
} else {
|
|
642
|
+
contentLength += this.isCompressibleContentType(value.type, interceptorOptions) ? value.size : -value.size;
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
contentLength += value.length * AVG_BYTES_PER_CHAR;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (contentLength >= this.threshold) {
|
|
649
|
+
const res = new Response(body);
|
|
650
|
+
const compressedStream = res.body.pipeThrough(new CompressionStream(encoding));
|
|
651
|
+
return {
|
|
652
|
+
...result,
|
|
653
|
+
response: {
|
|
654
|
+
...response,
|
|
655
|
+
body: compressedStream,
|
|
656
|
+
headers: {
|
|
657
|
+
...toCompressedHeaders(headers, encoding),
|
|
658
|
+
"standard-server": [],
|
|
659
|
+
"content-type": res.headers.get("content-type")
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
} else if (body instanceof URLSearchParams) {
|
|
665
|
+
const string = body.toString();
|
|
666
|
+
if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
|
|
667
|
+
return {
|
|
668
|
+
...result,
|
|
669
|
+
response: {
|
|
670
|
+
...response,
|
|
671
|
+
body: new Blob([string]).stream().pipeThrough(new CompressionStream(encoding)),
|
|
672
|
+
headers: {
|
|
673
|
+
...toCompressedHeaders(headers, encoding),
|
|
674
|
+
"standard-server": [],
|
|
675
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
} else if (body !== void 0 && !isAsyncIteratorObject(body)) {
|
|
681
|
+
const string = stringifyJSON(body);
|
|
682
|
+
if (string.length * AVG_BYTES_PER_CHAR >= this.threshold) {
|
|
683
|
+
return {
|
|
684
|
+
...result,
|
|
685
|
+
response: {
|
|
686
|
+
...response,
|
|
687
|
+
body: new Blob([string]).stream().pipeThrough(new CompressionStream(encoding)),
|
|
688
|
+
headers: {
|
|
689
|
+
...toCompressedHeaders(headers, encoding),
|
|
690
|
+
"standard-server": [],
|
|
691
|
+
"content-type": "application/json"
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return result;
|
|
698
|
+
};
|
|
699
|
+
return {
|
|
700
|
+
...options,
|
|
701
|
+
routingInterceptors: [
|
|
702
|
+
routingInterceptor,
|
|
703
|
+
...toArray(options.routingInterceptors)
|
|
704
|
+
]
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function negotiateResponseCompressionEncoding(response, requestHeaders, encodings) {
|
|
709
|
+
if (flattenStandardHeader(response.headers["content-encoding"]) !== void 0) {
|
|
710
|
+
return void 0;
|
|
711
|
+
}
|
|
712
|
+
if (response.status === 206 || flattenStandardHeader(response.headers["content-range"]) !== void 0) {
|
|
713
|
+
return void 0;
|
|
714
|
+
}
|
|
715
|
+
if (isNoTransformCacheControl(flattenStandardHeader(response.headers["cache-control"]))) {
|
|
716
|
+
return void 0;
|
|
717
|
+
}
|
|
718
|
+
const acceptEncodings = parseAcceptEncodingQualities(flattenStandardHeader(requestHeaders["accept-encoding"]));
|
|
719
|
+
return encodings.find((encoding) => isAcceptableEncoding(acceptEncodings, encoding));
|
|
720
|
+
}
|
|
721
|
+
function toCompressedHeaders(headers, encoding) {
|
|
722
|
+
return {
|
|
723
|
+
...headers,
|
|
724
|
+
"content-length": [],
|
|
725
|
+
"content-encoding": encoding,
|
|
726
|
+
"vary": varyByAcceptEncoding(flattenStandardHeader(headers.vary)),
|
|
727
|
+
/**
|
|
728
|
+
* A strong tag shared with the identity bytes would let `If-Range` splice them after compressed ones.
|
|
729
|
+
* A weak tag still revalidates through `If-None-Match`, and the compressed body serves no ranges.
|
|
730
|
+
*
|
|
731
|
+
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag
|
|
732
|
+
*/
|
|
733
|
+
"etag": weakenEtag(flattenStandardHeader(headers.etag)),
|
|
734
|
+
"accept-ranges": []
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
function weakenEtag(etag) {
|
|
738
|
+
return etag === void 0 || etag.startsWith("W/") ? etag : `W/${etag}`;
|
|
739
|
+
}
|
|
740
|
+
|
|
269
741
|
class ResponseHeadersHandlerPlugin {
|
|
270
742
|
name = "~response-headers";
|
|
271
743
|
/**
|
|
@@ -354,4 +826,53 @@ class RethrowHandlerPlugin {
|
|
|
354
826
|
}
|
|
355
827
|
}
|
|
356
828
|
|
|
357
|
-
|
|
829
|
+
class TimeoutHandlerPlugin {
|
|
830
|
+
timeout;
|
|
831
|
+
streamingTimeout;
|
|
832
|
+
name = "~timeout";
|
|
833
|
+
constructor(options) {
|
|
834
|
+
this.timeout = options.timeout;
|
|
835
|
+
this.streamingTimeout = options.streamingTimeout;
|
|
836
|
+
}
|
|
837
|
+
init(options) {
|
|
838
|
+
const interceptor = async (interceptorOptions) => {
|
|
839
|
+
const timeoutMs = value(this.timeout, interceptorOptions);
|
|
840
|
+
const streamingTimeoutMs = value(this.streamingTimeout, interceptorOptions);
|
|
841
|
+
const hasTimeout = timeoutMs !== null && timeoutMs !== void 0;
|
|
842
|
+
const hasStreamingTimeout = streamingTimeoutMs !== null && streamingTimeoutMs !== void 0;
|
|
843
|
+
if (!hasTimeout && !hasStreamingTimeout) {
|
|
844
|
+
return interceptorOptions.next();
|
|
845
|
+
}
|
|
846
|
+
const controller = new AbortController();
|
|
847
|
+
const abortWithTimeout = (ms) => {
|
|
848
|
+
controller.abort(new AbortError(`Request timed out after ${ms}ms`));
|
|
849
|
+
};
|
|
850
|
+
const timeoutId = hasTimeout ? setTimeout(abortWithTimeout, timeoutMs, timeoutMs) : void 0;
|
|
851
|
+
const signal = anyAbortSignal([interceptorOptions.request.signal, controller.signal]);
|
|
852
|
+
try {
|
|
853
|
+
const response = await interceptorOptions.next({
|
|
854
|
+
...interceptorOptions,
|
|
855
|
+
request: { ...interceptorOptions.request, signal }
|
|
856
|
+
});
|
|
857
|
+
if (hasStreamingTimeout) {
|
|
858
|
+
const body = response.body;
|
|
859
|
+
const isIterator = isAsyncIteratorObject(body);
|
|
860
|
+
if (isIterator || body instanceof ReadableStream) {
|
|
861
|
+
const streamingTimeoutId = setTimeout(abortWithTimeout, streamingTimeoutMs, streamingTimeoutMs);
|
|
862
|
+
const onFinish = () => clearTimeout(streamingTimeoutId);
|
|
863
|
+
return {
|
|
864
|
+
...response,
|
|
865
|
+
body: isIterator ? override(body, wrapAsyncIterator(body, { onFinish })) : override(body, wrapReadableStream(body, { onFinish }))
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return response;
|
|
870
|
+
} finally {
|
|
871
|
+
clearTimeout(timeoutId);
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
return { ...options, interceptors: [interceptor, ...toArray(options.interceptors)] };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export { BATCH_CONTENT_TYPE, BatchHandlerPlugin, CORSHandlerPlugin, CORSHandlerPlugin as CORSPlugin, GetMethodCsrfProtectionHandlerPlugin, MethodOverrideHandlerPlugin, PrototypePollutionProtectionHandlerPlugin, RequestCompressionHandlerPlugin, RequestHeadersHandlerPlugin, RequestHeadersHandlerPlugin as RequestHeadersPlugin, ResponseCompressionHandlerPlugin, ResponseHeadersHandlerPlugin, ResponseHeadersHandlerPlugin as ResponseHeadersPlugin, RethrowHandlerPlugin, TimeoutHandlerPlugin, negotiateResponseCompressionEncoding, toCompressedHeaders };
|