@remix-run/multipart-parser 0.11.0

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.
@@ -0,0 +1,420 @@
1
+ import Headers from '@remix-run/headers';
2
+
3
+ import { readStream } from './read-stream.ts';
4
+ import type { SearchFunction, PartialTailSearchFunction } from './buffer-search.ts';
5
+ import { createSearch, createPartialTailSearch } from './buffer-search.ts';
6
+
7
+ /**
8
+ * The base class for errors thrown by the multipart parser.
9
+ */
10
+ export class MultipartParseError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = 'MultipartParseError';
14
+ }
15
+ }
16
+
17
+ /**
18
+ * An error thrown when the maximum allowed size of a header is exceeded.
19
+ */
20
+ export class MaxHeaderSizeExceededError extends MultipartParseError {
21
+ constructor(maxHeaderSize: number) {
22
+ super(`Multipart header size exceeds maximum allowed size of ${maxHeaderSize} bytes`);
23
+ this.name = 'MaxHeaderSizeExceededError';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * An error thrown when the maximum allowed size of a file is exceeded.
29
+ */
30
+ export class MaxFileSizeExceededError extends MultipartParseError {
31
+ constructor(maxFileSize: number) {
32
+ super(`File size exceeds maximum allowed size of ${maxFileSize} bytes`);
33
+ this.name = 'MaxFileSizeExceededError';
34
+ }
35
+ }
36
+
37
+ export interface ParseMultipartOptions {
38
+ /**
39
+ * The boundary string used to separate parts in the multipart message,
40
+ * e.g. the `boundary` parameter in the `Content-Type` header.
41
+ */
42
+ boundary: string;
43
+ /**
44
+ * The maximum allowed size of a header in bytes. If an individual part's header
45
+ * exceeds this size, a `MaxHeaderSizeExceededError` will be thrown.
46
+ *
47
+ * Default: 8 KiB
48
+ */
49
+ maxHeaderSize?: number;
50
+ /**
51
+ * The maximum allowed size of a file in bytes. If an individual part's content
52
+ * exceeds this size, a `MaxFileSizeExceededError` will be thrown.
53
+ *
54
+ * Default: 2 MiB
55
+ */
56
+ maxFileSize?: number;
57
+ }
58
+
59
+ /**
60
+ * Parse a `multipart/*` message from a buffer/iterable and yield each part as a `MultipartPart` object.
61
+ *
62
+ * Note: This is a low-level API that requires manual handling of the content and boundary. If you're
63
+ * building a web server, consider using `parseMultipartRequest(request)` instead.
64
+ *
65
+ * @param message The multipart message as a `Uint8Array` or an iterable of `Uint8Array` chunks
66
+ * @param options Options for the parser
67
+ * @return A generator that yields `MultipartPart` objects
68
+ */
69
+ export function* parseMultipart(
70
+ message: Uint8Array | Iterable<Uint8Array>,
71
+ options: ParseMultipartOptions,
72
+ ): Generator<MultipartPart, void, unknown> {
73
+ let parser = new MultipartParser(options.boundary, {
74
+ maxHeaderSize: options.maxHeaderSize,
75
+ maxFileSize: options.maxFileSize,
76
+ });
77
+
78
+ if (message instanceof Uint8Array) {
79
+ if (message.length === 0) {
80
+ return; // No data to parse
81
+ }
82
+
83
+ yield* parser.write(message);
84
+ } else {
85
+ for (let chunk of message) {
86
+ yield* parser.write(chunk);
87
+ }
88
+ }
89
+
90
+ parser.finish();
91
+ }
92
+
93
+ /**
94
+ * Parse a `multipart/*` message stream and yield each part as a `MultipartPart` object.
95
+ *
96
+ * Note: This is a low-level API that requires manual handling of the content and boundary. If you're
97
+ * building a web server, consider using `parseMultipartRequest(request)` instead.
98
+ *
99
+ * @param stream A stream containing multipart data as a `ReadableStream<Uint8Array>`
100
+ * @param options Options for the parser
101
+ * @return An async generator that yields `MultipartPart` objects
102
+ */
103
+ export async function* parseMultipartStream(
104
+ stream: ReadableStream<Uint8Array>,
105
+ options: ParseMultipartOptions,
106
+ ): AsyncGenerator<MultipartPart, void, unknown> {
107
+ let parser = new MultipartParser(options.boundary, {
108
+ maxHeaderSize: options.maxHeaderSize,
109
+ maxFileSize: options.maxFileSize,
110
+ });
111
+
112
+ for await (let chunk of readStream(stream)) {
113
+ if (chunk.length === 0) {
114
+ continue; // No data to parse
115
+ }
116
+
117
+ yield* parser.write(chunk);
118
+ }
119
+
120
+ parser.finish();
121
+ }
122
+
123
+ export type MultipartParserOptions = Omit<ParseMultipartOptions, 'boundary'>;
124
+
125
+ const MultipartParserStateStart = 0;
126
+ const MultipartParserStateAfterBoundary = 1;
127
+ const MultipartParserStateHeader = 2;
128
+ const MultipartParserStateBody = 3;
129
+ const MultipartParserStateDone = 4;
130
+
131
+ const findDoubleNewline = createSearch('\r\n\r\n');
132
+
133
+ const oneKb = 1024;
134
+ const oneMb = 1024 * oneKb;
135
+
136
+ /**
137
+ * A streaming parser for `multipart/*` HTTP messages.
138
+ */
139
+ export class MultipartParser {
140
+ readonly boundary: string;
141
+ readonly maxHeaderSize: number;
142
+ readonly maxFileSize: number;
143
+
144
+ #findOpeningBoundary: SearchFunction;
145
+ #openingBoundaryLength: number;
146
+ #findBoundary: SearchFunction;
147
+ #findPartialTailBoundary: PartialTailSearchFunction;
148
+ #boundaryLength: number;
149
+
150
+ #state = MultipartParserStateStart;
151
+ #buffer: Uint8Array | null = null;
152
+ #currentPart: MultipartPart | null = null;
153
+ #contentLength = 0;
154
+
155
+ constructor(boundary: string, options?: MultipartParserOptions) {
156
+ this.boundary = boundary;
157
+ this.maxHeaderSize = options?.maxHeaderSize ?? 8 * oneKb;
158
+ this.maxFileSize = options?.maxFileSize ?? 2 * oneMb;
159
+
160
+ this.#findOpeningBoundary = createSearch(`--${boundary}`);
161
+ this.#openingBoundaryLength = 2 + boundary.length; // length of '--' + boundary
162
+ this.#findBoundary = createSearch(`\r\n--${boundary}`);
163
+ this.#findPartialTailBoundary = createPartialTailSearch(`\r\n--${boundary}`);
164
+ this.#boundaryLength = 4 + boundary.length; // length of '\r\n--' + boundary
165
+ }
166
+
167
+ /**
168
+ * Write a chunk of data to the parser.
169
+ *
170
+ * @param chunk A chunk of data to write to the parser
171
+ * @return A generator yielding `MultipartPart` objects as they are parsed
172
+ */
173
+ *write(chunk: Uint8Array): Generator<MultipartPart, void, unknown> {
174
+ if (this.#state === MultipartParserStateDone) {
175
+ throw new MultipartParseError('Unexpected data after end of stream');
176
+ }
177
+
178
+ let index = 0;
179
+ let chunkLength = chunk.length;
180
+
181
+ if (this.#buffer !== null) {
182
+ let newChunk = new Uint8Array(this.#buffer.length + chunkLength);
183
+ newChunk.set(this.#buffer, 0);
184
+ newChunk.set(chunk, this.#buffer.length);
185
+ chunk = newChunk;
186
+ chunkLength = chunk.length;
187
+ this.#buffer = null;
188
+ }
189
+
190
+ while (true) {
191
+ if (this.#state === MultipartParserStateBody) {
192
+ if (chunkLength - index < this.#boundaryLength) {
193
+ this.#buffer = chunk.subarray(index);
194
+ break;
195
+ }
196
+
197
+ let boundaryIndex = this.#findBoundary(chunk, index);
198
+
199
+ if (boundaryIndex === -1) {
200
+ // No boundary found, but there may be a partial match at the end of the chunk.
201
+ let partialTailIndex = this.#findPartialTailBoundary(chunk);
202
+
203
+ if (partialTailIndex === -1) {
204
+ this.#append(index === 0 ? chunk : chunk.subarray(index));
205
+ } else {
206
+ this.#append(chunk.subarray(index, partialTailIndex));
207
+ this.#buffer = chunk.subarray(partialTailIndex);
208
+ }
209
+
210
+ break;
211
+ }
212
+
213
+ this.#append(chunk.subarray(index, boundaryIndex));
214
+
215
+ yield this.#currentPart!;
216
+
217
+ index = boundaryIndex + this.#boundaryLength;
218
+
219
+ this.#state = MultipartParserStateAfterBoundary;
220
+ }
221
+
222
+ if (this.#state === MultipartParserStateAfterBoundary) {
223
+ if (chunkLength - index < 2) {
224
+ this.#buffer = chunk.subarray(index);
225
+ break;
226
+ }
227
+
228
+ if (chunk[index] === 45 && chunk[index + 1] === 45) {
229
+ this.#state = MultipartParserStateDone;
230
+ break;
231
+ }
232
+
233
+ index += 2; // Skip \r\n after boundary
234
+
235
+ this.#state = MultipartParserStateHeader;
236
+ }
237
+
238
+ if (this.#state === MultipartParserStateHeader) {
239
+ if (chunkLength - index < 4) {
240
+ this.#buffer = chunk.subarray(index);
241
+ break;
242
+ }
243
+
244
+ let headerEndIndex = findDoubleNewline(chunk, index);
245
+
246
+ if (headerEndIndex === -1) {
247
+ if (chunkLength - index > this.maxHeaderSize) {
248
+ throw new MaxHeaderSizeExceededError(this.maxHeaderSize);
249
+ }
250
+
251
+ this.#buffer = chunk.subarray(index);
252
+ break;
253
+ }
254
+
255
+ if (headerEndIndex - index > this.maxHeaderSize) {
256
+ throw new MaxHeaderSizeExceededError(this.maxHeaderSize);
257
+ }
258
+
259
+ this.#currentPart = new MultipartPart(chunk.subarray(index, headerEndIndex), []);
260
+ this.#contentLength = 0;
261
+
262
+ index = headerEndIndex + 4; // Skip header + \r\n\r\n
263
+
264
+ this.#state = MultipartParserStateBody;
265
+
266
+ continue;
267
+ }
268
+
269
+ if (this.#state === MultipartParserStateStart) {
270
+ if (chunkLength < this.#openingBoundaryLength) {
271
+ this.#buffer = chunk;
272
+ break;
273
+ }
274
+
275
+ if (this.#findOpeningBoundary(chunk) !== 0) {
276
+ throw new MultipartParseError('Invalid multipart stream: missing initial boundary');
277
+ }
278
+
279
+ index = this.#openingBoundaryLength;
280
+
281
+ this.#state = MultipartParserStateAfterBoundary;
282
+ }
283
+ }
284
+ }
285
+
286
+ #append(chunk: Uint8Array): void {
287
+ if (this.#contentLength + chunk.length > this.maxFileSize) {
288
+ throw new MaxFileSizeExceededError(this.maxFileSize);
289
+ }
290
+
291
+ this.#currentPart!.content.push(chunk);
292
+ this.#contentLength += chunk.length;
293
+ }
294
+
295
+ /**
296
+ * Should be called after all data has been written to the parser.
297
+ *
298
+ * Note: This will throw if the multipart message is incomplete or
299
+ * wasn't properly terminated.
300
+ *
301
+ * @return void
302
+ */
303
+ finish(): void {
304
+ if (this.#state !== MultipartParserStateDone) {
305
+ throw new MultipartParseError('Multipart stream not finished');
306
+ }
307
+ }
308
+ }
309
+
310
+ const decoder = new TextDecoder('utf-8', { fatal: true });
311
+
312
+ /**
313
+ * A part of a `multipart/*` HTTP message.
314
+ */
315
+ export class MultipartPart {
316
+ /**
317
+ * The raw content of this part as an array of `Uint8Array` chunks.
318
+ */
319
+ readonly content: Uint8Array[];
320
+
321
+ #header: Uint8Array;
322
+ #headers?: Headers;
323
+
324
+ constructor(header: Uint8Array, content: Uint8Array[]) {
325
+ this.#header = header;
326
+ this.content = content;
327
+ }
328
+
329
+ /**
330
+ * The content of this part as an `ArrayBuffer`.
331
+ */
332
+ get arrayBuffer(): ArrayBuffer {
333
+ return this.bytes.buffer as ArrayBuffer;
334
+ }
335
+
336
+ /**
337
+ * The content of this part as a single `Uint8Array`. In `multipart/form-data` messages, this is useful
338
+ * for reading the value of files that were uploaded using `<input type="file">` fields.
339
+ */
340
+ get bytes(): Uint8Array {
341
+ let buffer = new Uint8Array(this.size);
342
+
343
+ let offset = 0;
344
+ for (let chunk of this.content) {
345
+ buffer.set(chunk, offset);
346
+ offset += chunk.length;
347
+ }
348
+
349
+ return buffer;
350
+ }
351
+
352
+ /**
353
+ * The headers associated with this part.
354
+ */
355
+ get headers(): Headers {
356
+ if (!this.#headers) {
357
+ this.#headers = new Headers(decoder.decode(this.#header));
358
+ }
359
+
360
+ return this.#headers;
361
+ }
362
+
363
+ /**
364
+ * True if this part originated from a file upload.
365
+ */
366
+ get isFile(): boolean {
367
+ return this.filename !== undefined || this.mediaType === 'application/octet-stream';
368
+ }
369
+
370
+ /**
371
+ * True if this part originated from a text input field in a form submission.
372
+ */
373
+ get isText(): boolean {
374
+ return !this.isFile;
375
+ }
376
+
377
+ /**
378
+ * The filename of the part, if it is a file upload.
379
+ */
380
+ get filename(): string | undefined {
381
+ return this.headers.contentDisposition.preferredFilename;
382
+ }
383
+
384
+ /**
385
+ * The media type of the part.
386
+ */
387
+ get mediaType(): string | undefined {
388
+ return this.headers.contentType.mediaType;
389
+ }
390
+
391
+ /**
392
+ * The name of the part, usually the `name` of the field in the `<form>` that submitted the request.
393
+ */
394
+ get name(): string | undefined {
395
+ return this.headers.contentDisposition.name;
396
+ }
397
+
398
+ /**
399
+ * The size of the content in bytes.
400
+ */
401
+ get size(): number {
402
+ let size = 0;
403
+
404
+ for (let chunk of this.content) {
405
+ size += chunk.length;
406
+ }
407
+
408
+ return size;
409
+ }
410
+
411
+ /**
412
+ * The content of this part as a string. In `multipart/form-data` messages, this is useful for
413
+ * reading the value of parts that originated from `<input type="text">` fields.
414
+ *
415
+ * Note: Do not use this for binary data, use `part.bytes` or `part.arrayBuffer` instead.
416
+ */
417
+ get text(): string {
418
+ return decoder.decode(this.bytes);
419
+ }
420
+ }
@@ -0,0 +1,15 @@
1
+ // We need this little helper for environments that do not support
2
+ // ReadableStream.prototype[Symbol.asyncIterator] yet. See #46
3
+ export async function* readStream(stream: ReadableStream<Uint8Array>): AsyncIterable<Uint8Array> {
4
+ let reader = stream.getReader();
5
+
6
+ try {
7
+ while (true) {
8
+ const { done, value } = await reader.read();
9
+ if (done) break;
10
+ yield value;
11
+ }
12
+ } finally {
13
+ reader.releaseLock();
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ // Re-export all core functionality
2
+ export type { ParseMultipartOptions, MultipartParserOptions } from './lib/multipart.ts';
3
+ export {
4
+ MultipartParseError,
5
+ MaxHeaderSizeExceededError,
6
+ MaxFileSizeExceededError,
7
+ MultipartParser,
8
+ MultipartPart,
9
+ } from './lib/multipart.ts';
10
+
11
+ export { getMultipartBoundary } from './lib/multipart-request.ts';
12
+
13
+ // Export Node.js-specific functionality
14
+ export { isMultipartRequest, parseMultipartRequest, parseMultipart, parseMultipartStream } from './lib/multipart.node.ts';
@@ -0,0 +1,16 @@
1
+ export type { ParseMultipartOptions, MultipartParserOptions } from './lib/multipart.ts';
2
+ export {
3
+ MultipartParseError,
4
+ MaxHeaderSizeExceededError,
5
+ MaxFileSizeExceededError,
6
+ parseMultipart,
7
+ parseMultipartStream,
8
+ MultipartParser,
9
+ MultipartPart,
10
+ } from './lib/multipart.ts';
11
+
12
+ export {
13
+ getMultipartBoundary,
14
+ isMultipartRequest,
15
+ parseMultipartRequest,
16
+ } from './lib/multipart-request.ts';