@orkestrel/middleware 0.0.18 → 0.0.19
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 +4 -4
- package/dist/src/core/index.cjs +357 -294
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +390 -290
- package/dist/src/core/index.d.ts +390 -290
- package/dist/src/core/index.js +355 -293
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +472 -410
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +290 -208
- package/dist/src/server/index.d.ts +290 -208
- package/dist/src/server/index.js +464 -405
- package/dist/src/server/index.js.map +1 -1
- package/package.json +18 -19
package/dist/src/server/index.js
CHANGED
|
@@ -1,42 +1,57 @@
|
|
|
1
|
+
import { HTTPError, computeBodyETag, isDangerousKey, matchesETag, negotiateEncoding, parseRange } from "@orkestrel/server";
|
|
1
2
|
import { createReadStream } from "node:fs";
|
|
2
3
|
import { chmod, copyFile, mkdtemp, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
3
4
|
import { extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { brotliDecompressSync, deflate, gzip } from "node:zlib";
|
|
6
|
-
import { isArrayBuffer, isFiniteNumber, isFunction, isObject,
|
|
7
|
+
import { isArrayBuffer, isError, isFiniteNumber, isFunction, isObject, isString, isUint8Array } from "@orkestrel/contract";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { HTTPError, computeBodyETag, isDangerousKey, matchesETag, negotiateEncoding, parseRange } from "@orkestrel/server";
|
|
10
10
|
import { DEFAULT_COMPRESSION_THRESHOLD, compressResponse } from "../core/index.js";
|
|
11
11
|
//#region src/server/constants.ts
|
|
12
|
-
/**
|
|
13
|
-
var
|
|
12
|
+
/** Holds the HTTP status `createMultipart` renders for each {@link MultipartErrorCode}. */
|
|
13
|
+
var MULTIPART_STATUS = Object.freeze({
|
|
14
14
|
limit: 413,
|
|
15
15
|
malformed: 400,
|
|
16
16
|
rejected: 415
|
|
17
17
|
});
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Holds the `Symbol.for` brand {@link MultipartError} carries so
|
|
20
|
+
* {@link isMultipartError} recognizes an instance across duplicate copies of
|
|
21
|
+
* this package — a registry symbol rather than a module-local `Symbol()`,
|
|
22
|
+
* which would mint an unequal symbol per copy.
|
|
23
|
+
*/
|
|
24
|
+
var MULTIPART_ERROR_BRAND = Symbol.for("@orkestrel/middleware.MultipartError");
|
|
25
|
+
/** Names `createStatic`'s default directory-index filename. */
|
|
19
26
|
var DEFAULT_STATIC_INDEX = "index.html";
|
|
20
|
-
/** `createStatic`'s `fallback: true` default excluded path prefix. */
|
|
27
|
+
/** Names `createStatic`'s `fallback: true` default excluded path prefix. */
|
|
21
28
|
var DEFAULT_STATIC_FALLBACK_EXCLUDE = "/api";
|
|
22
|
-
/**
|
|
29
|
+
/** Names `createStatic`'s default policy for a path carrying a dotfile segment. */
|
|
30
|
+
var DEFAULT_STATIC_DOTFILES = "ignore";
|
|
31
|
+
/**
|
|
32
|
+
* Lists the content-codings the node face's `createCompression` offers — the two
|
|
33
|
+
* `node:zlib` guarantees on every Node runtime, so this face never
|
|
34
|
+
* feature-detects.
|
|
35
|
+
*/
|
|
36
|
+
var NODE_COMPRESSION_ENCODINGS = Object.freeze(["gzip", "deflate"]);
|
|
37
|
+
/** Names the MIME type served when a file extension has no known mapping. */
|
|
23
38
|
var DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
24
|
-
/** `createMultipart`'s default per-file byte-size cap. */
|
|
25
|
-
var
|
|
26
|
-
/** `createMultipart`'s default maximum file-part count. */
|
|
27
|
-
var
|
|
28
|
-
/** `createMultipart`'s default per-field byte-size cap. */
|
|
29
|
-
var
|
|
30
|
-
/** `createMultipart`'s default maximum field-part count. */
|
|
31
|
-
var
|
|
32
|
-
/** `createMultipart`'s default combined request-body byte-size cap. */
|
|
39
|
+
/** Holds `createMultipart`'s default per-file byte-size cap. */
|
|
40
|
+
var DEFAULT_MULTIPART_FILE_SIZE = 10485760;
|
|
41
|
+
/** Holds `createMultipart`'s default maximum file-part count. */
|
|
42
|
+
var DEFAULT_MULTIPART_FILE_COUNT = 10;
|
|
43
|
+
/** Holds `createMultipart`'s default per-field byte-size cap. */
|
|
44
|
+
var DEFAULT_MULTIPART_FIELD_SIZE = 65536;
|
|
45
|
+
/** Holds `createMultipart`'s default maximum field-part count. */
|
|
46
|
+
var DEFAULT_MULTIPART_FIELD_COUNT = 100;
|
|
47
|
+
/** Holds `createMultipart`'s default combined request-body byte-size cap. */
|
|
33
48
|
var DEFAULT_MULTIPART_TOTAL = 52428800;
|
|
34
|
-
/**
|
|
49
|
+
/** Holds the maximum bytes a single multipart part's header block may occupy before it is malformed. */
|
|
35
50
|
var MULTIPART_MAX_HEADER_BLOCK = 16384;
|
|
36
|
-
/**
|
|
51
|
+
/** Holds the maximum bytes scanned before the first multipart boundary is found before it is malformed. */
|
|
37
52
|
var MULTIPART_MAX_PREAMBLE = 65536;
|
|
38
53
|
/**
|
|
39
|
-
* Windows reserved device-name stems (CVE-2025-27210) — matched
|
|
54
|
+
* Lists the Windows reserved device-name stems (CVE-2025-27210) — matched
|
|
40
55
|
* case-insensitively against the segment's stem (before its first `.`).
|
|
41
56
|
*/
|
|
42
57
|
var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
|
|
@@ -63,7 +78,7 @@ var RESERVED_DEVICE_NAMES = Object.freeze(/* @__PURE__ */ new Set([
|
|
|
63
78
|
"LPT8",
|
|
64
79
|
"LPT9"
|
|
65
80
|
]));
|
|
66
|
-
/**
|
|
81
|
+
/** Holds the file-extension (lowercase, with leading `.`) → MIME type lookup table for static serving. */
|
|
67
82
|
var EXTENSION_TYPES = Object.freeze({
|
|
68
83
|
".html": "text/html; charset=utf-8",
|
|
69
84
|
".htm": "text/html; charset=utf-8",
|
|
@@ -89,15 +104,18 @@ var EXTENSION_TYPES = Object.freeze({
|
|
|
89
104
|
//#endregion
|
|
90
105
|
//#region src/server/errors.ts
|
|
91
106
|
/**
|
|
92
|
-
*
|
|
107
|
+
* Represents an error `createMultipart` throws when a streamed multipart request fails
|
|
93
108
|
* a mid-stream limit, is structurally malformed, or has a file whose sniffed
|
|
94
109
|
* bytes are rejected by the configured `allowed` MIME list.
|
|
95
110
|
*
|
|
96
111
|
* @remarks
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
112
|
+
* Extends the peer `HTTPError`, which already publishes the `status`,
|
|
113
|
+
* `context`, and brand members every fleet error of this shape carries, and
|
|
114
|
+
* adds the machine-readable `code` axis a caller narrows on. `status` is
|
|
115
|
+
* derived from `code` through {@link MULTIPART_STATUS} (limit → 413, malformed →
|
|
116
|
+
* 400, rejected → 415), so `createBoundary` — or any other `isHTTPError`-aware
|
|
117
|
+
* renderer — maps it without knowing this face's error type. Narrow a caught
|
|
118
|
+
* value to the richer type with {@link isMultipartError}.
|
|
101
119
|
*
|
|
102
120
|
* @example
|
|
103
121
|
* ```ts
|
|
@@ -106,29 +124,26 @@ var EXTENSION_TYPES = Object.freeze({
|
|
|
106
124
|
* throw new MultipartError('limit', 'too many files')
|
|
107
125
|
* ```
|
|
108
126
|
*/
|
|
109
|
-
var MultipartError = class extends
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
context
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
this.reason = reason;
|
|
117
|
-
if (context !== void 0) this.context = context;
|
|
118
|
-
Object.defineProperty(this, Symbol.for("@orkestrel/middleware.MultipartError"), { value: true });
|
|
127
|
+
var MultipartError = class extends HTTPError {
|
|
128
|
+
code;
|
|
129
|
+
[MULTIPART_ERROR_BRAND] = true;
|
|
130
|
+
constructor(code, message, context) {
|
|
131
|
+
super(MULTIPART_STATUS[code], message, context);
|
|
132
|
+
this.name = "MultipartError";
|
|
133
|
+
this.code = code;
|
|
119
134
|
}
|
|
120
135
|
};
|
|
121
136
|
/**
|
|
122
|
-
*
|
|
137
|
+
* Narrows an unknown caught value to a {@link MultipartError}.
|
|
123
138
|
*
|
|
124
139
|
* @remarks
|
|
125
140
|
* Structural, not `instanceof` — tests that `value` is a non-null object
|
|
126
|
-
* carrying
|
|
127
|
-
* parser's
|
|
141
|
+
* carrying {@link MULTIPART_ERROR_BRAND}, a numeric `status`, and a `code`
|
|
142
|
+
* in the parser's {@link MultipartErrorCode} set (`'limit' | 'malformed' | 'rejected'`).
|
|
128
143
|
* Total: never throws, returns `false` for any off-shape input.
|
|
129
144
|
*
|
|
130
145
|
* @param value - The value to test (typically a `catch` binding)
|
|
131
|
-
* @returns
|
|
146
|
+
* @returns True if `value` is a {@link MultipartError}; false otherwise
|
|
132
147
|
*
|
|
133
148
|
* @example
|
|
134
149
|
* ```ts
|
|
@@ -137,233 +152,29 @@ var MultipartError = class extends Error {
|
|
|
137
152
|
* try {
|
|
138
153
|
* await parse(request)
|
|
139
154
|
* } catch (error) {
|
|
140
|
-
* if (isMultipartError(error)) console.log(error.status, error.
|
|
155
|
+
* if (isMultipartError(error)) console.log(error.status, error.code)
|
|
141
156
|
* }
|
|
142
157
|
* ```
|
|
143
158
|
*/
|
|
144
159
|
function isMultipartError(value) {
|
|
145
160
|
if (typeof value !== "object" || value === null) return false;
|
|
146
|
-
if (!(
|
|
147
|
-
if (!("status" in value) || !("
|
|
161
|
+
if (!(MULTIPART_ERROR_BRAND in value)) return false;
|
|
162
|
+
if (!("status" in value) || !("code" in value)) return false;
|
|
148
163
|
if (typeof value.status !== "number") return false;
|
|
149
|
-
if (value.
|
|
164
|
+
if (value.code !== "limit" && value.code !== "malformed" && value.code !== "rejected") return false;
|
|
150
165
|
return true;
|
|
151
166
|
}
|
|
152
167
|
//#endregion
|
|
153
|
-
//#region src/server/MultipartParser.ts
|
|
154
|
-
var MultipartParser = class MultipartParser {
|
|
155
|
-
static #defaultDirectory;
|
|
156
|
-
#reader;
|
|
157
|
-
#signal;
|
|
158
|
-
#abort;
|
|
159
|
-
#boundary;
|
|
160
|
-
#limits;
|
|
161
|
-
#allowed;
|
|
162
|
-
#directory;
|
|
163
|
-
#staged = [];
|
|
164
|
-
#files = Object.create(null);
|
|
165
|
-
#fields = Object.create(null);
|
|
166
|
-
#buffer = Buffer.alloc(0);
|
|
167
|
-
#ended = false;
|
|
168
|
-
#fileCount = 0;
|
|
169
|
-
#fieldCount = 0;
|
|
170
|
-
#totalBytes = 0;
|
|
171
|
-
constructor(stream, signal, boundary, limits, allowed, directory) {
|
|
172
|
-
this.#reader = stream.getReader();
|
|
173
|
-
this.#signal = signal;
|
|
174
|
-
this.#abort = this.#wakeReader.bind(this);
|
|
175
|
-
this.#boundary = boundary;
|
|
176
|
-
this.#limits = limits;
|
|
177
|
-
this.#allowed = allowed;
|
|
178
|
-
this.#directory = directory;
|
|
179
|
-
}
|
|
180
|
-
static directory() {
|
|
181
|
-
if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
|
|
182
|
-
return MultipartParser.#defaultDirectory;
|
|
183
|
-
}
|
|
184
|
-
async parse() {
|
|
185
|
-
this.#signal.addEventListener("abort", this.#abort, { once: true });
|
|
186
|
-
try {
|
|
187
|
-
const openMarker = Buffer.from(`--${this.#boundary}`);
|
|
188
|
-
let preambleScanned = 0;
|
|
189
|
-
let index = this.#buffer.indexOf(openMarker);
|
|
190
|
-
while (index === -1) {
|
|
191
|
-
const carry = openMarker.length - 1;
|
|
192
|
-
if (this.#buffer.length > carry) {
|
|
193
|
-
const drop = this.#buffer.length - carry;
|
|
194
|
-
preambleScanned += drop;
|
|
195
|
-
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
196
|
-
this.#buffer = this.#buffer.subarray(drop);
|
|
197
|
-
}
|
|
198
|
-
if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
199
|
-
index = this.#buffer.indexOf(openMarker);
|
|
200
|
-
}
|
|
201
|
-
if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
202
|
-
this.#buffer = this.#buffer.subarray(index + openMarker.length);
|
|
203
|
-
for (;;) {
|
|
204
|
-
while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
205
|
-
if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
|
|
206
|
-
if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
207
|
-
this.#buffer = this.#buffer.subarray(2);
|
|
208
|
-
let headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
209
|
-
while (headerEnd === -1) {
|
|
210
|
-
if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
211
|
-
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
212
|
-
headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
213
|
-
}
|
|
214
|
-
if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
215
|
-
const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
|
|
216
|
-
this.#buffer = this.#buffer.subarray(headerEnd + 4);
|
|
217
|
-
const { name, filename, contentType } = parsePartHeaders(headerBlock);
|
|
218
|
-
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
219
|
-
const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
|
|
220
|
-
if (filename !== void 0) {
|
|
221
|
-
if (filename !== "") {
|
|
222
|
-
this.#fileCount += 1;
|
|
223
|
-
if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
224
|
-
}
|
|
225
|
-
const path = join(this.#directory, randomUUID());
|
|
226
|
-
this.#staged.push(path);
|
|
227
|
-
const handle = await open(path, "w", 384);
|
|
228
|
-
let size = 0;
|
|
229
|
-
let head = Buffer.alloc(0);
|
|
230
|
-
try {
|
|
231
|
-
for (;;) {
|
|
232
|
-
const boundaryIndex = this.#buffer.indexOf(partDelimiter);
|
|
233
|
-
if (boundaryIndex === -1) {
|
|
234
|
-
const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
|
|
235
|
-
if (safeLength > 0) {
|
|
236
|
-
const chunk = this.#buffer.subarray(0, safeLength);
|
|
237
|
-
size += chunk.length;
|
|
238
|
-
if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
239
|
-
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
240
|
-
await handle.write(chunk);
|
|
241
|
-
this.#buffer = this.#buffer.subarray(safeLength);
|
|
242
|
-
}
|
|
243
|
-
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart file part");
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
const chunk = this.#buffer.subarray(0, boundaryIndex);
|
|
247
|
-
size += chunk.length;
|
|
248
|
-
if (size > this.#limits.file) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
249
|
-
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
250
|
-
await handle.write(chunk);
|
|
251
|
-
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
} finally {
|
|
255
|
-
await handle.close();
|
|
256
|
-
}
|
|
257
|
-
if (filename === "" && size === 0) {
|
|
258
|
-
await unlink(path);
|
|
259
|
-
this.#staged.splice(this.#staged.indexOf(path), 1);
|
|
260
|
-
} else {
|
|
261
|
-
if (filename === "") {
|
|
262
|
-
this.#fileCount += 1;
|
|
263
|
-
if (this.#fileCount > this.#limits.files) throw new MultipartError("limit", "too many multipart files");
|
|
264
|
-
}
|
|
265
|
-
const detected = detectMIME(head);
|
|
266
|
-
const declared = contentType ?? "application/octet-stream";
|
|
267
|
-
const validated = detected !== void 0 && detected === declared;
|
|
268
|
-
if (this.#allowed !== void 0) {
|
|
269
|
-
if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
270
|
-
}
|
|
271
|
-
if (isDangerousKey(name)) {
|
|
272
|
-
await unlink(path);
|
|
273
|
-
this.#staged.splice(this.#staged.indexOf(path), 1);
|
|
274
|
-
} else {
|
|
275
|
-
const record = createUploadedFile({
|
|
276
|
-
field: name,
|
|
277
|
-
name: filename,
|
|
278
|
-
size,
|
|
279
|
-
mime: detected ?? declared,
|
|
280
|
-
validated,
|
|
281
|
-
status: "staged",
|
|
282
|
-
path
|
|
283
|
-
});
|
|
284
|
-
const existing = this.#files[name];
|
|
285
|
-
if (existing === void 0) this.#files[name] = [record];
|
|
286
|
-
else existing.push(record);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
} else {
|
|
290
|
-
this.#fieldCount += 1;
|
|
291
|
-
if (this.#fieldCount > this.#limits.fields) throw new MultipartError("limit", "too many multipart fields");
|
|
292
|
-
let value = Buffer.alloc(0);
|
|
293
|
-
for (;;) {
|
|
294
|
-
const boundaryIndex = this.#buffer.indexOf(partDelimiter);
|
|
295
|
-
if (boundaryIndex === -1) {
|
|
296
|
-
const safeLength = Math.max(0, this.#buffer.length - (partDelimiter.length - 1));
|
|
297
|
-
if (safeLength > 0) {
|
|
298
|
-
value = Buffer.concat([value, this.#buffer.subarray(0, safeLength)]);
|
|
299
|
-
if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
300
|
-
this.#buffer = this.#buffer.subarray(safeLength);
|
|
301
|
-
}
|
|
302
|
-
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart field part");
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
value = Buffer.concat([value, this.#buffer.subarray(0, boundaryIndex)]);
|
|
306
|
-
if (value.length > this.#limits.field) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
307
|
-
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
308
|
-
break;
|
|
309
|
-
}
|
|
310
|
-
if (!isDangerousKey(name)) this.#fields[name] = value.toString("utf8");
|
|
311
|
-
}
|
|
312
|
-
while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
313
|
-
this.#buffer = this.#buffer.subarray(openMarker.length);
|
|
314
|
-
}
|
|
315
|
-
} catch (error) {
|
|
316
|
-
await this.#cleanup();
|
|
317
|
-
await this.#reader.cancel().catch(() => {});
|
|
318
|
-
throw error;
|
|
319
|
-
} finally {
|
|
320
|
-
this.#signal.removeEventListener("abort", this.#abort);
|
|
321
|
-
if (!this.#ended) await this.#reader.cancel().catch(() => {});
|
|
322
|
-
}
|
|
323
|
-
return {
|
|
324
|
-
files: Object.freeze(this.#files),
|
|
325
|
-
fields: Object.freeze(this.#fields)
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
static async #createDirectory() {
|
|
329
|
-
const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
|
|
330
|
-
await chmod(path, 448);
|
|
331
|
-
return path;
|
|
332
|
-
}
|
|
333
|
-
async #cleanup() {
|
|
334
|
-
for (const path of this.#staged) try {
|
|
335
|
-
await unlink(path);
|
|
336
|
-
} catch {}
|
|
337
|
-
}
|
|
338
|
-
async #pull() {
|
|
339
|
-
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
340
|
-
if (this.#ended) return false;
|
|
341
|
-
const { done, value } = await this.#reader.read();
|
|
342
|
-
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
343
|
-
if (done) {
|
|
344
|
-
this.#ended = true;
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
this.#totalBytes += value.byteLength;
|
|
348
|
-
if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
349
|
-
this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
350
|
-
return true;
|
|
351
|
-
}
|
|
352
|
-
#wakeReader() {
|
|
353
|
-
this.#reader.cancel().catch(() => {});
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
//#endregion
|
|
357
168
|
//#region src/server/helpers.ts
|
|
358
169
|
/**
|
|
359
|
-
*
|
|
170
|
+
* Checks whether `pathname` is `prefix` itself or lies under it on a SEGMENT
|
|
360
171
|
* boundary — the shared under-path test `resolveStaticPath`'s prefix strip
|
|
361
172
|
* and `createStatic`'s SPA-fallback `exclude` both apply, so `exclude:
|
|
362
173
|
* '/api'` matches `/api` and `/api/x` but never `/apifoo`.
|
|
363
174
|
*
|
|
364
175
|
* @param pathname - The request pathname to test
|
|
365
176
|
* @param prefix - The path prefix to test against
|
|
366
|
-
* @returns
|
|
177
|
+
* @returns True if `pathname` equals `prefix` or starts with `prefix` + `/`; false otherwise
|
|
367
178
|
*
|
|
368
179
|
* @example
|
|
369
180
|
* ```ts
|
|
@@ -377,13 +188,19 @@ function isUnderPath(pathname, prefix) {
|
|
|
377
188
|
return pathname.startsWith(boundary);
|
|
378
189
|
}
|
|
379
190
|
/**
|
|
380
|
-
*
|
|
191
|
+
* Resolves the fixed SPA shell path when a static-file miss is eligible for
|
|
381
192
|
* fallback.
|
|
382
193
|
*
|
|
194
|
+
* @remarks
|
|
195
|
+
* `GET` and `HEAD` are both eligible, and resolve the SAME shell: `HEAD` is
|
|
196
|
+
* defined as `GET` without a body (RFC 9110 §9.3.2), so a navigation probe
|
|
197
|
+
* that answered `404` while its `GET` answered `200` would report a resource
|
|
198
|
+
* the very next request serves.
|
|
199
|
+
*
|
|
383
200
|
* @param root - The configured static root
|
|
384
201
|
* @param index - The configured shell filename
|
|
385
202
|
* @param exclude - The URL prefix excluded from fallback
|
|
386
|
-
* @param method - The request method
|
|
203
|
+
* @param method - The request method; only `GET` and `HEAD` are eligible
|
|
387
204
|
* @param pathname - The request pathname
|
|
388
205
|
* @param accept - The request's `Accept` header value
|
|
389
206
|
* @returns The fixed shell path, or `undefined` when fallback is ineligible
|
|
@@ -395,14 +212,14 @@ function isUnderPath(pathname, prefix) {
|
|
|
395
212
|
* ```
|
|
396
213
|
*/
|
|
397
214
|
function resolveStaticFallbackPath(root, index, exclude, method, pathname, accept) {
|
|
398
|
-
if (method !== "GET") return void 0;
|
|
215
|
+
if (method !== "GET" && method !== "HEAD") return void 0;
|
|
399
216
|
if (extname(pathname) !== "") return void 0;
|
|
400
217
|
if (!accept.includes("text/html") && !accept.includes("*/*")) return void 0;
|
|
401
218
|
if (isUnderPath(pathname, exclude)) return void 0;
|
|
402
219
|
return join(root, index);
|
|
403
220
|
}
|
|
404
221
|
/**
|
|
405
|
-
*
|
|
222
|
+
* Checks whether `child` is `parent` itself or lies inside it on-disk — the
|
|
406
223
|
* FILESYSTEM containment predicate `createStatic` applies to `fs.realpath`
|
|
407
224
|
* output (never to a URL pathname — that is {@link isUnderPath}'s job).
|
|
408
225
|
*
|
|
@@ -416,7 +233,7 @@ function resolveStaticFallbackPath(root, index, exclude, method, pathname, accep
|
|
|
416
233
|
*
|
|
417
234
|
* @param child - The absolute on-disk path to test
|
|
418
235
|
* @param parent - The absolute on-disk directory it must lie under
|
|
419
|
-
* @returns
|
|
236
|
+
* @returns True if `child` equals `parent` or resolves inside it; false otherwise
|
|
420
237
|
*
|
|
421
238
|
* @example
|
|
422
239
|
* ```ts
|
|
@@ -430,9 +247,9 @@ function isContainedPath(child, parent) {
|
|
|
430
247
|
return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
431
248
|
}
|
|
432
249
|
/**
|
|
433
|
-
*
|
|
434
|
-
* when it cannot — the traversal guard,
|
|
435
|
-
*
|
|
250
|
+
* Resolves a request pathname to an on-disk path UNDER `root`, or `undefined`
|
|
251
|
+
* when it cannot — the traversal guard, whose algorithm and order are exact:
|
|
252
|
+
* strip `prefix` on a segment boundary → `decodeURIComponent` (a
|
|
436
253
|
* malformed escape refuses, never throws) → reject a NUL byte → strip the
|
|
437
254
|
* leading path separator FIRST (so a leading `..` survives `normalize` as a
|
|
438
255
|
* genuine climbing segment) → `normalize` → refuse any Windows reserved-
|
|
@@ -469,10 +286,40 @@ function resolveStaticPath(root, prefix, pathname) {
|
|
|
469
286
|
const segments = normalized.split(/[/\\]+/).filter((segment) => segment.length > 0);
|
|
470
287
|
for (const segment of segments) if (isReservedDeviceName(segment)) return void 0;
|
|
471
288
|
const resolved = resolve(root, normalized);
|
|
472
|
-
|
|
289
|
+
return isContainedPath(resolved, root) ? resolved : void 0;
|
|
473
290
|
}
|
|
474
291
|
/**
|
|
475
|
-
*
|
|
292
|
+
* Canonicalizes `candidate` and returns it only when it lies inside `rootReal`
|
|
293
|
+
* — the shared realpath-then-contain step `createStatic` applies to a
|
|
294
|
+
* directory index and to its SPA shell.
|
|
295
|
+
*
|
|
296
|
+
* @remarks
|
|
297
|
+
* Total: a `realpath` failure (a dangling symlink, a missing file, a
|
|
298
|
+
* permission refusal) and an escape from `rootReal` both resolve `undefined`,
|
|
299
|
+
* so a caller that treats those two outcomes identically needs no `try`. A
|
|
300
|
+
* caller that must tell them apart keeps its own explicit branch instead.
|
|
301
|
+
*
|
|
302
|
+
* @param candidate - The on-disk path to canonicalize
|
|
303
|
+
* @param rootReal - The already-canonical root the result must lie under
|
|
304
|
+
* @returns The canonical path inside `rootReal`, or `undefined`
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* await resolveContainedRealPath('/srv/public/index.html', '/srv/public')
|
|
309
|
+
* // '/srv/public/index.html'
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
async function resolveContainedRealPath(candidate, rootReal) {
|
|
313
|
+
let real;
|
|
314
|
+
try {
|
|
315
|
+
real = await realpath(candidate);
|
|
316
|
+
} catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
return isContainedPath(real, rootReal) ? real : void 0;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Checks whether a path segment is a Windows reserved device name (CVE-2025-27210).
|
|
476
323
|
*
|
|
477
324
|
* @remarks
|
|
478
325
|
* Normalizes superscript digits (`¹²³` → `123`) first, strips trailing dots
|
|
@@ -482,7 +329,7 @@ function resolveStaticPath(root, prefix, pathname) {
|
|
|
482
329
|
* `console.js` and `nullable.css` are never flagged.
|
|
483
330
|
*
|
|
484
331
|
* @param segment - One path segment (no separators)
|
|
485
|
-
* @returns
|
|
332
|
+
* @returns True if `segment` names a reserved device; false otherwise
|
|
486
333
|
*
|
|
487
334
|
* @example
|
|
488
335
|
* ```ts
|
|
@@ -497,11 +344,11 @@ function isReservedDeviceName(segment) {
|
|
|
497
344
|
return RESERVED_DEVICE_NAMES.has(stem.toUpperCase());
|
|
498
345
|
}
|
|
499
346
|
/**
|
|
500
|
-
*
|
|
347
|
+
* Checks whether a relative path (already resolved under a static root) has any
|
|
501
348
|
* segment starting with `.` — a dotfile or dot-directory.
|
|
502
349
|
*
|
|
503
350
|
* @param relativePath - A path relative to the static root
|
|
504
|
-
* @returns
|
|
351
|
+
* @returns True if any segment starts with `.`; false otherwise
|
|
505
352
|
*
|
|
506
353
|
* @example
|
|
507
354
|
* ```ts
|
|
@@ -513,7 +360,7 @@ function isDotfilePath(relativePath) {
|
|
|
513
360
|
return relativePath.split(/[/\\]+/).some((segment) => segment.startsWith("."));
|
|
514
361
|
}
|
|
515
362
|
/**
|
|
516
|
-
*
|
|
363
|
+
* Looks up the MIME type for a static file path by its extension.
|
|
517
364
|
*
|
|
518
365
|
* @param pathname - The file's path (only its extension is read)
|
|
519
366
|
* @returns The mapped MIME type, or {@link DEFAULT_CONTENT_TYPE} when unknown
|
|
@@ -527,7 +374,7 @@ function lookupContentType(pathname) {
|
|
|
527
374
|
return EXTENSION_TYPES[extname(pathname).toLowerCase()] ?? "application/octet-stream";
|
|
528
375
|
}
|
|
529
376
|
/**
|
|
530
|
-
*
|
|
377
|
+
* Computes a static file's weak ETag from its size and modification time.
|
|
531
378
|
*
|
|
532
379
|
* @param size - The file's byte size
|
|
533
380
|
* @param mtimeMs - The file's modification time in milliseconds
|
|
@@ -542,7 +389,7 @@ function computeFileETag(size, mtimeMs) {
|
|
|
542
389
|
return `W/"${size}-${Math.floor(mtimeMs)}"`;
|
|
543
390
|
}
|
|
544
391
|
/**
|
|
545
|
-
*
|
|
392
|
+
* Compresses response bytes with Node's guaranteed zlib gzip/deflate codecs.
|
|
546
393
|
*
|
|
547
394
|
* @param bytes - The uncompressed response bytes
|
|
548
395
|
* @param encoding - The negotiated actionable coding
|
|
@@ -559,10 +406,16 @@ async function compressNodeBytes(bytes, encoding) {
|
|
|
559
406
|
return Uint8Array.from(compressed);
|
|
560
407
|
}
|
|
561
408
|
/**
|
|
562
|
-
*
|
|
563
|
-
* table (jpeg, png, gif87a/89a, webp, pdf, zip)
|
|
564
|
-
*
|
|
565
|
-
*
|
|
409
|
+
* Sniffs a MIME type from a file's leading bytes against a small magic-byte
|
|
410
|
+
* table (jpeg, png, gif87a/89a, webp, pdf, zip).
|
|
411
|
+
*
|
|
412
|
+
* @remarks
|
|
413
|
+
* `createMultipart`'s `allowed` check reads this signal alone and never the
|
|
414
|
+
* declared `Content-Type`, so a file whose bytes match no signature can never
|
|
415
|
+
* be placed on an `allowed` list. The record's `mime` field is a different
|
|
416
|
+
* question: it falls back to the declared `Content-Type` (then to
|
|
417
|
+
* {@link DEFAULT_CONTENT_TYPE}) when nothing sniffs, and its `validated` flag
|
|
418
|
+
* reports whether the sniffed and declared types agreed.
|
|
566
419
|
*
|
|
567
420
|
* @param head - The file's first bytes (16 is sufficient for every signature)
|
|
568
421
|
* @returns The detected MIME type, or `undefined` when no signature matches
|
|
@@ -640,12 +493,12 @@ function detectMIME(head) {
|
|
|
640
493
|
])) return "application/zip";
|
|
641
494
|
}
|
|
642
495
|
/**
|
|
643
|
-
*
|
|
496
|
+
* Checks whether `bytes` contains `signature` at the requested offset.
|
|
644
497
|
*
|
|
645
498
|
* @param bytes - The bytes to inspect
|
|
646
499
|
* @param signature - The exact byte sequence to match
|
|
647
500
|
* @param offset - The starting byte offset, defaulting to zero
|
|
648
|
-
* @returns
|
|
501
|
+
* @returns True if the complete signature matches; false otherwise
|
|
649
502
|
*
|
|
650
503
|
* @example
|
|
651
504
|
* ```ts
|
|
@@ -658,7 +511,7 @@ function matchesBytes(bytes, signature, offset = 0) {
|
|
|
658
511
|
return true;
|
|
659
512
|
}
|
|
660
513
|
/**
|
|
661
|
-
*
|
|
514
|
+
* Extracts the `boundary` parameter from a `Content-Type` header, or
|
|
662
515
|
* `undefined` when the request is not `multipart/form-data`.
|
|
663
516
|
*
|
|
664
517
|
* @param contentType - The request's `Content-Type` header value, if present
|
|
@@ -667,11 +520,11 @@ function matchesBytes(bytes, signature, offset = 0) {
|
|
|
667
520
|
*
|
|
668
521
|
* @example
|
|
669
522
|
* ```ts
|
|
670
|
-
*
|
|
671
|
-
*
|
|
523
|
+
* extractMultipartBoundary('multipart/form-data; boundary=abc123') // 'abc123'
|
|
524
|
+
* extractMultipartBoundary('application/json') // undefined
|
|
672
525
|
* ```
|
|
673
526
|
*/
|
|
674
|
-
function
|
|
527
|
+
function extractMultipartBoundary(contentType) {
|
|
675
528
|
if (contentType === null) return void 0;
|
|
676
529
|
const [type, ...params] = contentType.split(";").map((part) => part.trim());
|
|
677
530
|
if (type === void 0 || type.toLowerCase() !== "multipart/form-data") return void 0;
|
|
@@ -685,56 +538,49 @@ function multipartBoundary(contentType) {
|
|
|
685
538
|
}
|
|
686
539
|
}
|
|
687
540
|
/**
|
|
688
|
-
*
|
|
689
|
-
* every documented default.
|
|
541
|
+
* Resolves `createMultipart`'s effective {@link MultipartLimits}, applying
|
|
542
|
+
* every documented default to an omitted leaf.
|
|
690
543
|
*
|
|
691
544
|
* @param limits - The caller's partial limits
|
|
692
545
|
* @returns The fully-resolved limits
|
|
693
|
-
*/
|
|
694
|
-
function resolveMultipartLimits(limits) {
|
|
695
|
-
return {
|
|
696
|
-
file: limits?.file ?? 10485760,
|
|
697
|
-
files: limits?.files ?? 10,
|
|
698
|
-
field: limits?.field ?? 65536,
|
|
699
|
-
fields: limits?.fields ?? 100,
|
|
700
|
-
total: limits?.total ?? 52428800
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* Resolve `parseMultipartRequest`'s default staging directory when the
|
|
705
|
-
* caller did not configure one — a process-owned directory created ONCE
|
|
706
|
-
* (lazily, memoized across calls) via `mkdtemp` under `os.tmpdir()` and
|
|
707
|
-
* locked to mode `0o700`.
|
|
708
|
-
*
|
|
709
|
-
* @returns The absolute path of the process-owned staging directory
|
|
710
546
|
*
|
|
711
547
|
* @example
|
|
712
548
|
* ```ts
|
|
713
|
-
*
|
|
549
|
+
* resolveMultipartLimits({ file: { size: 1_048_576 } })
|
|
714
550
|
* ```
|
|
715
551
|
*/
|
|
716
|
-
function
|
|
717
|
-
return
|
|
552
|
+
function resolveMultipartLimits(limits) {
|
|
553
|
+
return {
|
|
554
|
+
file: {
|
|
555
|
+
size: limits?.file?.size ?? 10485760,
|
|
556
|
+
count: limits?.file?.count ?? 10
|
|
557
|
+
},
|
|
558
|
+
field: {
|
|
559
|
+
size: limits?.field?.size ?? 65536,
|
|
560
|
+
count: limits?.field?.count ?? 100
|
|
561
|
+
},
|
|
562
|
+
total: limits?.total ?? 52428800
|
|
563
|
+
};
|
|
718
564
|
}
|
|
719
565
|
/**
|
|
720
|
-
*
|
|
566
|
+
* Parses one multipart part's raw header block into its `name` (from
|
|
721
567
|
* `Content-Disposition`), optional `filename`, and optional `Content-Type`.
|
|
722
568
|
*
|
|
723
569
|
* @param block - The raw header block for one multipart part (before the
|
|
724
570
|
* terminating blank line)
|
|
725
|
-
* @returns The parsed `name`, `filename`, and `
|
|
726
|
-
*
|
|
571
|
+
* @returns The parsed `name`, `filename`, and `mime` (each `undefined` when
|
|
572
|
+
* absent)
|
|
727
573
|
*
|
|
728
574
|
* @example
|
|
729
575
|
* ```ts
|
|
730
576
|
* parsePartHeaders('Content-Disposition: form-data; name="title"')
|
|
731
|
-
* // { name: 'title', filename: undefined,
|
|
577
|
+
* // { name: 'title', filename: undefined, mime: undefined }
|
|
732
578
|
* ```
|
|
733
579
|
*/
|
|
734
580
|
function parsePartHeaders(block) {
|
|
735
581
|
let name;
|
|
736
582
|
let filename;
|
|
737
|
-
let
|
|
583
|
+
let mime;
|
|
738
584
|
for (const line of block.split("\r\n")) {
|
|
739
585
|
const colon = line.indexOf(":");
|
|
740
586
|
if (colon === -1) continue;
|
|
@@ -745,69 +591,19 @@ function parsePartHeaders(block) {
|
|
|
745
591
|
const filenameMatch = /;\s*filename="([^"]*)"/.exec(value);
|
|
746
592
|
if (nameMatch !== null) name = nameMatch[1];
|
|
747
593
|
if (filenameMatch !== null) filename = filenameMatch[1];
|
|
748
|
-
} else if (key === "content-type")
|
|
594
|
+
} else if (key === "content-type") mime = value;
|
|
749
595
|
}
|
|
750
596
|
return {
|
|
751
597
|
name,
|
|
752
598
|
filename,
|
|
753
|
-
|
|
599
|
+
mime
|
|
754
600
|
};
|
|
755
601
|
}
|
|
756
602
|
/**
|
|
757
|
-
*
|
|
758
|
-
* the mid-stream state machine `createMultipart` drives (PROPOSAL §4.15).
|
|
759
|
-
*
|
|
760
|
-
* @remarks
|
|
761
|
-
* Reads `request.body` chunk by chunk via its `ReadableStream` reader —
|
|
762
|
-
* NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
|
|
763
|
-
* the instant it is exceeded (reading stops, every already-staged temp file
|
|
764
|
-
* is deleted, throws {@link MultipartError} with reason `'limit'`). Each file
|
|
765
|
-
* part streams to `join(directory, randomUUID())` — the client's declared
|
|
766
|
-
* filename is METADATA ONLY, never a path component. A field OR file part
|
|
767
|
-
* named `__proto__` / `constructor` / `prototype` is silently skipped and
|
|
768
|
-
* never keyed onto the returned {@link MultipartBody} (a skipped file's
|
|
769
|
-
* staged temp file is unlinked immediately, since it can never be
|
|
770
|
-
* referenced). A file part with an empty declared filename (`filename=""`)
|
|
771
|
-
* AND a zero-byte body — the browser convention for an unselected optional
|
|
772
|
-
* `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
|
|
773
|
-
* never counted against the `files` limit, and it never runs the `allowed`
|
|
774
|
-
* check. A malformed
|
|
775
|
-
* structure (missing/unterminated boundary, nameless part, an oversized
|
|
776
|
-
* header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
|
|
777
|
-
* the first boundary) throws with reason `'malformed'`. A file is accepted
|
|
778
|
-
* against the configured `allowed` MIME list iff its SNIFFED bytes detect a
|
|
779
|
-
* type present in the list — sniff-authoritative, independent of whether the
|
|
780
|
-
* declared `Content-Type` matches (that agreement is exposed separately as
|
|
781
|
-
* `validated`); otherwise throws with reason `'rejected'`. A
|
|
782
|
-
* request abort mid-upload triggers the same fail-closed cleanup as a limit
|
|
783
|
-
* breach. Returns `undefined` for a non-multipart request (untouched).
|
|
784
|
-
*
|
|
785
|
-
* @param request - The incoming multipart request
|
|
786
|
-
* @param options - See {@link MultipartOptions}
|
|
787
|
-
* @returns The parsed {@link MultipartBody}, or `undefined` when the request
|
|
788
|
-
* is not `multipart/form-data`
|
|
789
|
-
* @throws {MultipartError} On any limit breach, malformed structure, or
|
|
790
|
-
* rejected file type
|
|
791
|
-
*
|
|
792
|
-
* @example
|
|
793
|
-
* ```ts
|
|
794
|
-
* const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
|
|
795
|
-
* ```
|
|
796
|
-
*/
|
|
797
|
-
async function parseMultipartRequest(request, options = {}) {
|
|
798
|
-
const boundary = multipartBoundary(request.headers.get("content-type"));
|
|
799
|
-
if (boundary === void 0) return void 0;
|
|
800
|
-
if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
|
|
801
|
-
const limits = resolveMultipartLimits(options.limits);
|
|
802
|
-
const allowed = options.allowed;
|
|
803
|
-
const directory = options.directory ?? await resolveDefaultDirectory();
|
|
804
|
-
return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
|
|
805
|
-
}
|
|
806
|
-
/**
|
|
807
|
-
* Build a frozen {@link UploadedFileInterface} record.
|
|
603
|
+
* Builds a frozen {@link UploadedFile} record.
|
|
808
604
|
*
|
|
809
605
|
* @param input - Every field of the record
|
|
810
|
-
* @returns A frozen {@link
|
|
606
|
+
* @returns A frozen {@link UploadedFile}
|
|
811
607
|
*
|
|
812
608
|
* @example
|
|
813
609
|
* ```ts
|
|
@@ -818,7 +614,7 @@ function createUploadedFile(input) {
|
|
|
818
614
|
return Object.freeze({ ...input });
|
|
819
615
|
}
|
|
820
616
|
/**
|
|
821
|
-
*
|
|
617
|
+
* Attempts to unlink every still-`'staged'` file in a parsed
|
|
822
618
|
* {@link MultipartBody} — the fail-closed cleanup `createMultipart` runs when
|
|
823
619
|
* its downstream handler throws, mirroring `parseMultipartRequest`'s own
|
|
824
620
|
* cleanup pattern (a missing file is already gone; failures are swallowed).
|
|
@@ -840,7 +636,7 @@ async function unlinkStagedFiles(body) {
|
|
|
840
636
|
}
|
|
841
637
|
}
|
|
842
638
|
/**
|
|
843
|
-
*
|
|
639
|
+
* Adapts a `node:fs` read stream over a file path (or an already-open
|
|
844
640
|
* `FileHandle`) into a DOM-compatible `ReadableStream<Uint8Array>` — the
|
|
845
641
|
* single shared node↔web stream bridge every static-file and uploaded-file
|
|
846
642
|
* response body routes through.
|
|
@@ -851,24 +647,23 @@ async function unlinkStagedFiles(body) {
|
|
|
851
647
|
* the web `ReadableStream` invokes exactly when its internal queue has room
|
|
852
648
|
* for more data. Exactly one disk chunk is read and enqueued per `pull` —
|
|
853
649
|
* never more — so a slow or stalled consumer (a stalled HTTP connection)
|
|
854
|
-
*
|
|
650
|
+
* stops triggering `pull` calls and the source stops reading ahead;
|
|
855
651
|
* this is genuine consumer backpressure, not the "naturally backpressured"
|
|
856
652
|
* `for await`/`enqueue` pattern (which does not block on a slow consumer at
|
|
857
|
-
* all,
|
|
653
|
+
* all, because `enqueue` returns synchronously). The controller is closed on
|
|
858
654
|
* iterator completion and errored (never thrown into the process) on a
|
|
859
|
-
* mid-stream read failure. Cancelling the returned `ReadableStream` (
|
|
860
|
-
* consumer aborts the response) calls the iterator's `return()`, which
|
|
655
|
+
* mid-stream read failure. Cancelling the returned `ReadableStream` (for
|
|
656
|
+
* example the consumer aborts the response) calls the iterator's `return()`, which
|
|
861
657
|
* destroys the underlying node read stream so the file descriptor is
|
|
862
|
-
* released. When `
|
|
658
|
+
* released. When `source` is a `FileHandle`, `FileHandle.createReadStream`'s
|
|
863
659
|
* default `autoClose` closes the handle on every terminal path (end, error,
|
|
864
|
-
* or `destroy()`
|
|
865
|
-
* separate `handle.close()` for a
|
|
660
|
+
* or `destroy()` through the iterator's `return()`) — the caller never needs a
|
|
661
|
+
* separate `handle.close()` for a `FileHandle` passed as `source`.
|
|
866
662
|
*
|
|
867
663
|
* @param source - The absolute on-disk file path to stream, or an already-open
|
|
868
|
-
* `FileHandle` (
|
|
664
|
+
* `FileHandle` (for example one already `fstat`'d so the served bytes match the
|
|
869
665
|
* headers computed from that same `fstat`)
|
|
870
|
-
* @param range - An optional inclusive byte range
|
|
871
|
-
* 0-indexed and inclusive, matching `node:fs`'s `createReadStream` options)
|
|
666
|
+
* @param range - An optional inclusive byte range; see {@link ByteRange}
|
|
872
667
|
* @returns A `ReadableStream<Uint8Array>` valid as a fetch `BodyInit`
|
|
873
668
|
*
|
|
874
669
|
* @example
|
|
@@ -909,9 +704,9 @@ function streamFile(source, range) {
|
|
|
909
704
|
});
|
|
910
705
|
}
|
|
911
706
|
/**
|
|
912
|
-
*
|
|
707
|
+
* Opens a staged/moved uploaded file as a web `ReadableStream`.
|
|
913
708
|
*
|
|
914
|
-
* @param file - The {@link
|
|
709
|
+
* @param file - The {@link UploadedFile} record to stream
|
|
915
710
|
* @returns A `ReadableStream<Uint8Array>` over the file's current on-disk path
|
|
916
711
|
*
|
|
917
712
|
* @example
|
|
@@ -923,9 +718,9 @@ function streamUploadedFile(file) {
|
|
|
923
718
|
return streamFile(file.path);
|
|
924
719
|
}
|
|
925
720
|
/**
|
|
926
|
-
*
|
|
721
|
+
* Reads a staged/moved uploaded file's full contents into memory.
|
|
927
722
|
*
|
|
928
|
-
* @param file - The {@link
|
|
723
|
+
* @param file - The {@link UploadedFile} record to read
|
|
929
724
|
* @returns The file's bytes
|
|
930
725
|
*
|
|
931
726
|
* @example
|
|
@@ -937,16 +732,18 @@ async function readUploadedFile(file) {
|
|
|
937
732
|
return readFile(file.path);
|
|
938
733
|
}
|
|
939
734
|
/**
|
|
940
|
-
*
|
|
735
|
+
* Moves a staged uploaded file to its final `destination`.
|
|
941
736
|
*
|
|
942
737
|
* @remarks
|
|
943
738
|
* Attempts a `rename` first; on a cross-device error (`EXDEV`) falls back to
|
|
944
739
|
* `copyFile` + `unlink`. Returns a new frozen record with `status: 'moved'`
|
|
945
|
-
* and `path: destination` — the input record is never mutated.
|
|
740
|
+
* and `path: destination` — the input record is never mutated. The suite
|
|
741
|
+
* drives the `EXDEV` fallback only on a host whose device probe finds a
|
|
742
|
+
* second filesystem, so a single-device host leaves that branch unproven.
|
|
946
743
|
*
|
|
947
|
-
* @param file - The {@link
|
|
744
|
+
* @param file - The {@link UploadedFile} record to move
|
|
948
745
|
* @param destination - The final on-disk path
|
|
949
|
-
* @returns A new {@link
|
|
746
|
+
* @returns A new {@link UploadedFile} record reflecting the move
|
|
950
747
|
*
|
|
951
748
|
* @example
|
|
952
749
|
* ```ts
|
|
@@ -957,7 +754,7 @@ async function moveUploadedFile(file, destination) {
|
|
|
957
754
|
try {
|
|
958
755
|
await rename(file.path, destination);
|
|
959
756
|
} catch (error) {
|
|
960
|
-
if (
|
|
757
|
+
if (isError(error) && "code" in error && error.code === "EXDEV") {
|
|
961
758
|
await copyFile(file.path, destination);
|
|
962
759
|
await unlink(file.path);
|
|
963
760
|
} else throw error;
|
|
@@ -973,9 +770,267 @@ async function moveUploadedFile(file, destination) {
|
|
|
973
770
|
});
|
|
974
771
|
}
|
|
975
772
|
//#endregion
|
|
773
|
+
//#region src/server/MultipartParser.ts
|
|
774
|
+
var MultipartParser = class MultipartParser {
|
|
775
|
+
static #defaultDirectory;
|
|
776
|
+
#reader;
|
|
777
|
+
#signal;
|
|
778
|
+
#abort;
|
|
779
|
+
#boundary;
|
|
780
|
+
#limits;
|
|
781
|
+
#allowed;
|
|
782
|
+
#directory;
|
|
783
|
+
#staged = [];
|
|
784
|
+
#files = Object.create(null);
|
|
785
|
+
#fields = Object.create(null);
|
|
786
|
+
#buffer = Buffer.alloc(0);
|
|
787
|
+
#ended = false;
|
|
788
|
+
#fileCount = 0;
|
|
789
|
+
#fieldCount = 0;
|
|
790
|
+
#totalBytes = 0;
|
|
791
|
+
constructor(stream, signal, boundary, limits, allowed, directory) {
|
|
792
|
+
this.#reader = stream.getReader();
|
|
793
|
+
this.#signal = signal;
|
|
794
|
+
this.#abort = this.#wakeReader.bind(this);
|
|
795
|
+
this.#boundary = boundary;
|
|
796
|
+
this.#limits = limits;
|
|
797
|
+
this.#allowed = allowed;
|
|
798
|
+
this.#directory = directory;
|
|
799
|
+
}
|
|
800
|
+
static directory() {
|
|
801
|
+
if (MultipartParser.#defaultDirectory === void 0) MultipartParser.#defaultDirectory = MultipartParser.#createDirectory();
|
|
802
|
+
return MultipartParser.#defaultDirectory;
|
|
803
|
+
}
|
|
804
|
+
async parse() {
|
|
805
|
+
this.#signal.addEventListener("abort", this.#abort, { once: true });
|
|
806
|
+
try {
|
|
807
|
+
const openMarker = Buffer.from(`--${this.#boundary}`);
|
|
808
|
+
let preambleScanned = 0;
|
|
809
|
+
let index = this.#buffer.indexOf(openMarker);
|
|
810
|
+
while (index === -1) {
|
|
811
|
+
const carry = openMarker.length - 1;
|
|
812
|
+
if (this.#buffer.length > carry) {
|
|
813
|
+
const drop = this.#buffer.length - carry;
|
|
814
|
+
preambleScanned += drop;
|
|
815
|
+
if (preambleScanned > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
816
|
+
this.#buffer = this.#buffer.subarray(drop);
|
|
817
|
+
}
|
|
818
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "missing multipart boundary");
|
|
819
|
+
index = this.#buffer.indexOf(openMarker);
|
|
820
|
+
}
|
|
821
|
+
if (preambleScanned + index > 65536) throw new MultipartError("malformed", "multipart preamble too large");
|
|
822
|
+
this.#buffer = this.#buffer.subarray(index + openMarker.length);
|
|
823
|
+
for (;;) {
|
|
824
|
+
while (this.#buffer.length < 2) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
825
|
+
if (this.#buffer[0] === 45 && this.#buffer[1] === 45) break;
|
|
826
|
+
if (this.#buffer[0] !== 13 || this.#buffer[1] !== 10) throw new MultipartError("malformed", "malformed multipart boundary");
|
|
827
|
+
this.#buffer = this.#buffer.subarray(2);
|
|
828
|
+
let headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
829
|
+
while (headerEnd === -1) {
|
|
830
|
+
if (this.#buffer.length > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
831
|
+
if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart part headers");
|
|
832
|
+
headerEnd = this.#buffer.indexOf("\r\n\r\n");
|
|
833
|
+
}
|
|
834
|
+
if (headerEnd > 16384) throw new MultipartError("malformed", "multipart header block too large");
|
|
835
|
+
const headerBlock = this.#buffer.subarray(0, headerEnd).toString("utf8");
|
|
836
|
+
this.#buffer = this.#buffer.subarray(headerEnd + 4);
|
|
837
|
+
const { name, filename, mime } = parsePartHeaders(headerBlock);
|
|
838
|
+
if (name === void 0) throw new MultipartError("malformed", "multipart part missing name");
|
|
839
|
+
const partDelimiter = Buffer.from(`\r\n--${this.#boundary}`);
|
|
840
|
+
if (filename !== void 0) await this.#consumeFile(name, filename, mime, partDelimiter);
|
|
841
|
+
else await this.#consumeField(name, partDelimiter);
|
|
842
|
+
while (this.#buffer.length < openMarker.length) if (!await this.#pull()) throw new MultipartError("malformed", "unterminated multipart boundary");
|
|
843
|
+
this.#buffer = this.#buffer.subarray(openMarker.length);
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
await this.#cleanup();
|
|
847
|
+
await this.#reader.cancel().catch(() => {});
|
|
848
|
+
throw error;
|
|
849
|
+
} finally {
|
|
850
|
+
this.#signal.removeEventListener("abort", this.#abort);
|
|
851
|
+
if (!this.#ended) await this.#reader.cancel().catch(() => {});
|
|
852
|
+
}
|
|
853
|
+
const files = Object.create(null);
|
|
854
|
+
for (const [field, records] of Object.entries(this.#files)) files[field] = Object.freeze([...records]);
|
|
855
|
+
const fields = Object.assign(Object.create(null), this.#fields);
|
|
856
|
+
return {
|
|
857
|
+
files: Object.freeze(files),
|
|
858
|
+
fields: Object.freeze(fields)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
async #consumeFile(name, filename, mime, delimiter) {
|
|
862
|
+
if (filename !== "") {
|
|
863
|
+
this.#fileCount += 1;
|
|
864
|
+
if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
|
|
865
|
+
}
|
|
866
|
+
const path = join(this.#directory, randomUUID());
|
|
867
|
+
this.#staged.push(path);
|
|
868
|
+
const handle = await open(path, "w", 384);
|
|
869
|
+
let size = 0;
|
|
870
|
+
let head = Buffer.alloc(0);
|
|
871
|
+
try {
|
|
872
|
+
await this.#scan(delimiter, "unterminated multipart file part", async (chunk) => {
|
|
873
|
+
size += chunk.length;
|
|
874
|
+
if (size > this.#limits.file.size) throw new MultipartError("limit", "multipart file exceeds size limit");
|
|
875
|
+
if (head.length < 16) head = Buffer.concat([head, chunk.subarray(0, 16 - head.length)]);
|
|
876
|
+
await handle.write(chunk);
|
|
877
|
+
});
|
|
878
|
+
} finally {
|
|
879
|
+
await handle.close();
|
|
880
|
+
}
|
|
881
|
+
if (filename === "" && size === 0) {
|
|
882
|
+
await this.#discard(path);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (filename === "") {
|
|
886
|
+
this.#fileCount += 1;
|
|
887
|
+
if (this.#fileCount > this.#limits.file.count) throw new MultipartError("limit", "too many multipart files");
|
|
888
|
+
}
|
|
889
|
+
const detected = detectMIME(head);
|
|
890
|
+
const declared = mime ?? "application/octet-stream";
|
|
891
|
+
const validated = detected !== void 0 && detected === declared;
|
|
892
|
+
if (this.#allowed !== void 0) {
|
|
893
|
+
if (!(detected !== void 0 && this.#allowed.includes(detected))) throw new MultipartError("rejected", "multipart file failed type validation");
|
|
894
|
+
}
|
|
895
|
+
if (isDangerousKey(name)) {
|
|
896
|
+
await this.#discard(path);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const record = createUploadedFile({
|
|
900
|
+
field: name,
|
|
901
|
+
name: filename,
|
|
902
|
+
size,
|
|
903
|
+
mime: detected ?? declared,
|
|
904
|
+
validated,
|
|
905
|
+
status: "staged",
|
|
906
|
+
path
|
|
907
|
+
});
|
|
908
|
+
const existing = this.#files[name];
|
|
909
|
+
if (existing === void 0) this.#files[name] = [record];
|
|
910
|
+
else existing.push(record);
|
|
911
|
+
}
|
|
912
|
+
async #consumeField(name, delimiter) {
|
|
913
|
+
this.#fieldCount += 1;
|
|
914
|
+
if (this.#fieldCount > this.#limits.field.count) throw new MultipartError("limit", "too many multipart fields");
|
|
915
|
+
let value = Buffer.alloc(0);
|
|
916
|
+
await this.#scan(delimiter, "unterminated multipart field part", (chunk) => {
|
|
917
|
+
value = Buffer.concat([value, chunk]);
|
|
918
|
+
if (value.length > this.#limits.field.size) throw new MultipartError("limit", "multipart field exceeds size limit");
|
|
919
|
+
});
|
|
920
|
+
if (!isDangerousKey(name)) this.#fields[name] = value.toString("utf8");
|
|
921
|
+
}
|
|
922
|
+
async #scan(delimiter, unterminated, sink) {
|
|
923
|
+
for (;;) {
|
|
924
|
+
const boundaryIndex = this.#buffer.indexOf(delimiter);
|
|
925
|
+
if (boundaryIndex === -1) {
|
|
926
|
+
const safeLength = Math.max(0, this.#buffer.length - (delimiter.length - 1));
|
|
927
|
+
if (safeLength > 0) {
|
|
928
|
+
await sink(this.#buffer.subarray(0, safeLength));
|
|
929
|
+
this.#buffer = this.#buffer.subarray(safeLength);
|
|
930
|
+
}
|
|
931
|
+
if (!await this.#pull()) throw new MultipartError("malformed", unterminated);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
await sink(this.#buffer.subarray(0, boundaryIndex));
|
|
935
|
+
this.#buffer = this.#buffer.subarray(boundaryIndex + 2);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async #discard(path) {
|
|
940
|
+
try {
|
|
941
|
+
await unlink(path);
|
|
942
|
+
} catch {}
|
|
943
|
+
const index = this.#staged.indexOf(path);
|
|
944
|
+
if (index !== -1) this.#staged.splice(index, 1);
|
|
945
|
+
}
|
|
946
|
+
static async #createDirectory() {
|
|
947
|
+
const path = await mkdtemp(join(tmpdir(), "orkestrel-multipart-"));
|
|
948
|
+
await chmod(path, 448);
|
|
949
|
+
return path;
|
|
950
|
+
}
|
|
951
|
+
async #cleanup() {
|
|
952
|
+
for (const path of this.#staged) try {
|
|
953
|
+
await unlink(path);
|
|
954
|
+
} catch {}
|
|
955
|
+
}
|
|
956
|
+
async #pull() {
|
|
957
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
958
|
+
if (this.#ended) return false;
|
|
959
|
+
const { done, value } = await this.#reader.read();
|
|
960
|
+
if (this.#signal.aborted) throw new MultipartError("malformed", "request aborted mid-upload");
|
|
961
|
+
if (done) {
|
|
962
|
+
this.#ended = true;
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
this.#totalBytes += value.byteLength;
|
|
966
|
+
if (this.#totalBytes > this.#limits.total) throw new MultipartError("limit", "multipart body exceeds total limit");
|
|
967
|
+
this.#buffer = Buffer.concat([this.#buffer, Buffer.from(value.buffer, value.byteOffset, value.byteLength)]);
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
#wakeReader() {
|
|
971
|
+
this.#reader.cancel().catch(() => {});
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
//#endregion
|
|
975
|
+
//#region src/server/parsers.ts
|
|
976
|
+
/**
|
|
977
|
+
* Stream-parses a `multipart/form-data` request into its files and fields —
|
|
978
|
+
* the mid-stream state machine `createMultipart` drives.
|
|
979
|
+
*
|
|
980
|
+
* @remarks
|
|
981
|
+
* Reads `request.body` chunk by chunk through its `ReadableStream` reader —
|
|
982
|
+
* NEVER buffers the whole body — enforcing every {@link MultipartLimits} cap
|
|
983
|
+
* the instant it is exceeded (reading stops, every already-staged temp file
|
|
984
|
+
* is deleted, throws {@link MultipartError} with code `'limit'`). Each file
|
|
985
|
+
* part streams to `join(directory, randomUUID())` — the client's declared
|
|
986
|
+
* filename is METADATA ONLY, never a path component. A field OR file part
|
|
987
|
+
* named `__proto__` / `constructor` / `prototype` is silently skipped and
|
|
988
|
+
* never keyed onto the returned {@link MultipartBody} (a skipped file's
|
|
989
|
+
* staged temp file is unlinked immediately, since it can never be
|
|
990
|
+
* referenced). A file part with an empty declared filename (`filename=""`)
|
|
991
|
+
* AND a zero-byte body — the browser convention for an unselected optional
|
|
992
|
+
* `<input type="file">` — is a silent no-op: its temp file is unlinked, it is
|
|
993
|
+
* never counted against the `file.count` limit, and it never runs the
|
|
994
|
+
* `allowed` check. A malformed
|
|
995
|
+
* structure (missing/unterminated boundary, nameless part, an oversized
|
|
996
|
+
* header block, or a preamble exceeding {@link MULTIPART_MAX_PREAMBLE} before
|
|
997
|
+
* the first boundary) throws with code `'malformed'`. A file is accepted
|
|
998
|
+
* against the configured `allowed` MIME list iff its SNIFFED bytes detect a
|
|
999
|
+
* type present in the list — sniff-authoritative, independent of whether the
|
|
1000
|
+
* declared `Content-Type` matches (that agreement is exposed separately as
|
|
1001
|
+
* `validated`); otherwise throws with code `'rejected'`. A
|
|
1002
|
+
* request abort mid-upload triggers the same fail-closed cleanup as a limit
|
|
1003
|
+
* breach. Returns `undefined` for a non-multipart request (untouched).
|
|
1004
|
+
*
|
|
1005
|
+
* Staging defaults to a process-owned directory created ONCE (lazily,
|
|
1006
|
+
* memoized across calls) with `mkdtemp` under `os.tmpdir()` and locked to
|
|
1007
|
+
* mode `0o700`; `options.directory` overrides it.
|
|
1008
|
+
*
|
|
1009
|
+
* @param request - The incoming multipart request
|
|
1010
|
+
* @param options - See {@link MultipartOptions}
|
|
1011
|
+
* @returns The parsed {@link MultipartBody}, or `undefined` when the request
|
|
1012
|
+
* is not `multipart/form-data`
|
|
1013
|
+
* @throws {MultipartError} On any limit breach, malformed structure, or
|
|
1014
|
+
* rejected file type
|
|
1015
|
+
*
|
|
1016
|
+
* @example
|
|
1017
|
+
* ```ts
|
|
1018
|
+
* const body = await parseMultipartRequest(request, { allowed: ['image/png'] })
|
|
1019
|
+
* ```
|
|
1020
|
+
*/
|
|
1021
|
+
async function parseMultipartRequest(request, options = {}) {
|
|
1022
|
+
const boundary = extractMultipartBoundary(request.headers.get("content-type"));
|
|
1023
|
+
if (boundary === void 0) return void 0;
|
|
1024
|
+
if (request.body === null) throw new MultipartError("malformed", "multipart request has no body");
|
|
1025
|
+
const limits = resolveMultipartLimits(options.limits);
|
|
1026
|
+
const allowed = options.allowed;
|
|
1027
|
+
const directory = options.directory ?? await MultipartParser.directory();
|
|
1028
|
+
return new MultipartParser(request.body, request.signal, boundary, limits, allowed, directory).parse();
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
976
1031
|
//#region src/server/middlewares.ts
|
|
977
1032
|
/**
|
|
978
|
-
*
|
|
1033
|
+
* Serves validated in-memory assets with identity/Brotli negotiation.
|
|
979
1034
|
*
|
|
980
1035
|
* @remarks
|
|
981
1036
|
* Only `GET` and `HEAD` are served. `/` resolves to `index.html`. Every other
|
|
@@ -1068,8 +1123,8 @@ function createAssets(options) {
|
|
|
1068
1123
|
};
|
|
1069
1124
|
}
|
|
1070
1125
|
/**
|
|
1071
|
-
*
|
|
1072
|
-
* static-file battery
|
|
1126
|
+
* Serves static files from `options.root` over `node:fs` — the node-bound
|
|
1127
|
+
* static-file battery.
|
|
1073
1128
|
*
|
|
1074
1129
|
* @remarks
|
|
1075
1130
|
* Containment is enforced on CANONICAL paths, not merely the lexically
|
|
@@ -1084,7 +1139,7 @@ function createAssets(options) {
|
|
|
1084
1139
|
* that carries a file body), the open `FileHandle` is owned by the
|
|
1085
1140
|
* `Response` body and is released only once that body is fully read or
|
|
1086
1141
|
* cancelled — Node HTTP servers do this automatically when sending the
|
|
1087
|
-
* response, but a caller that holds an unread `Response` (
|
|
1142
|
+
* response, but a caller that holds an unread `Response` (for example in a test)
|
|
1088
1143
|
* must cancel its body to release the handle promptly.
|
|
1089
1144
|
*
|
|
1090
1145
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
@@ -1136,9 +1191,10 @@ function createStatic(options) {
|
|
|
1136
1191
|
resolvedPath = join(resolvedPath, index);
|
|
1137
1192
|
try {
|
|
1138
1193
|
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1194
|
+
const rootReal = await canonicalRootPromise;
|
|
1195
|
+
const indexReal = await resolveContainedRealPath(resolvedPath, rootReal);
|
|
1196
|
+
if (indexReal === void 0) fallbackNeeded = true;
|
|
1197
|
+
else resolvedPath = indexReal;
|
|
1142
1198
|
} catch {
|
|
1143
1199
|
fallbackNeeded = true;
|
|
1144
1200
|
}
|
|
@@ -1169,8 +1225,8 @@ function createStatic(options) {
|
|
|
1169
1225
|
let shellReal;
|
|
1170
1226
|
try {
|
|
1171
1227
|
if (canonicalRootPromise === void 0) canonicalRootPromise = realpath(root);
|
|
1172
|
-
const
|
|
1173
|
-
if (
|
|
1228
|
+
const candidate = await resolveContainedRealPath(shellPath, await canonicalRootPromise);
|
|
1229
|
+
if (candidate === void 0) return next();
|
|
1174
1230
|
shellReal = candidate;
|
|
1175
1231
|
} catch {
|
|
1176
1232
|
return next();
|
|
@@ -1181,16 +1237,20 @@ function createStatic(options) {
|
|
|
1181
1237
|
} catch {
|
|
1182
1238
|
return next();
|
|
1183
1239
|
}
|
|
1240
|
+
let shellInfo;
|
|
1184
1241
|
try {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
status: 200,
|
|
1188
|
-
headers: new Headers({ "content-type": lookupContentType(shellReal) })
|
|
1189
|
-
});
|
|
1190
|
-
} catch (error) {
|
|
1242
|
+
shellInfo = await shellHandle.stat();
|
|
1243
|
+
} catch {
|
|
1191
1244
|
await shellHandle.close().catch(() => {});
|
|
1192
|
-
|
|
1245
|
+
return next();
|
|
1246
|
+
}
|
|
1247
|
+
if (!shellInfo.isFile()) {
|
|
1248
|
+
await shellHandle.close().catch(() => {});
|
|
1249
|
+
return next();
|
|
1193
1250
|
}
|
|
1251
|
+
resolvedPath = shellReal;
|
|
1252
|
+
handle = shellHandle;
|
|
1253
|
+
info = shellInfo;
|
|
1194
1254
|
}
|
|
1195
1255
|
if (handle === void 0 || info === void 0) return next();
|
|
1196
1256
|
let streaming = false;
|
|
@@ -1257,9 +1317,9 @@ function createStatic(options) {
|
|
|
1257
1317
|
};
|
|
1258
1318
|
}
|
|
1259
1319
|
/**
|
|
1260
|
-
*
|
|
1320
|
+
* Parses a streamed `multipart/form-data` request body and stashes its
|
|
1261
1321
|
* {@link MultipartBody} on `context.state.multipart` — the node-bound
|
|
1262
|
-
* streaming multipart battery
|
|
1322
|
+
* streaming multipart battery.
|
|
1263
1323
|
*
|
|
1264
1324
|
* @remarks
|
|
1265
1325
|
* A non-multipart request passes through untouched. Consumes `request.body`
|
|
@@ -1307,20 +1367,19 @@ function createMultipart(options = {}) {
|
|
|
1307
1367
|
};
|
|
1308
1368
|
}
|
|
1309
1369
|
/**
|
|
1310
|
-
*
|
|
1311
|
-
* core face's `CompressionStream`-feature-detected `createCompression`,
|
|
1370
|
+
* Compresses response bodies through `node:zlib` — the node-bound sibling of
|
|
1371
|
+
* the core face's `CompressionStream`-feature-detected `createCompression`,
|
|
1312
1372
|
* guaranteed available on any Node runtime rather than dependent on the
|
|
1313
|
-
* WHATWG `CompressionStream` global
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
* consumer import path (ruling H).
|
|
1373
|
+
* WHATWG `CompressionStream` global. Ships as a SEPARATE package entry point
|
|
1374
|
+
* (`@orkestrel/middleware/server`) from the core face's `createCompression`,
|
|
1375
|
+
* so the shared name is unambiguous per consumer import path.
|
|
1317
1376
|
*
|
|
1318
1377
|
* @remarks
|
|
1319
|
-
* Peer-type limitation
|
|
1378
|
+
* Peer-type limitation, the same one the core face carries: the shipped
|
|
1320
1379
|
* `@orkestrel/server` `Encoding` union is `'gzip' | 'deflate' | 'identity'`
|
|
1321
1380
|
* — it does not include `'br'`, so this battery cannot honestly type or
|
|
1322
1381
|
* negotiate a guaranteed brotli coding despite `node:zlib` shipping
|
|
1323
|
-
* `brotliCompress`. It guarantees `gzip`/`deflate`
|
|
1382
|
+
* `brotliCompress`. It guarantees `gzip`/`deflate` through `node:zlib` (never
|
|
1324
1383
|
* feature-detected — always available) and negotiates only those.
|
|
1325
1384
|
*
|
|
1326
1385
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
@@ -1341,7 +1400,7 @@ function createCompression(options) {
|
|
|
1341
1400
|
if (options?.filter !== void 0 && !isFunction(options.filter)) throw new TypeError("NodeCompressionOptions.filter must be a function when provided");
|
|
1342
1401
|
const threshold = options?.threshold ?? DEFAULT_COMPRESSION_THRESHOLD;
|
|
1343
1402
|
const filter = options?.filter;
|
|
1344
|
-
const encodings =
|
|
1403
|
+
const encodings = NODE_COMPRESSION_ENCODINGS;
|
|
1345
1404
|
return async (request, context, next) => {
|
|
1346
1405
|
const response = await next();
|
|
1347
1406
|
return compressResponse(request, context, response, {
|
|
@@ -1353,6 +1412,6 @@ function createCompression(options) {
|
|
|
1353
1412
|
};
|
|
1354
1413
|
}
|
|
1355
1414
|
//#endregion
|
|
1356
|
-
export { DEFAULT_CONTENT_TYPE,
|
|
1415
|
+
export { DEFAULT_CONTENT_TYPE, DEFAULT_MULTIPART_FIELD_COUNT, DEFAULT_MULTIPART_FIELD_SIZE, DEFAULT_MULTIPART_FILE_COUNT, DEFAULT_MULTIPART_FILE_SIZE, DEFAULT_MULTIPART_TOTAL, DEFAULT_STATIC_DOTFILES, DEFAULT_STATIC_FALLBACK_EXCLUDE, DEFAULT_STATIC_INDEX, EXTENSION_TYPES, MULTIPART_ERROR_BRAND, MULTIPART_MAX_HEADER_BLOCK, MULTIPART_MAX_PREAMBLE, MULTIPART_STATUS, MultipartError, NODE_COMPRESSION_ENCODINGS, RESERVED_DEVICE_NAMES, compressNodeBytes, computeFileETag, createAssets, createCompression, createMultipart, createStatic, createUploadedFile, detectMIME, extractMultipartBoundary, isContainedPath, isDotfilePath, isMultipartError, isReservedDeviceName, isUnderPath, lookupContentType, matchesBytes, moveUploadedFile, parseMultipartRequest, parsePartHeaders, readUploadedFile, resolveContainedRealPath, resolveMultipartLimits, resolveStaticFallbackPath, resolveStaticPath, streamFile, streamUploadedFile, unlinkStagedFiles };
|
|
1357
1416
|
|
|
1358
1417
|
//# sourceMappingURL=index.js.map
|