Skip to content

Commit

Permalink
make req.body[name] an array
Browse files Browse the repository at this point in the history
  • Loading branch information
talentlessguy committed Sep 29, 2024
1 parent ff96362 commit ca5a7a2
Show file tree
Hide file tree
Showing 6 changed files with 106 additions and 86 deletions.
18 changes: 7 additions & 11 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
{
"npm.packageManager": "pnpm",
"editor.formatOnSave": true,
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"prettier.enable": false,
"eslint.enable": false,
"prettier.enable": false,
"editor.codeActionsOnSave": {
"source.fixAll": "always"
},
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"source.fixAll": "explicit",
"source.organizeImports.biome": "explicit"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
"typescript.tsdk": "node_modules/typescript/lib"
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Parses request body using `multipart/form-data` content type and boundary. Suppo
```js
// curl -F "textfield=textfield" -F "someother=textfield with text" localhost:3000
await multipart()(req, res, (err) => void err && console.log(err))
res.end(req.body) // { textfield: "textfield", someother: "textfield with text" }
res.end(req.body) // { textfield: ["textfield"], someother: ["textfield with text"] }
```

### `custom(fn)(req, res, cb)`
Expand Down
7 changes: 3 additions & 4 deletions bench/formidable.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { createReadStream } from 'node:fs'
// @ts-check
import { createServer } from 'node:http'
import formidable from 'formidable'
import { createReadStream } from 'node:fs'

const form = formidable({})

const server = createServer((req, res) => {
form.parse(req, (_, __, files) => {
form.parse(req, (_, fields, files) => {
// @ts-expect-error this is JS
const file = createReadStream(files.file[0].filepath)

file.pipe(res)
})
})

server.listen(3005)
server.listen(3005)
3 changes: 1 addition & 2 deletions bench/milliparsec-multipart.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ const server = createServer((req, res) => {
* @type {File}
*/
// @ts-ignore
const file = req.body.file

const file = req.body.file[0]
const stream = file.stream()

res.writeHead(200, {
Expand Down
119 changes: 61 additions & 58 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,66 +24,66 @@ const defaultErrorFn: LimitErrorFn = (limit) => `Payload too large. Limit: ${lim
// Main function
export const p =
<T = any>(fn: (body: any) => any, limit = defaultPayloadLimit, errorFn: LimitErrorFn = defaultErrorFn) =>
async (req: ReqWithBody<T>, _res: Response, next: (err?: any) => void) => {
try {
let body = ''

for await (const chunk of req) {
if (body.length > limit) throw new Error(errorFn(limit))
body += chunk
async (req: ReqWithBody<T>, _res: Response, next: (err?: any) => void) => {
try {
let body = ''

for await (const chunk of req) {
if (body.length > limit) throw new Error(errorFn(limit))
body += chunk
}

return fn(body)
} catch (e) {
next(e)
}

return fn(body)
} catch (e) {
next(e)
}
}

const custom =
<T = any>(fn: (body: any) => any) =>
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) req.body = await p<T>(fn)(req, _res, next)
next()
}
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) req.body = await p<T>(fn)(req, _res, next)
next()
}

const json =
({ limit, errorFn }: ParserOptions = {}) =>
async (req: ReqWithBody, res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => (x ? JSON.parse(x.toString()) : {}), limit, errorFn)(req, res, next)
} else next()
}
async (req: ReqWithBody, res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => (x ? JSON.parse(x.toString()) : {}), limit, errorFn)(req, res, next)
} else next()
}

const raw =
({ limit, errorFn }: ParserOptions = {}) =>
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => x, limit, errorFn)(req, _res, next)
} else next()
}
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => x, limit, errorFn)(req, _res, next)
} else next()
}

const text =
({ limit, errorFn }: ParserOptions = {}) =>
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => x.toString(), limit, errorFn)(req, _res, next)
} else next()
}
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => x.toString(), limit, errorFn)(req, _res, next)
} else next()
}

const urlencoded =
({ limit, errorFn }: ParserOptions = {}) =>
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p(
(x) => {
const urlSearchParam = new URLSearchParams(x.toString())
return Object.fromEntries(urlSearchParam.entries())
},
limit,
errorFn
)(req, _res, next)
} else next()
}
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p(
(x) => {
const urlSearchParam = new URLSearchParams(x.toString())
return Object.fromEntries(urlSearchParam.entries())
},
limit,
errorFn
)(req, _res, next)
} else next()
}

const getBoundary = (contentType: string) => {
// Extract the boundary from the Content-Type header
Expand All @@ -94,9 +94,10 @@ const getBoundary = (contentType: string) => {
const parseMultipart = (body: string, boundary: string) => {
// Split the body into an array of parts
const parts = body.split(new RegExp(`${boundary}(--)?`)).filter((part) => !!part && /content-disposition/i.test(part))
const parsedBody = {}
const parsedBody: Record<string, (File | string)[]> = {}
// Parse each part into a form data object
parts.map((part) => {
// biome-ignore lint/complexity/noForEach: <explanation>
parts.forEach((part) => {
const [headers, ...lines] = part.split('\r\n').filter((part) => !!part)
const data = lines.join('\r\n').trim()

Expand All @@ -107,31 +108,33 @@ const parseMultipart = (body: string, boundary: string) => {
const contentTypeMatch = /Content-Type: (.+)/i.exec(data)!
const fileContent = data.slice(contentTypeMatch[0].length + 2)

return Object.assign(parsedBody, {
[name]: new File([fileContent], filename[1], { type: contentTypeMatch[1] })
})
const file = new File([fileContent], filename[1], { type: contentTypeMatch[1] })

parsedBody[name] = parsedBody[name] ? [...parsedBody[name], file] : [file]
return
}
// This is a regular field
return Object.assign(parsedBody, { [name]: data })
parsedBody[name] = parsedBody[name] ? [...parsedBody[name], data] : [data]
return
})

return parsedBody
}

type MultipartOptions = Partial<{
fileCountLimit: number
fileSizeLimit: number
}>

const multipart =
(opts: MultipartOptions = {}) =>
async (req: ReqWithBody, res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => {
const boundary = getBoundary(req.headers['content-type']!)
if (boundary) return parseMultipart(x, boundary)
})(req, res, next)
} else next()
}
async (req: ReqWithBody, res: Response, next: NextFunction) => {
if (hasBody(req.method!)) {
req.body = await p((x) => {
const boundary = getBoundary(req.headers['content-type']!)
if (boundary) return parseMultipart(x, boundary)
})(req, res, next)
next()
} else next()
}

export { custom, json, raw, text, urlencoded, multipart }
43 changes: 33 additions & 10 deletions test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ test('should parse multipart body', async () => {
body: fd,
method: 'POST'
}).expect(200, {
textfield: 'textfield data\r\nwith new lines\r\nbecause this is valid',
someother: 'textfield with text'
textfield: ['textfield data\r\nwith new lines\r\nbecause this is valid'],
someother: ['textfield with text']
})
})

Expand All @@ -284,8 +284,31 @@ test('should parse multipart with boundary', async () => {
'Content-Type': 'multipart/form-data; boundary=some-boundary'
}
}).expect(200, {
textfield: 'textfield data\nwith new lines\nbecause this is valid',
someother: 'textfield with text'
textfield: ['textfield data\nwith new lines\nbecause this is valid'],
someother: ['textfield with text']
})
})

test('should parse an array of multipart values', async () => {
const server = createServer(async (req: ReqWithBody, res) => {
await multipart()(req, res, (err) => void err && console.log(err))

res.setHeader('Content-Type', 'multipart/form-data; boundary=some-boundary')

res.end(JSON.stringify(req.body))
})

const fd = new FormData()

fd.set('textfield', 'textfield data\nwith new lines\nbecause this is valid')
fd.append('textfield', 'textfield with text')

await makeFetch(server)('/', {
// probaly better to use form-data package
body: fd,
method: 'POST'
}).expect(200, {
textfield: ['textfield data\r\nwith new lines\r\nbecause this is valid', 'textfield with text'],
})
})

Expand All @@ -311,17 +334,17 @@ test('should parse multipart with files', async () => {
const fd = new FormData()
const file = new File(['hello world'], 'hello.txt', { type: 'text/plain' })
fd.set('file', file)
const server = createServer(async (req: ReqWithBody<{ file: File }>, res) => {
const server = createServer(async (req: ReqWithBody<{ file: [File] }>, res) => {
await multipart()(req, res, (err) => void err && console.log(err))

res.setHeader('Content-Type', 'multipart/form-data')

const formBuf = new Uint8Array(await file.arrayBuffer())
const buf = new Uint8Array(await (req.body?.file as File).arrayBuffer())
const buf = new Uint8Array(await (req.body!.file[0]).arrayBuffer())

assert.equal(Buffer.compare(buf, formBuf), 0)

res.end(req.body?.file.name)
res.end(req.body?.file[0].name)
})

await makeFetch(server)('/', {
Expand All @@ -342,17 +365,17 @@ test('should support multiple files', async () => {
fd.set('file1', files[0])
fd.set('file2', files[1])

const server = createServer(async (req: ReqWithBody<{ file1: File; file2: File }>, res) => {
const server = createServer(async (req: ReqWithBody<{ file1: [File]; file2: [File] }>, res) => {
await multipart()(req, res, (err) => void err && console.log(err))

res.setHeader('Content-Type', 'multipart/form-data')

const files = Object.values(req.body!)

for (const file of files) {
const buf = new Uint8Array(await file.arrayBuffer())
const buf = new Uint8Array(await file[0].arrayBuffer())
const i = files.indexOf(file)
const formBuf = new Uint8Array(await files[i].arrayBuffer())
const formBuf = new Uint8Array(await files[i][0].arrayBuffer())
assert.strictEqual(Buffer.compare(buf, formBuf), 0)
}
res.end('ok')
Expand Down

0 comments on commit ca5a7a2

Please sign in to comment.