Skip to content

Commit 0d5ff19

Browse files
committed
feat(fetch): initial FormData support
1 parent c7cbef4 commit 0d5ff19

File tree

6 files changed

+335
-27
lines changed

6 files changed

+335
-27
lines changed

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ if (nodeMajor > 16 || (nodeMajor === 16 && nodeMinor >= 5)) {
9696
module.exports.Headers = require('./lib/fetch/headers').Headers
9797
module.exports.Response = require('./lib/fetch/response').Response
9898
module.exports.Request = require('./lib/fetch/request').Request
99+
module.exports.FormData = require('./lib/fetch/formdata').FormData
99100
}
100101

101102
module.exports.request = makeDispatcher(api.request)

lib/fetch/body.js

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
const util = require('../core/util')
44
const { toWebReadable } = require('./util')
5+
const { FormData, File } = require('./formdata')
56
const { kState } = require('./symbols')
67
const { Blob } = require('buffer')
78
const { Readable } = require('stream')
8-
const { NotSupportedError } = require('../core/errors')
99
const { kBodyUsed } = require('../core/symbols')
1010
const assert = require('assert')
1111
const nodeUtil = require('util')
12+
const Dicer = require('dicer')
13+
const { pipeline: pipelinep } = require('stream/promises')
1214

1315
let ReadableStream
1416

@@ -71,12 +73,67 @@ function extractBody (object, keepalive = false) {
7173

7274
// Set source to a copy of the bytes held by object.
7375
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
74130
} else if (object instanceof Blob) {
75131
// Blob
76132

77133
// Set action to this step: read object.
78134
action = async (onNext, onError, onComplete) => {
79135
try {
136+
// TODO: object.stream() + read in parts + back-pressure?
80137
onNext(await object.arrayBuffer())
81138
onComplete()
82139
} catch (err) {
@@ -244,8 +301,115 @@ const methods = {
244301
},
245302

246303
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+
}
249413
}
250414
}
251415

lib/fetch/formdata.js

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict'
2+
3+
const { Blob } = require('buffer')
4+
5+
const { kState } = require('./symbols')
6+
7+
class File extends Blob {
8+
constructor (fileBits, fileName, options = {}) {
9+
super(fileBits)
10+
this[kState] = {
11+
filename: fileName,
12+
lastModified: options.lastModified
13+
}
14+
}
15+
16+
get filename () {
17+
return this[kState].filename
18+
}
19+
20+
get lastModified () {
21+
return this[kState].lastModified
22+
}
23+
}
24+
25+
class FormData {
26+
constructor () {
27+
this[kState] = []
28+
}
29+
30+
append (name, value, filename) {
31+
// 1. Let value be value if given; otherwise blobValue.
32+
// 2. Let entry be the result of creating an entry with
33+
// name, value, and filename if given.
34+
const entry = makeEntry(name, value, filename)
35+
36+
// 3. Append entry to this’s entry list.
37+
this[kState].push(entry)
38+
}
39+
40+
delete (name) {
41+
// The delete(name) method steps are to remove all entries whose name
42+
// is name from this’s entry list.
43+
const next = []
44+
for (const entry of this[kState]) {
45+
if (entry.name !== name) {
46+
next.push(entry)
47+
}
48+
}
49+
50+
this[kState] = next
51+
}
52+
53+
get (name) {
54+
// 1. If there is no entry whose name is name in this’s entry list,
55+
// then return null.
56+
const idx = this[kState].findIndex((entry) => entry.name === name)
57+
if (idx === -1) {
58+
return null
59+
}
60+
61+
// 2. Return the value of the first entry whose name is name from
62+
// this’s entry list.
63+
return this[kState][idx].value
64+
}
65+
66+
getAll (name) {
67+
// 1. If there is no entry whose name is name in this’s entry list,
68+
// then return the empty list.
69+
// 2. Return the values of all entries whose name is name, in order,
70+
// from this’s entry list.
71+
return this[kState]
72+
.filter((entry) => entry.name === name)
73+
.map((entry) => entry.value)
74+
}
75+
76+
has (name) {
77+
// The has(name) method steps are to return true if there is an entry
78+
// whose name is name in this’s entry list; otherwise false.
79+
return this[kState].findIndex((entry) => entry.name === name) !== -1
80+
}
81+
82+
set (name, value, filename) {
83+
// The set(name, value) and set(name, blobValue, filename) method steps
84+
// are:
85+
86+
// 1. Let value be value if given; otherwise blobValue.
87+
// 2. Let entry be the result of creating an entry with name, value, and
88+
// filename if given.
89+
const entry = makeEntry(name, value, filename)
90+
91+
// 3. If there are entries in this’s entry list whose name is name, then
92+
// replace the first such entry with entry and remove the others.
93+
this[kState] = this[kState].filter((entry) => entry.name !== name)
94+
95+
// 4. Otherwise, append entry to this’s entry list.
96+
this[kState].push(entry)
97+
}
98+
99+
* [Symbol.iterator] () {
100+
// The value pairs to iterate over are this’s entry list’s entries with
101+
// the key being the name and the value being the value.
102+
for (const { name, value } of this[kState]) {
103+
yield [name, value]
104+
}
105+
}
106+
}
107+
108+
function makeEntry (name, value, filename) {
109+
// To create an entry for name, value, and optionally a filename, run these
110+
// steps:
111+
112+
// Let entry be a new entry.
113+
const entry = {
114+
name: null,
115+
value: null
116+
}
117+
118+
// Set entry’s name to name.
119+
entry.name = name
120+
121+
// If value is a Blob object and not a File object, then set value to a new File
122+
// object, representing the same bytes, whose name attribute value is "blob".
123+
if (value instanceof Blob && !(value instanceof File)) {
124+
// TODO
125+
}
126+
127+
// If value is (now) a File object and filename is given, then set value to a
128+
// new File object, representing the same bytes, whose name attribute value is
129+
// filename.
130+
if (value instanceof File && filename) {
131+
// TODO
132+
}
133+
134+
// Set entry’s value to value.
135+
entry.value = value
136+
137+
// Return entry.
138+
return entry
139+
}
140+
141+
module.exports = { FormData, File }

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@
5353
"@sinonjs/fake-timers": "^7.0.5",
5454
"@types/node": "^15.0.2",
5555
"abort-controller": "^3.0.0",
56-
"busboy": "^0.3.1",
5756
"chai": "^4.3.4",
5857
"chai-as-promised": "^7.1.1",
5958
"chai-iterator": "^3.0.2",
@@ -96,5 +95,8 @@
9695
"compilerOptions": {
9796
"esModuleInterop": true
9897
}
98+
},
99+
"dependencies": {
100+
"dicer": "^0.3.0"
99101
}
100102
}

0 commit comments

Comments
 (0)