Skip to content

Commit daa8344

Browse files
committed
feat(fetch): FormData
1 parent c7cbef4 commit daa8344

File tree

7 files changed

+345
-27
lines changed

7 files changed

+345
-27
lines changed

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ 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
100+
module.exports.File = require('./lib/fetch/file').File
99101
}
100102

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

lib/fetch/body.js

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

33
const util = require('../core/util')
44
const { toWebReadable } = require('./util')
5+
const { FormData } = require('./formdata')
6+
const { File } = require('./file')
57
const { kState } = require('./symbols')
68
const { Blob } = require('buffer')
79
const { Readable } = require('stream')
8-
const { NotSupportedError } = require('../core/errors')
910
const { kBodyUsed } = require('../core/symbols')
1011
const assert = require('assert')
1112
const nodeUtil = require('util')
13+
const Dicer = require('dicer')
14+
const { pipeline: pipelinep } = require('stream/promises')
1215

1316
let ReadableStream
1417

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

7275
// Set source to a copy of the bytes held by object.
7376
source = new Uint8Array(object)
77+
} else if (object instanceof FormData) {
78+
const boundary = '----formdata-undici-' + Math.random()
79+
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="`
80+
81+
/*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
82+
const escape = (str, filename) =>
83+
(filename ? str : str.replace(/\r?\n|\r/g, '\r\n'))
84+
.replace(/\n/g, '%0A')
85+
.replace(/\r/g, '%0D')
86+
.replace(/"/g, '%22')
87+
88+
// Set action to this step: run the multipart/form-data
89+
// encoding algorithm, with object’s entry list and UTF-8.
90+
action = async (onNext, onError, onComplete) => {
91+
try {
92+
for (const [name, value] of object) {
93+
let str
94+
if (typeof value === 'string') {
95+
str =
96+
prefix +
97+
escape(name) +
98+
`"\r\n\r\n${value.replace(/\r(?!\n)|(?<!\r)\n/g, '\r\n')}\r\n`
99+
} else {
100+
str =
101+
(prefix +
102+
escape(name) +
103+
`"; filename="${escape(value.name, 1)}"\r\n` +
104+
`Content-Type: ${
105+
value.type || 'application/octet-stream'
106+
}\r\n\r\n`,
107+
value,
108+
'\r\n')
109+
}
110+
111+
// TODO: Backpressure?
112+
onNext(Buffer.from(str))
113+
}
114+
onNext(Buffer.from(`--${boundary}--`))
115+
onComplete()
116+
} catch (err) {
117+
onError(err)
118+
}
119+
}
120+
121+
// Set source to object.
122+
source = object
123+
124+
// Set length to unclear, see html/6424 for improving this.
125+
// TODO
126+
127+
// Set Content-Type to `multipart/form-data; boundary=`,
128+
// followed by the multipart/form-data boundary string generated
129+
// by the multipart/form-data encoding algorithm.
130+
contentType = 'multipart/form-data; boundary=' + boundary
74131
} else if (object instanceof Blob) {
75132
// Blob
76133

77134
// Set action to this step: read object.
78135
action = async (onNext, onError, onComplete) => {
79136
try {
137+
// TODO: object.stream() + read in parts + back-pressure?
80138
onNext(await object.arrayBuffer())
81139
onComplete()
82140
} catch (err) {
@@ -244,8 +302,115 @@ const methods = {
244302
},
245303

246304
async formData () {
247-
// TODO: Implement.
248-
throw new NotSupportedError('formData')
305+
const contentType = this.headers.get('Content-Type')
306+
307+
// If mimeType’s essence is "multipart/form-data", then:
308+
if (/multipart\/form-data/.test(contentType)) {
309+
const entries = []
310+
311+
// 1. Parse bytes, using the value of the `boundary` parameter from
312+
// mimeType, per the rules set forth in Returning Values from Forms:
313+
// multipart/form-data. [RFC7578]
314+
const m =
315+
contentType.match(
316+
/^multipart\/form-data(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/
317+
) ?? []
318+
319+
// TODO: Try to avoid dicer.
320+
const d = new Dicer({ boundary: m[1] || m[2] }).on('part', (p) => {
321+
const chunks = []
322+
let headers
323+
p.on('header', (header) => {
324+
headers = header
325+
})
326+
.on('data', (data) => {
327+
chunks.push(data)
328+
})
329+
.on('end', () => {
330+
let name
331+
let filename
332+
333+
if (headers['content-disposition']) {
334+
const parts = headers['content-disposition']
335+
.map((header) =>
336+
header.split(';').map((x) => x.trim().split('='))
337+
)
338+
.flat()
339+
name = parts.find((part) => part[0] === 'name')?.[1]
340+
filename = parts.find((part) => part[0] === 'filename')?.[1]
341+
}
342+
343+
if (!name) {
344+
return
345+
}
346+
347+
name = name.match(/^"(.*)"$|(.*)/)[1]
348+
349+
if (filename) {
350+
// Each part whose `Content-Disposition` header contains a `filename`
351+
// parameter must be parsed into an entry whose value is a File object
352+
// whose contents are the contents of the part. The name attribute of
353+
// the File object must have the value of the `filename` parameter of
354+
// the part. The type attribute of the File object must have the value
355+
// of the `Content-Type` header of the part if the part has such header,
356+
// and `text/plain` (the default defined by [RFC7578] section 4.4)
357+
// otherwise.
358+
entries.push([
359+
name,
360+
new File(chunks, filename, {
361+
type: headers['content-type'] ?? 'text/plain'
362+
})
363+
])
364+
} else {
365+
// Each part whose `Content-Disposition` header does not contain a
366+
// `filename` parameter must be parsed into an entry whose value is the
367+
// UTF-8 decoded without BOM content of the part. This is done regardless
368+
// of the presence or the value of a `Content-Type` header and regardless
369+
// of the presence or the value of a `charset` parameter.
370+
entries.push([name, Buffer.concat(chunks).toString()])
371+
}
372+
})
373+
})
374+
await pipelinep(this.body, d)
375+
376+
// 2. If that fails for some reason, then throw a TypeError.
377+
if (entries instanceof Error) {
378+
throw Object.assign(new TypeError(), { cause: entries })
379+
}
380+
381+
// 3. Return a new FormData object, appending each entry, resulting from
382+
// the parsing operation, to entries.
383+
const formData = new FormData()
384+
for (const [name, value] of entries) {
385+
formData.append(name, value)
386+
}
387+
return formData
388+
} else if (/application\/x-www-form-urlencoded/.test(contentType)) {
389+
// Otherwise, if mimeType’s essence is "application/x-www-form-urlencoded", then:
390+
391+
// 1. Let entries be the result of parsing bytes.
392+
let entries
393+
try {
394+
entries = new URLSearchParams(await this.text())
395+
} catch (err) {
396+
entries = err
397+
}
398+
399+
// 2. If entries is failure, then throw a TypeError.
400+
if (entries instanceof Error) {
401+
throw Object.assign(new TypeError(), { cause: entries })
402+
}
403+
404+
// 3. Return a new FormData object whose entries are entries.
405+
const formData = new FormData()
406+
for (const [name, value] of entries) {
407+
formData.append(name, value)
408+
}
409+
return formData
410+
} else {
411+
// Otherwise, throw a TypeError.
412+
throw new TypeError()
413+
}
249414
}
250415
}
251416

lib/fetch/file.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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, options)
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+
module.exports = { File }

lib/fetch/formdata.js

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

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)