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