All files / http / unstable_formdata_decoder_stream.ts

100.00% Branches 64/64
100.00% Lines 185/185
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
 
 
x4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x4
x4
 
 
 
 
 
 
x4
x25
x25
x25
x25
x25
x26
x26
x25
x45
 
 
 
x25
x25
x25
x25
x25
x25
 
x25
x25
x44
x25
 
x4
x4
x4
 
x23
x23
x23
x92
x23
x23
x23
 
x23
 
x23
x46
x46
x46
x46
x46
x46
 
x23
x23
x23
x23
x23
x44
x44
x44
x45
x45
 
x45
 
 
x63
x63
x63
x44
x67
x67
x85
x85
x85
x85
x85
x86
x86
x85
x85
x85
x85
x86
x86
x101
 
x85
x85
x85
x85
x89
x89
x67
x72
x67
x67
x67
x44
x44
x45
x45
 
x45
 
 
x59
x59
x59
x59
x59
x59
x59
x59
x59
x59
x88
x88
x88
x88
x88
 
 
x88
x88
x118
x118
x119
x119
x119
x118
x118
x118
x118
x157
x157
x157
x157
x157
 
x118
x123
x124
x124
x372
x124
x124
x508
x127
x127
 
x145
x160
x161
x161
x161
x160
x174
x174
x174
x174
x160
x160
x160
 
x119
x119
x59
x59
x61
x193
x193
x194
x194
x61
x62
x61
x59
x59
x44
x45
x44
x59
x57
x23
 
x4
 
 
 
 
x19
x19
x19
x34
x34
x19
x95
x19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x4
x15
x15
x16
x16
x15
x16
x16
x24
x15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x4
x23
x23
x4


















































































































































































































































































































































// Copyright 2018-2026 the Deno authors. MIT license.

import { CappedDelimiterStream } from "@std/streams/unstable-capped-delimiter-stream";

/**
 * The output that is passed from a {@linkcode FormDataDecoderStream}.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 */
export interface FormDataEntry {
  /**
   * A string containing the key used for the form data entry.
   */
  name: string;
  /**
   * The value of the form data entry.
   */
  value: ReadableStream<Uint8Array>;
  /**
   * The content type of the form data entry.
   */
  contentType: string;
  /**
   * The filename of the form data entry if one was provided.
   */
  filename: string | undefined;
}

/**
 * ### Overview
 * {@linkcode FormDataDecoderStream} is a class based off the
 * [RFC 7578](https://datatracker.ietf.org/doc/html/rfc7578) spec and offers a
 * way to decode a {@linkcode FormData} in a streaming manner. Enabling one to
 * receive large amounts of information and start working with it, without
 * having it all locally in memory first.
 *
 * ### Limitations
 * Due to the nature of streaming implementations the next entry of this stream
 * won't be yielded until the `ReadableStream` value on the entry has been
 * either fully consumed or cancelled. To not handle it will result in a
 * hanging effect. Be aware that the client may send you a
 * `multipart/form-data` with entries in an undesirable order.
 *
 * @experimental **UNSTABLE**: New API, yet to be vetted.
 *
 * @example Usage
 * ```ts
 * import { assertEquals } from "@std/assert";
 * import { FormDataDecoderStream } from "@std/http/unstable-formdata-decoder-stream";
 * const request = new Request("https://example.com", {
 *   method: "POST",
 *   body: function() {
 *     const formData = new FormData();
 *     formData.append("file", "Hello World");
 *     return formData;
 *   }(),
 * });
 *
 * for await (const entry of FormDataDecoderStream.from(request)) {
 *   assertEquals(entry.name, "file");
 *   assertEquals(await new Response(entry.value).text(), "Hello World");
 * }
 * ```
 */
