expediate 1.0.4 → 1.0.6
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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +47 -8
- package/.npmignore +0 -16
package/dist/misc.js
CHANGED
|
@@ -19,28 +19,63 @@
|
|
|
19
19
|
* DEALINGS IN THE SOFTWARE.
|
|
20
20
|
*/
|
|
21
21
|
'use strict';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
import { Readable } from 'stream';
|
|
23
|
+
import zlib from 'zlib';
|
|
24
|
+
/**
|
|
25
|
+
* Build a fully-resolved {@link BodyOptions} object, applying defaults for any
|
|
26
|
+
* unspecified field. `defaultType` is the parser's natural content type, used
|
|
27
|
+
* when the caller does not supply `opts.type`.
|
|
28
|
+
*/
|
|
29
|
+
function resolveBodyOptions(opts, defaultType) {
|
|
30
|
+
return {
|
|
31
|
+
inflate: opts?.inflate ?? true,
|
|
32
|
+
limit: opts?.limit ?? '100kb',
|
|
33
|
+
reviver: opts?.reviver ?? null,
|
|
34
|
+
strict: opts?.strict ?? true,
|
|
35
|
+
type: opts?.type ?? defaultType,
|
|
36
|
+
verify: opts?.verify ?? null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Test whether a request's `Content-Type` matches a {@link BodyTypeMatcher}.
|
|
41
|
+
* Returns `true` for any request when `matcher` is `null`.
|
|
42
|
+
*/
|
|
43
|
+
function matchesBodyType(req, matcher) {
|
|
44
|
+
if (matcher === null)
|
|
45
|
+
return true;
|
|
46
|
+
if (typeof matcher === 'function')
|
|
47
|
+
return matcher(req);
|
|
48
|
+
const actual = ((req.headers['content-type']) ?? '')
|
|
49
|
+
.split(';')[0].trim().toLowerCase();
|
|
50
|
+
if (!actual)
|
|
51
|
+
return false;
|
|
52
|
+
const patterns = Array.isArray(matcher) ? matcher : [matcher];
|
|
53
|
+
return patterns.some((pattern) => matchMimePattern(actual, pattern.toLowerCase()));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Match a concrete media type (e.g. `'application/json'`) against a pattern
|
|
57
|
+
* that may contain wildcards (`'*\/*'`, `'application/*'`).
|
|
58
|
+
*/
|
|
59
|
+
function matchMimePattern(actual, pattern) {
|
|
60
|
+
if (pattern === '*/*' || pattern === '*')
|
|
61
|
+
return true;
|
|
62
|
+
if (pattern === actual)
|
|
63
|
+
return true;
|
|
64
|
+
const [pType, pSub] = pattern.split('/');
|
|
65
|
+
const [aType] = actual.split('/');
|
|
66
|
+
return pSub === '*' && pType === aType;
|
|
67
|
+
}
|
|
34
68
|
// ---------------------------------------------------------------------------
|
|
35
69
|
// Decompression algorithm registry
|
|
36
70
|
// ---------------------------------------------------------------------------
|
|
37
71
|
/**
|
|
38
72
|
* Maps a supported `Content-Encoding` value to its corresponding `zlib`
|
|
39
|
-
* decompression function.
|
|
73
|
+
* decompression function. `gzip`, `deflate`, and `br` (Brotli) are supported.
|
|
40
74
|
*/
|
|
41
75
|
const DECOMPRESS_ALGO = {
|
|
42
|
-
gzip:
|
|
43
|
-
deflate:
|
|
76
|
+
gzip: zlib.gunzip,
|
|
77
|
+
deflate: zlib.inflate,
|
|
78
|
+
br: zlib.brotliDecompress,
|
|
44
79
|
};
|
|
45
80
|
// ---------------------------------------------------------------------------
|
|
46
81
|
// Internal utilities
|
|
@@ -70,11 +105,11 @@ function readSize(value) {
|
|
|
70
105
|
return 0;
|
|
71
106
|
const num = parseFloat(fmt[1] ?? '0');
|
|
72
107
|
const sfx = (fmt[3] ?? 'b').toLowerCase();
|
|
73
|
-
if (sfx
|
|
108
|
+
if (sfx.startsWith('k'))
|
|
74
109
|
return num * 1024;
|
|
75
|
-
if (sfx
|
|
110
|
+
if (sfx.startsWith('m'))
|
|
76
111
|
return num * 1024 * 1024;
|
|
77
|
-
if (sfx
|
|
112
|
+
if (sfx.startsWith('g'))
|
|
78
113
|
return num * 1024 * 1024 * 1024;
|
|
79
114
|
return num;
|
|
80
115
|
}
|
|
@@ -112,10 +147,7 @@ function splitBuffer(buffer, delimiter) {
|
|
|
112
147
|
* (e.g. `'text/plain; charset=iso-8859-1'`).
|
|
113
148
|
* @returns A Node.js-compatible encoding name (e.g. `'utf8'`, `'iso-8859-1'`).
|
|
114
149
|
*/
|
|
115
|
-
function extractCharset(contentType) {
|
|
116
|
-
// BUG FIX: the original regex `'s+$` was a literal quote followed by `s+$`
|
|
117
|
-
// instead of `\s+$`. The trim therefore left leading/trailing whitespace on
|
|
118
|
-
// every parameter, breaking charset detection. Corrected to `\s+$`.
|
|
150
|
+
export function extractCharset(contentType) {
|
|
119
151
|
const param = contentType
|
|
120
152
|
.split(';')
|
|
121
153
|
.map((s) => s.replace(/^\s+|\s+$/g, ''))
|
|
@@ -137,44 +169,46 @@ function extractCharset(contentType) {
|
|
|
137
169
|
*
|
|
138
170
|
* Callers that need to pass control to the next middleware when there is no
|
|
139
171
|
* body should rely on the `next()` call that this function makes when
|
|
140
|
-
* `Content-Length` is `0` or absent
|
|
172
|
+
* `Content-Length` is `0` or absent and `Transfer-Encoding` is not `chunked`.
|
|
141
173
|
*
|
|
142
174
|
* @param req - The incoming request.
|
|
143
175
|
* @param res - The outgoing response.
|
|
144
176
|
* @param opts - Resolved body-parsing options.
|
|
145
|
-
* @param
|
|
146
|
-
* to accept any content type.
|
|
177
|
+
* @param type - Content-type matcher (see {@link BodyTypeMatcher}), or
|
|
178
|
+
* `null` to accept any content type.
|
|
147
179
|
* @param next - The next middleware callback; called when the body is
|
|
148
180
|
* empty or absent.
|
|
149
181
|
* @param callback - Invoked with `(contentType, body)` on success.
|
|
150
182
|
*/
|
|
151
|
-
function readBody(req, res, opts,
|
|
152
|
-
const length = parseInt(req.headers['content-length'] ?? '0', 10);
|
|
153
|
-
//
|
|
154
|
-
|
|
183
|
+
function readBody(req, res, opts, type, next, callback) {
|
|
184
|
+
const length = parseInt((req.headers['content-length']) ?? '0', 10);
|
|
185
|
+
// Detect chunked transfer encoding — no Content-Length is present in this case.
|
|
186
|
+
const isChunked = (req.headers['transfer-encoding'])
|
|
187
|
+
?.split(',').map((v) => v.trim()).some((v) => v.toLowerCase() === 'chunked') ?? false;
|
|
188
|
+
// No body declared and not chunked — skip to next middleware.
|
|
189
|
+
if (!isChunked && (!length || length === 0))
|
|
155
190
|
return next();
|
|
156
191
|
const maxLength = readSize(opts.limit) || 102_400;
|
|
157
|
-
|
|
192
|
+
// For requests with a known Content-Length we can reject upfront.
|
|
193
|
+
if (!isChunked && length > maxLength)
|
|
158
194
|
return void res.status(413).send('Content Too Large');
|
|
159
195
|
// Compression handling.
|
|
160
196
|
const encoding = req.headers['content-encoding'];
|
|
161
197
|
if (encoding && (opts.inflate === false || !DECOMPRESS_ALGO[encoding]))
|
|
162
198
|
return void res.status(415).send('Unsupported Media Type: Wrong Content-Encoding');
|
|
163
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
164
199
|
const decompress = (encoding ? DECOMPRESS_ALGO[encoding] : undefined) ?? ((d, c) => c(null, d));
|
|
165
200
|
// Content-Type validation.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
201
|
+
// When the request's content type does not match, pass through to the next
|
|
202
|
+
// middleware (Express-compatible composable behaviour). Returning 415 here
|
|
203
|
+
// would break parser stacking: json() + formEncoded() + …
|
|
204
|
+
const contentType = (req.headers['content-type']) ?? '';
|
|
205
|
+
if (!matchesBodyType(req, type))
|
|
206
|
+
return next();
|
|
169
207
|
// Stream collection.
|
|
170
208
|
let data = Buffer.alloc(0);
|
|
171
209
|
req.on('data', (chunk) => {
|
|
172
210
|
if (data === null)
|
|
173
211
|
return; // already aborted
|
|
174
|
-
// BUG FIX: the size check must happen AFTER concatenating the new chunk,
|
|
175
|
-
// not before. Checking before allowed a stream of (maxLength-1)-byte chunks
|
|
176
|
-
// to bypass the limit entirely.
|
|
177
|
-
// BUG FIX: the original code referenced `chink` (typo) instead of `chunk`.
|
|
178
212
|
const next_ = Buffer.concat([data, chunk]);
|
|
179
213
|
if (next_.length > maxLength) {
|
|
180
214
|
data = null;
|
|
@@ -187,32 +221,60 @@ function readBody(req, res, opts, mimetype, next, callback) {
|
|
|
187
221
|
if (data === null)
|
|
188
222
|
return; // aborted during streaming
|
|
189
223
|
// zlib types require NonSharedBuffer; Buffer satisfies this at runtime.
|
|
190
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
224
|
decompress(data, (err, decompressed) => {
|
|
192
225
|
if (err)
|
|
193
226
|
return void res.status(500).send(err.message);
|
|
194
|
-
|
|
227
|
+
const body = decompressed;
|
|
228
|
+
// Optional verify hook — runs on the raw bytes before parsing. A throw
|
|
229
|
+
// rejects the request with the error's status (default 403).
|
|
230
|
+
if (opts.verify) {
|
|
231
|
+
try {
|
|
232
|
+
opts.verify(req, res, body, extractCharset(contentType));
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
const status = e.status
|
|
236
|
+
?? e.statusCode ?? 403;
|
|
237
|
+
return void res.status(status).send(e.message ?? 'Forbidden');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
callback(contentType, body);
|
|
195
241
|
});
|
|
196
242
|
});
|
|
197
243
|
}
|
|
198
|
-
|
|
199
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Promise-based body collector used by the `req.json()`, `req.text()`, and
|
|
246
|
+
* `req.formData()` request helpers.
|
|
247
|
+
*
|
|
248
|
+
* @param req - The incoming request.
|
|
249
|
+
* @param opts - Resolved body-parsing options.
|
|
250
|
+
* @param mimetype - Expected MIME type, or `null` to accept any content type.
|
|
251
|
+
* @param res - The outgoing response, required only so an `opts.verify`
|
|
252
|
+
* hook receives the Express-style `(req, res, buf, encoding)`
|
|
253
|
+
* arguments. When omitted, the verify hook is skipped.
|
|
254
|
+
* @returns The collected `{ mimetype, content }`, or `null` for an empty body.
|
|
255
|
+
* Rejects with `{ status, message }` on size, encoding, type, or
|
|
256
|
+
* verify-hook failures.
|
|
257
|
+
*/
|
|
258
|
+
export function readReqBody(req, opts, mimetype, res) {
|
|
200
259
|
return new Promise((resolve, reject) => {
|
|
201
|
-
const length = parseInt(req.headers['content-length'] ?? '0', 10);
|
|
202
|
-
//
|
|
203
|
-
|
|
260
|
+
const length = parseInt((req.headers['content-length']) ?? '0', 10);
|
|
261
|
+
// Detect chunked transfer encoding — no Content-Length is present in this case.
|
|
262
|
+
const isChunked = (req.headers['transfer-encoding'])
|
|
263
|
+
?.split(',').map((v) => v.trim()).some((v) => v.toLowerCase() === 'chunked') ?? false;
|
|
264
|
+
// No body declared and not chunked — resolve with null.
|
|
265
|
+
if (!isChunked && (!length || length === 0))
|
|
204
266
|
return resolve(null);
|
|
205
267
|
const maxLength = readSize(opts.limit) || 102_400;
|
|
206
|
-
|
|
268
|
+
// For requests with a known Content-Length we can reject upfront.
|
|
269
|
+
if (!isChunked && length > maxLength)
|
|
207
270
|
return reject({ status: 413, message: 'Content Too Large' });
|
|
208
271
|
// Compression handling.
|
|
209
272
|
const encoding = req.headers['content-encoding'];
|
|
210
273
|
if (encoding && (opts.inflate === false || !DECOMPRESS_ALGO[encoding]))
|
|
211
274
|
return reject({ status: 415, message: 'Unsupported Media Type: Wrong Content-Encoding' });
|
|
212
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
213
275
|
const decompress = (encoding ? DECOMPRESS_ALGO[encoding] : undefined) ?? ((d, c) => c(null, d));
|
|
214
276
|
// Content-Type validation.
|
|
215
|
-
const contentType = req.headers['content-type'] ?? '';
|
|
277
|
+
const contentType = (req.headers['content-type']) ?? '';
|
|
216
278
|
if (mimetype && contentType.split(';')[0].trim() !== mimetype)
|
|
217
279
|
return reject({ status: 415, message: 'Unsupported Media Type: Wrong Content-Type' });
|
|
218
280
|
// Stream collection.
|
|
@@ -220,10 +282,6 @@ function readReqBody(req, opts, mimetype) {
|
|
|
220
282
|
req.on('data', (chunk) => {
|
|
221
283
|
if (data === null)
|
|
222
284
|
return; // already aborted
|
|
223
|
-
// BUG FIX: the size check must happen AFTER concatenating the new chunk,
|
|
224
|
-
// not before. Checking before allowed a stream of (maxLength-1)-byte chunks
|
|
225
|
-
// to bypass the limit entirely.
|
|
226
|
-
// BUG FIX: the original code referenced `chink` (typo) instead of `chunk`.
|
|
227
285
|
const next_ = Buffer.concat([data, chunk]);
|
|
228
286
|
if (next_.length > maxLength) {
|
|
229
287
|
data = null;
|
|
@@ -236,11 +294,23 @@ function readReqBody(req, opts, mimetype) {
|
|
|
236
294
|
if (data === null)
|
|
237
295
|
return; // aborted during streaming
|
|
238
296
|
// zlib types require NonSharedBuffer; Buffer satisfies this at runtime.
|
|
239
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
297
|
decompress(data, (err, decompressed) => {
|
|
241
298
|
if (err)
|
|
242
299
|
return reject({ status: 500, message: err.message });
|
|
243
|
-
|
|
300
|
+
const body = decompressed;
|
|
301
|
+
// Optional verify hook — runs on the raw bytes before parsing. A throw
|
|
302
|
+
// rejects the promise with the error's status (default 403).
|
|
303
|
+
if (opts.verify && res) {
|
|
304
|
+
try {
|
|
305
|
+
opts.verify(req, res, body, extractCharset(contentType));
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
const status = e.status
|
|
309
|
+
?? e.statusCode ?? 403;
|
|
310
|
+
return reject({ status, message: e.message ?? 'Forbidden' });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
resolve({ mimetype: contentType ?? '', content: body });
|
|
244
314
|
});
|
|
245
315
|
});
|
|
246
316
|
});
|
|
@@ -288,20 +358,26 @@ function readBodyAsPlainText(req, res, next, contentType, data) {
|
|
|
288
358
|
function readBodyAsJson(req, res, next, opts, contentType, data) {
|
|
289
359
|
const charset = extractCharset(contentType);
|
|
290
360
|
try {
|
|
291
|
-
|
|
361
|
+
const parsed = JSON.parse(data.toString(charset), opts.reviver ?? undefined);
|
|
362
|
+
// FIX-10: strict mode — reject bare primitives (strings, numbers, booleans)
|
|
363
|
+
// at the top level; only objects and arrays are accepted.
|
|
364
|
+
if (opts.strict && (typeof parsed !== 'object' || parsed === null)) {
|
|
365
|
+
return void res.status(400).send('Bad Request: JSON body must be an object or array');
|
|
366
|
+
}
|
|
367
|
+
req.body = parsed;
|
|
292
368
|
next();
|
|
293
369
|
}
|
|
294
370
|
catch (ex) {
|
|
295
|
-
|
|
371
|
+
// Invalid JSON is a client error (400), not a server error.
|
|
372
|
+
res.status(400).send('Bad Request: ' + ex.message);
|
|
296
373
|
}
|
|
297
374
|
}
|
|
298
375
|
/**
|
|
299
|
-
* Parse a
|
|
300
|
-
*
|
|
301
|
-
* an array of {@link FormPart} objects to `req.body`.
|
|
376
|
+
* Parse a raw `multipart/form-data` body buffer into an array of
|
|
377
|
+
* {@link FormPart} objects.
|
|
302
378
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
379
|
+
* This is the shared parsing kernel used by both the {@link formData}
|
|
380
|
+
* middleware and the `req.formData()` extension method on the request object.
|
|
305
381
|
*
|
|
306
382
|
* **Multipart wire format recap:**
|
|
307
383
|
* ```
|
|
@@ -316,68 +392,118 @@ function readBodyAsJson(req, res, next, opts, contentType, data) {
|
|
|
316
392
|
* The boundary string in `Content-Type` does **not** include the leading `--`;
|
|
317
393
|
* actual part delimiters on the wire are `\r\n--boundary`.
|
|
318
394
|
*
|
|
319
|
-
* @param req - The incoming request (mutated: `req.body` is set).
|
|
320
|
-
* @param res - The outgoing response.
|
|
321
|
-
* @param next - Called on successful parsing.
|
|
322
395
|
* @param contentType - The raw `Content-Type` header value (must include
|
|
323
396
|
* `boundary=<value>`).
|
|
324
|
-
* @param data - The raw body buffer.
|
|
397
|
+
* @param data - The fully-collected raw body buffer.
|
|
398
|
+
* @returns An array of parsed {@link FormPart} objects.
|
|
399
|
+
* @throws `{ status: 400, message }` when the `boundary` parameter is absent.
|
|
325
400
|
*/
|
|
326
|
-
function
|
|
327
|
-
// BUG FIX: the original regex used `'s+$` (literal quote) instead of `\s+$`.
|
|
401
|
+
export function parseMultipartBody(contentType, data) {
|
|
328
402
|
const boundary = contentType
|
|
329
403
|
.split(';')
|
|
330
404
|
.map((s) => s.replace(/^\s+|\s+$/g, ''))
|
|
331
405
|
.find((s) => s.startsWith('boundary='))
|
|
332
406
|
?.substring('boundary='.length);
|
|
333
407
|
if (!boundary)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
408
|
+
throw { status: 400, message: 'Bad Request: missing multipart boundary' };
|
|
409
|
+
// Wire-level delimiter: each part (after the preamble) is preceded by
|
|
410
|
+
// \r\n--boundary. We split on this sequence so every resulting slice is
|
|
411
|
+
// the raw content of one part (headers + blank line + body), without any
|
|
412
|
+
// leading delimiter bytes.
|
|
413
|
+
const delimiter = Buffer.from(`\r\n--${boundary}`);
|
|
414
|
+
// Prepend \r\n so the very first part is also cleanly split.
|
|
415
|
+
const normalized = Buffer.concat([Buffer.from('\r\n'), data]);
|
|
416
|
+
const rawParts = splitBuffer(normalized, delimiter);
|
|
417
|
+
const parts = [];
|
|
418
|
+
for (const part of rawParts) {
|
|
419
|
+
// The closing delimiter ends with '--'; skip it.
|
|
420
|
+
if (part.toString('utf8', 0, 2) === '--')
|
|
421
|
+
continue;
|
|
422
|
+
// Each part begins with \r\n (from after the delimiter), then headers,
|
|
423
|
+
// then \r\n\r\n (blank line), then content.
|
|
424
|
+
// Skip the leading \r\n.
|
|
425
|
+
const partContent = part.slice(2);
|
|
426
|
+
const blankLine = Buffer.from('\r\n\r\n');
|
|
427
|
+
const blankIdx = partContent.indexOf(blankLine);
|
|
428
|
+
if (blankIdx === -1)
|
|
429
|
+
continue; // malformed part — skip
|
|
430
|
+
const headerSection = partContent.slice(0, blankIdx).toString('utf8');
|
|
431
|
+
const content = partContent.slice(blankIdx + blankLine.length);
|
|
432
|
+
const headers = {};
|
|
433
|
+
for (const line of headerSection.split('\r\n')) {
|
|
434
|
+
if (!line)
|
|
352
435
|
continue;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
436
|
+
const colonIdx = line.indexOf(':');
|
|
437
|
+
if (colonIdx === -1)
|
|
438
|
+
continue;
|
|
439
|
+
const key = line.substring(0, colonIdx).replace(/^\s+|\s+$/g, '').toLowerCase();
|
|
440
|
+
const value = line.substring(colonIdx + 1).replace(/^\s+|\s+$/g, '');
|
|
441
|
+
headers[key] = value;
|
|
442
|
+
}
|
|
443
|
+
parts.push({ headers, content });
|
|
444
|
+
}
|
|
445
|
+
return parts;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Parse a collected body buffer as `multipart/form-data`, split it on the
|
|
449
|
+
* boundary declared in `contentType`, parse each part's headers, and assign
|
|
450
|
+
* an array of {@link FormPart} objects to `req.body`.
|
|
451
|
+
*
|
|
452
|
+
* On success, calls `next()`. On failure (missing boundary, malformed parts),
|
|
453
|
+
* sends the appropriate HTTP error.
|
|
454
|
+
*
|
|
455
|
+
* @param req - The incoming request (mutated: `req.body` is set).
|
|
456
|
+
* @param res - The outgoing response.
|
|
457
|
+
* @param next - Called on successful parsing.
|
|
458
|
+
* @param contentType - The raw `Content-Type` header value (must include
|
|
459
|
+
* `boundary=<value>`).
|
|
460
|
+
* @param data - The raw body buffer.
|
|
461
|
+
*/
|
|
462
|
+
function readBodyAsFormData(req, res, next, contentType, data) {
|
|
463
|
+
try {
|
|
464
|
+
req.body = parseMultipartBody(contentType, data);
|
|
465
|
+
next();
|
|
466
|
+
}
|
|
467
|
+
catch (ex) {
|
|
468
|
+
const status = ex.status ?? 500;
|
|
469
|
+
res.status(status).send(ex.message ?? String(ex));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Parse a collected body buffer as `application/x-www-form-urlencoded` and
|
|
474
|
+
* assign the result to `req.body`.
|
|
475
|
+
*
|
|
476
|
+
* Repeated keys (e.g. `tags=a&tags=b`) produce an array value:
|
|
477
|
+
* `{ tags: ['a', 'b'] }`. Single-occurrence keys produce a plain string.
|
|
478
|
+
*
|
|
479
|
+
* @param req - The incoming request (mutated: `req.body` is set).
|
|
480
|
+
* @param res - The outgoing response.
|
|
481
|
+
* @param next - Called on successful parsing.
|
|
482
|
+
* @param contentType - The raw `Content-Type` header value.
|
|
483
|
+
* @param data - The raw body buffer.
|
|
484
|
+
*/
|
|
485
|
+
function readBodyAsFormEncoded(req, res, next, contentType, data) {
|
|
486
|
+
const charset = extractCharset(contentType);
|
|
487
|
+
try {
|
|
488
|
+
const params = new URLSearchParams(data.toString(charset));
|
|
489
|
+
const result = {};
|
|
490
|
+
for (const [key, value] of params.entries()) {
|
|
491
|
+
const existing = result[key];
|
|
492
|
+
if (existing === undefined) {
|
|
493
|
+
result[key] = value;
|
|
494
|
+
}
|
|
495
|
+
else if (Array.isArray(existing)) {
|
|
496
|
+
existing.push(value);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
result[key] = [existing, value];
|
|
373
500
|
}
|
|
374
|
-
parts.push({ headers, content });
|
|
375
501
|
}
|
|
376
|
-
req.body =
|
|
502
|
+
req.body = result;
|
|
377
503
|
next();
|
|
378
504
|
}
|
|
379
505
|
catch (ex) {
|
|
380
|
-
res.status(
|
|
506
|
+
res.status(400).send('Bad Request: ' + ex.message);
|
|
381
507
|
}
|
|
382
508
|
}
|
|
383
509
|
/**
|
|
@@ -387,49 +513,34 @@ function readBodyAsFormData(req, res, next, contentType, data) {
|
|
|
387
513
|
const BODY_READERS = {
|
|
388
514
|
'multipart/form-data': (req, res, next, _opts, ct, data) => readBodyAsFormData(req, res, next, ct, data),
|
|
389
515
|
'application/json': (req, res, next, opts, ct, data) => readBodyAsJson(req, res, next, opts, ct, data),
|
|
516
|
+
'application/x-www-form-urlencoded': (req, res, next, _opts, ct, data) => readBodyAsFormEncoded(req, res, next, ct, data),
|
|
390
517
|
'text/plain': (req, res, next, _opts, ct, data) => readBodyAsPlainText(req, res, next, ct, data),
|
|
391
518
|
};
|
|
392
519
|
// ---------------------------------------------------------------------------
|
|
393
520
|
// Public middleware factories
|
|
394
521
|
// ---------------------------------------------------------------------------
|
|
395
522
|
/**
|
|
396
|
-
* Middleware factory that parses
|
|
523
|
+
* Middleware factory that parses an `application/json` request body and
|
|
397
524
|
* assigns the parsed value to `req.body`.
|
|
398
525
|
*
|
|
399
|
-
* Also attaches a `res.json(data)` helper to the response object so that
|
|
400
|
-
* handlers can send JSON responses conveniently:
|
|
401
|
-
* ```ts
|
|
402
|
-
* res.json({ ok: true });
|
|
403
|
-
* ```
|
|
404
|
-
*
|
|
405
526
|
* Behaviour:
|
|
406
527
|
* - Requests without a body (`Content-Length: 0` or absent) are passed
|
|
407
528
|
* through to `next()` without touching `req.body`.
|
|
529
|
+
* - Requests whose `Content-Type` is not `application/json` are also passed
|
|
530
|
+
* through to `next()` unchanged, allowing other parsers to handle them
|
|
531
|
+
* (Express-compatible composable behaviour).
|
|
408
532
|
* - Bodies larger than `opts.limit` receive **413 Content Too Large**.
|
|
409
533
|
* - Bodies with an unsupported `Content-Encoding` receive
|
|
410
534
|
* **415 Unsupported Media Type**.
|
|
411
|
-
* - Bodies whose `Content-Type` is not `application/json` receive
|
|
412
|
-
* **415 Unsupported Media Type**.
|
|
413
535
|
* - Parse errors receive **500 Internal Server Error**.
|
|
414
536
|
*
|
|
415
537
|
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
416
538
|
* @returns An Express-compatible middleware function.
|
|
417
539
|
*/
|
|
418
|
-
function json(opts) {
|
|
419
|
-
const resolved =
|
|
420
|
-
inflate: true,
|
|
421
|
-
limit: '100kb',
|
|
422
|
-
reviver: null,
|
|
423
|
-
strict: true,
|
|
424
|
-
...opts,
|
|
425
|
-
};
|
|
540
|
+
export function json(opts) {
|
|
541
|
+
const resolved = resolveBodyOptions(opts, 'application/json');
|
|
426
542
|
return (req, res, next) => {
|
|
427
|
-
|
|
428
|
-
res.json = (data) => {
|
|
429
|
-
res.write(JSON.stringify(data));
|
|
430
|
-
res.end();
|
|
431
|
-
};
|
|
432
|
-
readBody(req, res, resolved, 'application/json', next, (contentType, body) => {
|
|
543
|
+
readBody(req, res, resolved, resolved.type, next, (contentType, body) => {
|
|
433
544
|
readBodyAsJson(req, res, next, resolved, contentType, body);
|
|
434
545
|
});
|
|
435
546
|
};
|
|
@@ -452,46 +563,71 @@ function json(opts) {
|
|
|
452
563
|
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
453
564
|
* @returns An Express-compatible middleware function.
|
|
454
565
|
*/
|
|
455
|
-
function formData(opts) {
|
|
456
|
-
const resolved =
|
|
457
|
-
inflate: true,
|
|
458
|
-
limit: '100kb',
|
|
459
|
-
reviver: null,
|
|
460
|
-
strict: true,
|
|
461
|
-
...opts,
|
|
462
|
-
};
|
|
566
|
+
export function formData(opts) {
|
|
567
|
+
const resolved = resolveBodyOptions(opts, 'multipart/form-data');
|
|
463
568
|
return (req, res, next) => {
|
|
464
|
-
readBody(req, res, resolved,
|
|
569
|
+
readBody(req, res, resolved, resolved.type, next, (contentType, body) => {
|
|
465
570
|
readBodyAsFormData(req, res, next, contentType, body);
|
|
466
571
|
});
|
|
467
572
|
};
|
|
468
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* Middleware factory that parses an `application/x-www-form-urlencoded`
|
|
576
|
+
* request body and assigns the decoded fields to `req.body`.
|
|
577
|
+
*
|
|
578
|
+
* Repeated keys (e.g. `tags=a&tags=b`) produce an array value:
|
|
579
|
+
* `{ tags: ['a', 'b'] }`. Single-occurrence keys produce a plain string.
|
|
580
|
+
*
|
|
581
|
+
* Behaviour:
|
|
582
|
+
* - Requests without a body are passed through to `next()`.
|
|
583
|
+
* - Bodies larger than `opts.limit` receive **413 Content Too Large**.
|
|
584
|
+
* - Requests whose `Content-Type` is not `application/x-www-form-urlencoded`
|
|
585
|
+
* are passed through to `next()` unchanged (Express-compatible composable behaviour).
|
|
586
|
+
* - Parse errors receive **400 Bad Request**.
|
|
587
|
+
*
|
|
588
|
+
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
589
|
+
* @returns An Express-compatible middleware function.
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* ```ts
|
|
593
|
+
* app.post('/form', formEncoded(), (req, res) => {
|
|
594
|
+
* const { username, tags } = req.body as any;
|
|
595
|
+
* res.json({ username, tags }); // tags may be string | string[]
|
|
596
|
+
* });
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
export function formEncoded(opts) {
|
|
600
|
+
const resolved = resolveBodyOptions(opts, 'application/x-www-form-urlencoded');
|
|
601
|
+
return (req, res, next) => {
|
|
602
|
+
readBody(req, res, resolved, resolved.type, next, (contentType, body) => {
|
|
603
|
+
readBodyAsFormEncoded(req, res, next, contentType, body);
|
|
604
|
+
});
|
|
605
|
+
};
|
|
606
|
+
}
|
|
469
607
|
/**
|
|
470
608
|
* Middleware factory that auto-detects the `Content-Type` of the request body
|
|
471
609
|
* and parses it using the appropriate parser.
|
|
472
610
|
*
|
|
473
611
|
* Supported MIME types:
|
|
474
|
-
* - `application/json`
|
|
475
|
-
* - `multipart/form-data`
|
|
476
|
-
* - `
|
|
612
|
+
* - `application/json` → parsed as JSON; result is a JS value.
|
|
613
|
+
* - `multipart/form-data` → parsed as multipart; result is `FormPart[]`.
|
|
614
|
+
* - `application/x-www-form-urlencoded` → decoded as key/value pairs; result is
|
|
615
|
+
* `Record<string, string | string[]>`.
|
|
616
|
+
* - `text/plain` → decoded as text; result is a string.
|
|
477
617
|
*
|
|
478
618
|
* Requests with an unsupported MIME type receive **415 Unsupported Media Type**.
|
|
479
|
-
* All other error conditions behave identically to {@link json}
|
|
480
|
-
* {@link
|
|
619
|
+
* All other error conditions behave identically to {@link json}, {@link formData},
|
|
620
|
+
* and {@link formEncoded}.
|
|
481
621
|
*
|
|
482
622
|
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
483
623
|
* @returns An Express-compatible middleware function.
|
|
484
624
|
*/
|
|
485
|
-
function parseBody(opts) {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
reviver: null,
|
|
490
|
-
strict: true,
|
|
491
|
-
...opts,
|
|
492
|
-
};
|
|
625
|
+
export function parseBody(opts) {
|
|
626
|
+
// parseBody matches any content type by default (`null`); callers may still
|
|
627
|
+
// narrow it via `opts.type`.
|
|
628
|
+
const resolved = resolveBodyOptions(opts, null);
|
|
493
629
|
return (req, res, next) => {
|
|
494
|
-
readBody(req, res, resolved,
|
|
630
|
+
readBody(req, res, resolved, resolved.type, next, (contentType, body) => {
|
|
495
631
|
const mimetype = contentType.split(';')[0].trim();
|
|
496
632
|
if (!BODY_READERS[mimetype])
|
|
497
633
|
return res.status(415).send('Unsupported Media Type');
|
|
@@ -499,6 +635,113 @@ function parseBody(opts) {
|
|
|
499
635
|
});
|
|
500
636
|
};
|
|
501
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Middleware factory that collects the request body into a `Buffer` and assigns
|
|
640
|
+
* it to `req.body` without any parsing.
|
|
641
|
+
*
|
|
642
|
+
* Behaviour:
|
|
643
|
+
* - Defaults to handling `application/octet-stream`; override with `opts.type`
|
|
644
|
+
* (e.g. `raw({ type: '*\/*' })` to capture every body).
|
|
645
|
+
* - Requests without a body are passed through to `next()`.
|
|
646
|
+
* - Requests whose `Content-Type` does not match are passed through unchanged.
|
|
647
|
+
* - Bodies larger than `opts.limit` receive **413 Content Too Large**.
|
|
648
|
+
* - Bodies with an unsupported `Content-Encoding` receive
|
|
649
|
+
* **415 Unsupported Media Type**.
|
|
650
|
+
*
|
|
651
|
+
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
652
|
+
* @returns An Express-compatible middleware function.
|
|
653
|
+
*
|
|
654
|
+
* @example
|
|
655
|
+
* ```ts
|
|
656
|
+
* app.post('/webhook', raw({ type: 'application/json' }), (req, res) => {
|
|
657
|
+
* const raw = req.body as Buffer; // untouched bytes
|
|
658
|
+
* res.send('ok');
|
|
659
|
+
* });
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
export function raw(opts) {
|
|
663
|
+
const resolved = resolveBodyOptions(opts, 'application/octet-stream');
|
|
664
|
+
return (req, res, next) => {
|
|
665
|
+
readBody(req, res, resolved, resolved.type, next, (_contentType, body) => {
|
|
666
|
+
req.body = body;
|
|
667
|
+
next();
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Middleware factory that decodes the request body as text (using the charset
|
|
673
|
+
* from the `Content-Type` header) and assigns the resulting string to
|
|
674
|
+
* `req.body`.
|
|
675
|
+
*
|
|
676
|
+
* Behaviour:
|
|
677
|
+
* - Defaults to handling `text/plain`; override with `opts.type`
|
|
678
|
+
* (e.g. `text({ type: 'text/*' })`).
|
|
679
|
+
* - Requests without a body are passed through to `next()`.
|
|
680
|
+
* - Requests whose `Content-Type` does not match are passed through unchanged.
|
|
681
|
+
* - Bodies larger than `opts.limit` receive **413 Content Too Large**.
|
|
682
|
+
* - Decoding errors receive **500 Internal Server Error**.
|
|
683
|
+
*
|
|
684
|
+
* @param opts - Optional configuration (see {@link BodyOptions}).
|
|
685
|
+
* @returns An Express-compatible middleware function.
|
|
686
|
+
*/
|
|
687
|
+
export function text(opts) {
|
|
688
|
+
const resolved = resolveBodyOptions(opts, 'text/plain');
|
|
689
|
+
return (req, res, next) => {
|
|
690
|
+
readBody(req, res, resolved, resolved.type, next, (contentType, body) => {
|
|
691
|
+
readBodyAsPlainText(req, res, next, contentType, body);
|
|
692
|
+
});
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Async generator that yields each part of a `multipart/form-data` request
|
|
697
|
+
* body as a {@link FormPartStream} object.
|
|
698
|
+
*
|
|
699
|
+
* Unlike {@link formData} (which must be installed as middleware before a
|
|
700
|
+
* handler), `streamFormData` can be called directly inside any handler and
|
|
701
|
+
* returns an async iterable of parts:
|
|
702
|
+
*
|
|
703
|
+
* ```ts
|
|
704
|
+
* app.post('/upload', async (req, res) => {
|
|
705
|
+
* for await (const part of streamFormData(req)) {
|
|
706
|
+
* const name = part.headers['content-disposition'];
|
|
707
|
+
* const chunks: Buffer[] = [];
|
|
708
|
+
* for await (const chunk of part.stream) chunks.push(chunk);
|
|
709
|
+
* const content = Buffer.concat(chunks);
|
|
710
|
+
* // ... process content
|
|
711
|
+
* }
|
|
712
|
+
* res.send('ok');
|
|
713
|
+
* });
|
|
714
|
+
* ```
|
|
715
|
+
*
|
|
716
|
+
* **Note:** the full request body is buffered before parts are yielded,
|
|
717
|
+
* because the multipart boundary must span the entire body. For very large
|
|
718
|
+
* uploads, prefer streaming directly from the raw request.
|
|
719
|
+
*
|
|
720
|
+
* @param req - The incoming request (must be a `multipart/form-data` request).
|
|
721
|
+
* @param opts - Optional configuration — only `limit` and `inflate` are used.
|
|
722
|
+
* @throws `{ status: 400, message }` when the `boundary` parameter is absent.
|
|
723
|
+
* @throws `{ status: 413, message }` when the body exceeds the size limit.
|
|
724
|
+
*/
|
|
725
|
+
export async function* streamFormData(req, opts) {
|
|
726
|
+
const maxSize = (opts?.limit !== undefined ? readSize(opts.limit) : 0) || 102_400;
|
|
727
|
+
// Collect all chunks from the raw request stream.
|
|
728
|
+
const chunks = [];
|
|
729
|
+
let totalSize = 0;
|
|
730
|
+
for await (const chunk of req) {
|
|
731
|
+
totalSize += chunk.length;
|
|
732
|
+
if (totalSize > maxSize)
|
|
733
|
+
throw { status: 413, message: 'Content Too Large' };
|
|
734
|
+
chunks.push(chunk);
|
|
735
|
+
}
|
|
736
|
+
const body = Buffer.concat(chunks);
|
|
737
|
+
const contentType = (req.headers['content-type']) ?? '';
|
|
738
|
+
const parts = parseMultipartBody(contentType, body);
|
|
739
|
+
for (const part of parts) {
|
|
740
|
+
// Expose each part's Buffer content as a Readable stream so callers can
|
|
741
|
+
// pipe, pipeline, or iterate it uniformly.
|
|
742
|
+
yield { headers: part.headers, stream: Readable.from(part.content) };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
502
745
|
// ---------------------------------------------------------------------------
|
|
503
746
|
// Logger middleware
|
|
504
747
|
// ---------------------------------------------------------------------------
|
|
@@ -554,7 +797,7 @@ const ANSI_RESET = '\x1b[0m';
|
|
|
554
797
|
* }));
|
|
555
798
|
* ```
|
|
556
799
|
*/
|
|
557
|
-
function logger(opts) {
|
|
800
|
+
export function logger(opts) {
|
|
558
801
|
const options = opts ?? {};
|
|
559
802
|
const log = options.logger ?? console.log;
|
|
560
803
|
const formatter = new Intl.DateTimeFormat(options.locale ?? 'en-GB', options.dateFormat ?? {
|
|
@@ -568,9 +811,7 @@ function logger(opts) {
|
|
|
568
811
|
// even if later middleware mutates req.path (e.g. prefix stripping).
|
|
569
812
|
const timestamp = formatter.format(new Date());
|
|
570
813
|
const requestPath = req.path ?? req.url ?? '/';
|
|
571
|
-
const ip = req.
|
|
572
|
-
?? req.socket?.remoteAddress
|
|
573
|
-
?? '-';
|
|
814
|
+
const ip = req.ip ?? '';
|
|
574
815
|
const user = options.user?.(req) ?? '-';
|
|
575
816
|
const receivedAt = Date.now();
|
|
576
817
|
// Lost-request tracking.
|
|
@@ -601,20 +842,57 @@ function logger(opts) {
|
|
|
601
842
|
length: contentLen,
|
|
602
843
|
});
|
|
603
844
|
else
|
|
604
|
-
log(`${timestamp} ${statusStr} ${req.method} ${requestPath} ${ip} <${user}> ${elapsed}ms (${contentLen})`);
|
|
845
|
+
log(`${timestamp} ${statusStr} ${req.method} ${requestPath} ${ip} <${user}> ${elapsed}ms (${String(contentLen)})`);
|
|
605
846
|
});
|
|
606
847
|
next();
|
|
607
848
|
};
|
|
608
849
|
}
|
|
609
|
-
|
|
850
|
+
/**
|
|
851
|
+
* Resolve the single `Access-Control-Allow-Origin` value for a request, given
|
|
852
|
+
* the configured {@link CorsOptions.origin} and the request's `Origin` header.
|
|
853
|
+
*
|
|
854
|
+
* @param origin - The configured allow-list (string or array form).
|
|
855
|
+
* @param requestOrigin - The request's `Origin` header value.
|
|
856
|
+
* @returns The string to send as `Access-Control-Allow-Origin`, or `undefined`
|
|
857
|
+
* when `origin` is an array with no matching entry (the header
|
|
858
|
+
* should then be omitted entirely).
|
|
859
|
+
*/
|
|
860
|
+
function resolveAllowOrigin(origin, requestOrigin) {
|
|
861
|
+
if (typeof origin === 'string')
|
|
862
|
+
return origin;
|
|
863
|
+
return origin.includes(requestOrigin) ? requestOrigin : undefined;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Middleware factory that adds Cross-Origin Resource Sharing (CORS) response
|
|
867
|
+
* headers and answers `OPTIONS` preflight requests.
|
|
868
|
+
*
|
|
869
|
+
* CORS headers are only set when the request carries an `Origin` header
|
|
870
|
+
* (browser-only; server-to-server requests typically omit it). When
|
|
871
|
+
* {@link CorsOptions.origin} is an array, the request's `Origin` is matched
|
|
872
|
+
* against it via {@link resolveAllowOrigin} and only the matching value is
|
|
873
|
+
* echoed back — see that option's documentation for why a plain array cannot
|
|
874
|
+
* be passed straight to `res.setHeader()`.
|
|
875
|
+
*
|
|
876
|
+
* @param opts - Optional configuration (see {@link CorsOptions}). All fields
|
|
877
|
+
* are optional; unset fields fall back to permissive defaults
|
|
878
|
+
* (wildcard origin, no credentials, no max-age).
|
|
879
|
+
* @returns An Express-compatible middleware function.
|
|
880
|
+
*
|
|
881
|
+
* @example
|
|
882
|
+
* ```ts
|
|
883
|
+
* // Allow exactly two known origins, denying everything else
|
|
884
|
+
* app.use(cors({ origin: ['https://app.example.com', 'https://admin.example.com'] }));
|
|
885
|
+
* ```
|
|
886
|
+
*/
|
|
887
|
+
export function cors(opts) {
|
|
610
888
|
const options = {
|
|
611
|
-
origin: opts?.origin
|
|
612
|
-
allowHeaders: opts?.allowHeaders
|
|
613
|
-
allowMethods: opts?.allowMethods
|
|
889
|
+
origin: opts?.origin ?? '*',
|
|
890
|
+
allowHeaders: opts?.allowHeaders ?? 'Accept, Content-Type, Authorization',
|
|
891
|
+
allowMethods: opts?.allowMethods ?? 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
|
614
892
|
allowCredentials: opts?.allowCredentials,
|
|
615
893
|
maxAge: opts?.maxAge,
|
|
616
894
|
vary: opts?.vary,
|
|
617
|
-
optionsStatus: opts?.optionsStatus
|
|
895
|
+
optionsStatus: opts?.optionsStatus ?? 204,
|
|
618
896
|
preflight: opts?.preflight,
|
|
619
897
|
};
|
|
620
898
|
return (req, res, next) => {
|
|
@@ -623,9 +901,12 @@ function cors(opts) {
|
|
|
623
901
|
return;
|
|
624
902
|
}
|
|
625
903
|
if (req.headers.origin) {
|
|
626
|
-
|
|
627
|
-
if (
|
|
628
|
-
res.setHeader('
|
|
904
|
+
const allowOrigin = resolveAllowOrigin(options.origin, req.headers.origin);
|
|
905
|
+
if (allowOrigin !== undefined) {
|
|
906
|
+
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
|
|
907
|
+
if (options.vary !== undefined)
|
|
908
|
+
res.setHeader('Vary', options.vary);
|
|
909
|
+
}
|
|
629
910
|
}
|
|
630
911
|
if (req.method == 'OPTIONS') {
|
|
631
912
|
res.setHeader('Access-Control-Allow-Headers', options.allowHeaders);
|
|
@@ -640,5 +921,5 @@ function cors(opts) {
|
|
|
640
921
|
next();
|
|
641
922
|
};
|
|
642
923
|
}
|
|
643
|
-
|
|
924
|
+
export default { json, formData, formEncoded, parseBody, logger, cors, streamFormData };
|
|
644
925
|
//# sourceMappingURL=misc.js.map
|