|
2 | 2 |
|
3 | 3 | const util = require('../core/util') |
4 | 4 | const { toWebReadable } = require('./util') |
| 5 | +const { FormData, File } = require('./formdata') |
5 | 6 | const { kState } = require('./symbols') |
6 | 7 | const { Blob } = require('buffer') |
7 | 8 | const { Readable } = require('stream') |
8 | | -const { NotSupportedError } = require('../core/errors') |
9 | 9 | const { kBodyUsed } = require('../core/symbols') |
10 | 10 | const assert = require('assert') |
11 | 11 | const nodeUtil = require('util') |
| 12 | +const Dicer = require('dicer') |
| 13 | +const { pipeline: pipelinep } = require('stream/promises') |
12 | 14 |
|
13 | 15 | let ReadableStream |
14 | 16 |
|
@@ -71,12 +73,67 @@ function extractBody (object, keepalive = false) { |
71 | 73 |
|
72 | 74 | // Set source to a copy of the bytes held by object. |
73 | 75 | source = new Uint8Array(object) |
| 76 | + } else if (object instanceof FormData) { |
| 77 | + const boundary = '----formdata-undici-' + Math.random() |
| 78 | + const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="` |
| 79 | + |
| 80 | + /*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */ |
| 81 | + const escape = (str, filename) => |
| 82 | + (filename ? str : str.replace(/\r?\n|\r/g, '\r\n')) |
| 83 | + .replace(/\n/g, '%0A') |
| 84 | + .replace(/\r/g, '%0D') |
| 85 | + .replace(/"/g, '%22') |
| 86 | + |
| 87 | + // Set action to this step: run the multipart/form-data |
| 88 | + // encoding algorithm, with object’s entry list and UTF-8. |
| 89 | + action = async (onNext, onError, onComplete) => { |
| 90 | + try { |
| 91 | + for (const [name, value] of object) { |
| 92 | + let str |
| 93 | + if (typeof value === 'string') { |
| 94 | + str = |
| 95 | + prefix + |
| 96 | + escape(name) + |
| 97 | + `"\r\n\r\n${value.replace(/\r(?!\n)|(?<!\r)\n/g, '\r\n')}\r\n` |
| 98 | + } else { |
| 99 | + str = |
| 100 | + (prefix + |
| 101 | + escape(name) + |
| 102 | + `"; filename="${escape(value.name, 1)}"\r\n` + |
| 103 | + `Content-Type: ${ |
| 104 | + value.type || 'application/octet-stream' |
| 105 | + }\r\n\r\n`, |
| 106 | + value, |
| 107 | + '\r\n') |
| 108 | + } |
| 109 | + |
| 110 | + // TODO: Backpressure? |
| 111 | + onNext(Buffer.from(str)) |
| 112 | + } |
| 113 | + onNext(Buffer.from(`--${boundary}--`)) |
| 114 | + onComplete() |
| 115 | + } catch (err) { |
| 116 | + onError(err) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + // Set source to object. |
| 121 | + source = object |
| 122 | + |
| 123 | + // Set length to unclear, see html/6424 for improving this. |
| 124 | + // TODO |
| 125 | + |
| 126 | + // Set Content-Type to `multipart/form-data; boundary=`, |
| 127 | + // followed by the multipart/form-data boundary string generated |
| 128 | + // by the multipart/form-data encoding algorithm. |
| 129 | + contentType = 'multipart/form-data; boundary=' + boundary |
74 | 130 | } else if (object instanceof Blob) { |
75 | 131 | // Blob |
76 | 132 |
|
77 | 133 | // Set action to this step: read object. |
78 | 134 | action = async (onNext, onError, onComplete) => { |
79 | 135 | try { |
| 136 | + // TODO: object.stream() + read in parts + back-pressure? |
80 | 137 | onNext(await object.arrayBuffer()) |
81 | 138 | onComplete() |
82 | 139 | } catch (err) { |
@@ -244,8 +301,115 @@ const methods = { |
244 | 301 | }, |
245 | 302 |
|
246 | 303 | async formData () { |
247 | | - // TODO: Implement. |
248 | | - throw new NotSupportedError('formData') |
| 304 | + const contentType = this.headers.get('Content-Type') |
| 305 | + |
| 306 | + // If mimeType’s essence is "multipart/form-data", then: |
| 307 | + if (/multipart\/form-data/.test(contentType)) { |
| 308 | + const entries = [] |
| 309 | + |
| 310 | + // 1. Parse bytes, using the value of the `boundary` parameter from |
| 311 | + // mimeType, per the rules set forth in Returning Values from Forms: |
| 312 | + // multipart/form-data. [RFC7578] |
| 313 | + const m = |
| 314 | + contentType.match( |
| 315 | + /^multipart\/form-data(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/ |
| 316 | + ) ?? [] |
| 317 | + |
| 318 | + // TODO: Try to avoid dicer. |
| 319 | + const d = new Dicer({ boundary: m[1] || m[2] }).on('part', (p) => { |
| 320 | + const chunks = [] |
| 321 | + let headers |
| 322 | + p.on('header', (header) => { |
| 323 | + headers = header |
| 324 | + }) |
| 325 | + .on('data', (data) => { |
| 326 | + chunks.push(data) |
| 327 | + }) |
| 328 | + .on('end', () => { |
| 329 | + // Each part whose `Content-Disposition` header contains a `filename` |
| 330 | + // parameter must be parsed into an entry whose value is a File object |
| 331 | + // whose contents are the contents of the part. The name attribute of |
| 332 | + // the File object must have the value of the `filename` parameter of |
| 333 | + // the part. The type attribute of the File object must have the value |
| 334 | + // of the `Content-Type` header of the part if the part has such header, |
| 335 | + // and `text/plain` (the default defined by [RFC7578] section 4.4) |
| 336 | + // otherwise. |
| 337 | + |
| 338 | + let name |
| 339 | + let filename |
| 340 | + |
| 341 | + if (headers['content-disposition']) { |
| 342 | + const parts = headers['content-disposition'] |
| 343 | + .map((header) => |
| 344 | + header.split(';').map((x) => x.trim().split('=')) |
| 345 | + ) |
| 346 | + .flat() |
| 347 | + name = parts.find((part) => part[0] === 'name')?.[1] |
| 348 | + filename = parts.find((part) => part[0] === 'filename')?.[1] |
| 349 | + } |
| 350 | + |
| 351 | + if (!name) { |
| 352 | + return |
| 353 | + } |
| 354 | + |
| 355 | + // TODO: When should this be a Blob and when should it be a string? |
| 356 | + entries.push([ |
| 357 | + name.match(/^"(.*)"$|(.*)/)[1], |
| 358 | + filename |
| 359 | + ? new File(chunks, filename, { |
| 360 | + type: headers['content-type'] ?? 'text/plain' |
| 361 | + }) |
| 362 | + : new Blob(chunks) |
| 363 | + ]) |
| 364 | + }) |
| 365 | + }) |
| 366 | + await pipelinep(this.body, d) |
| 367 | + |
| 368 | + // Each part whose `Content-Disposition` header does not contain a |
| 369 | + // `filename` parameter must be parsed into an entry whose value is the |
| 370 | + // UTF-8 decoded without BOM content of the part. This is done regardless |
| 371 | + // of the presence or the value of a `Content-Type` header and regardless |
| 372 | + // of the presence or the value of a `charset` parameter. |
| 373 | + // TODO |
| 374 | + |
| 375 | + // 2. If that fails for some reason, then throw a TypeError. |
| 376 | + if (entries instanceof Error) { |
| 377 | + throw Object.assign(new TypeError(), { cause: entries }) |
| 378 | + } |
| 379 | + |
| 380 | + // 3. Return a new FormData object, appending each entry, resulting from |
| 381 | + // the parsing operation, to entries. |
| 382 | + const formData = new FormData() |
| 383 | + for (const [name, value] of entries) { |
| 384 | + formData.append(name, value) |
| 385 | + } |
| 386 | + return formData |
| 387 | + } else if (/application\/x-www-form-urlencoded/.test(contentType)) { |
| 388 | + // Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then: |
| 389 | + |
| 390 | + // 1. Let entries be the result of parsing bytes. |
| 391 | + let entries |
| 392 | + try { |
| 393 | + entries = new URLSearchParams(await this.text()) |
| 394 | + } catch (err) { |
| 395 | + entries = err |
| 396 | + } |
| 397 | + |
| 398 | + // 2. If entries is failure, then throw a TypeError. |
| 399 | + if (entries instanceof Error) { |
| 400 | + throw Object.assign(new TypeError(), { cause: entries }) |
| 401 | + } |
| 402 | + |
| 403 | + // 3. Return a new FormData object whose entries are entries. |
| 404 | + const formData = new FormData() |
| 405 | + for (const [name, value] of entries) { |
| 406 | + formData.append(name, value) |
| 407 | + } |
| 408 | + return formData |
| 409 | + } else { |
| 410 | + // Otherwise, throw a TypeError. |
| 411 | + throw new TypeError() |
| 412 | + } |
249 | 413 | } |
250 | 414 | } |
251 | 415 |
|
|
0 commit comments