export class FormDataDecoderStream {
  #readable: ReadableStream<FormDataEntry>;
  /**
   * Constructs a new instance.
   *
   * @param contentType The content type of the form data.
   * @param readable The readable stream of the form data.
   */
  constructor(contentType: string, readable: ReadableStream<Uint8Array>) {
    let boundary: string | Uint8Array | undefined = contentType
      .split(";")
      .find((x) => x.trimStart().startsWith("boundary="))
      ?.split("=")[1];
    if (boundary == undefined) {
      throw TypeError("Boundary not found in contentType");
    }
    if (boundary.startsWith('"')) boundary = boundary.slice(1, -1);
    boundary = "--" + boundary;
    // Only ASCII Characters are allowed within the boundary.
    // *Presumably* if the UTF-16 length of the boundary does not match that of
    // the characters encoded then the boundary contained non-ASCII characters.
    if (
      boundary.length !==
        new TextEncoder()
          .encodeInto(
            boundary,
            boundary = new Uint8Array(boundary.length),
          )
          .read
    ) throw new SyntaxError("Boundary has invalid characters within it");
    this.#readable = ReadableStream.from(this.#handle(readable, boundary));
  }

  async *#handle(
    readable: ReadableStream<Uint8Array>,
    boundary: Uint8Array,
  ): AsyncGenerator<FormDataEntry> {
    const decoder = new TextDecoder();
    const reader = readable.pipeThrough(
      new CappedDelimiterStream({
        delimiter: Uint8Array.from([13, 10]),
        limit: 1024 * 8 - 2,
      }),
    ).getReader();

    let buffer = (await reader.read()).value?.value;
    // Ignore Preamble
    while (buffer != undefined) {
      if (
        buffer.length >= boundary.length &&
        boundary.every((x, i) => x === buffer![i])
      ) break;
      buffer = (await reader.read()).value?.value;
    }

    let defaultContentType = "text/plain; charset=UTF-8";
    while (
      buffer != undefined && buffer[boundary.length] !== 45 &&
      buffer[boundary.length + 1] !== 45
    ) {
      buffer = (await reader.read()).value?.value;
      if (buffer == undefined) throw new SyntaxError("Unexpected EOF");
      if (!buffer.length) {
        throw new SyntaxError(
          "Missing Content-Disposition header within FormData segment",
        );
      }

      // Header
      let name: string | undefined;
      let filename: string | undefined;
      let contentType = defaultContentType;
      do {
        const header = decoder.decode(buffer);
        if (header.startsWith("Content-Disposition:")) {
          const x = header
            .slice(header.indexOf(":") + 1)
            .split(";")
            .map((x) => x.trim());
          if (x.findIndex((x) => x === "form-data") === -1) {
            throw new SyntaxError("Content-Disposition was not of form-data");
          }
          name = x
            .find((x) => x.startsWith("name="))
            ?.split("=")[1];
          if (name == undefined) {
            throw new SyntaxError("Content-Disposition missing name field");
          }
          if (name.startsWith('"')) name = name.slice(1, -1);

          filename = x
            .find((x) => x.startsWith("filename="))
            ?.split("=")[1];
          if (filename != undefined && filename.startsWith('"')) {
            filename = filename.slice(1, -1);
          }
        } else if (header.startsWith("Content-Type:")) {
          contentType = header.slice(header.indexOf(":") + 1).trim();
        } // else ignore header
        buffer = (await reader.read()).value?.value;
        if (buffer == undefined) throw new SyntaxError("Unexpected EOF");
      } while (buffer.length);
      if (name == undefined) {
        throw new SyntaxError(
          "Missing Content-Disposition header within FormData segment",
        );
      }

      // Body
      const { lock, releaseLock, error } = this.#createLock();
      let pMatch = false;
      const entry = {
        contentType,
        filename,
        name,
        value: new ReadableStream({
          type: "bytes",
          autoAllocateChunkSize: 1024 * 8,
          async pull(controller) {
            const length = controller.byobRequest!.view!.byteLength;
            const buf = new Uint8Array(
              controller.byobRequest!.view!.buffer,
              controller.byobRequest!.view!.byteOffset,
              length,
            );

            let written = 0;
            while (true) {
              const v = (await reader.read()).value;
              if (v == undefined) {
                error(new SyntaxError("Unexpected EOF"));
                return controller.error(new SyntaxError("Unexpected EOF"));
              }
              if (
                v.value.length >= boundary.length &&
                boundary.every((x, i) => x === v.value[i])
              ) {
                buffer = v.value;
                releaseLock();
                controller.close();
                return controller.byobRequest!.respond(0);
              }

              if (pMatch) {
                if (length - written === 1) {
                  buf[written] = 13;
                  controller.byobRequest!.respond(written + 1);
                  controller.enqueue(Uint8Array.from([10]));
                  return controller.enqueue(v.value);
                }
                buf.set([13, 10], written);
                written += 2;
              }

              if (v.value.length + written) {
                if (v.value.length > length - written) {
                  buf.set(v.value.subarray(0, length - written), written);
                  controller.byobRequest!.respond(length);
                  controller.enqueue(v.value.subarray(length - written));
                } else {
                  buf.set(v.value, written);
                  written += v.value.length;
                  controller.byobRequest!.respond(written);
                }
                pMatch = v.match;
                return;
              }
              // Only loops if body starts with \r\n
              pMatch = v.match;
            }
          },
          async cancel() {
            do {
              buffer = (await reader.read()).value?.value;
              if (buffer == undefined) {
                return error(new SyntaxError("Unexpected EOF"));
              }
            } while (!boundary.every((x, i) => x === buffer![i]));
            releaseLock();
          },
        }),
      };
      if (entry.name === "_charset_") {
        defaultContentType = await new Response(entry.value).text();
      } else yield entry;
      await lock;
    }
  }

  #createLock<T>(): {
    lock: Promise<void>;
    releaseLock: () => void;
    error: (x: T) => void;
  } {
    let releaseLock: () => void;
    let error: (x: T) => void;
    const lock = new Promise<void>((resolve, reject) => {
      releaseLock = () => resolve();
      error = (x: T) => reject(x);
    });
    return { lock, releaseLock: releaseLock!, error: error! };
  }

  /**
   * Creates a {@linkcode ReadableStream} from a {@linkcode Request} or
   * {@linkcode Response}.
   *
   * @param request The {@linkcode Request} or {@linkcode Response} to decode.
   * @returns A {@linkcode ReadableStream} containing the decoded form data
   * entries.
   *
   * @example Usage
   * ```ts
   * import { assertEquals } from "@std/assert";
   * import { FormDataDecoderStream } from "@std/http/unstable-formdata-decoder-stream";
   * const request = new Request("https://example.com", {
   *   method: "POST",
   *   body: function() {
   *     const formData = new FormData();
   *     formData.append("file", "Hello World");
   *     return formData;
   *   }(),
   * });
   *
   * for await (const entry of FormDataDecoderStream.from(request)) {
   *   assertEquals(entry.name, "file");
   *   assertEquals(await new Response(entry.value).text(), "Hello World");
   * }
   * ```
   */
  static from(request: Request | Response): ReadableStream<FormDataEntry> {
    const contentType = request.headers.get("Content-Type");
    if (contentType == undefined) {
      throw new TypeError("Content-Type header is missing");
    }
    if (request.body == undefined) {
      throw new TypeError("Request body is missing");
    }
    return new FormDataDecoderStream(contentType, request.body).readable;
  }

  /**
   * The ReadableStream containing the decoded form data entries.
   *
   * @returns The {@linkcode ReadableStream} containing the decoded form data
   * entries.
   *
   * @example Usage
   * ```ts
   * import { assert, assertEquals } from "@std/assert";
   * import { FormDataDecoderStream } from "@std/http/unstable-formdata-decoder-stream";
   * const request = new Request("https://example.com", {
   *   method: "POST",
   *   body: function() {
   *     const formData = new FormData();
   *     formData.append("file", "Hello World");
   *     return formData;
   *   }(),
   * });
   * const contentType = request.headers.get("Content-Type");
   * assert(typeof contentType === "string");
   * const readable = request.body;
   * assert(readable != undefined);
   *
   * for await (
   *   const entry of new FormDataDecoderStream(contentType, readable).readable
   * ) {
   *   assertEquals(entry.name, "file");
   *   assertEquals(await new Response(entry.value).text(), "Hello World");
   * }
   * ```
   */
  get readable(): ReadableStream<FormDataEntry> {
    return this.#readable;
  }
}