Skip to content

Commit 7b187d9

Browse files
committed
move docs to jsdoc and rewrite the core to use buffers
1 parent 381ce1f commit 7b187d9

File tree

5 files changed

+122
-100
lines changed

5 files changed

+122
-100
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
"source.organizeImports.biome": "explicit"
1010
},
1111
"typescript.tsdk": "node_modules/typescript/lib"
12-
}
12+
}

README.md

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -49,48 +49,6 @@ const server = createServer(async (req: ReqWithBody, res) => {
4949
})
5050
```
5151

52-
## API
53-
54-
### `raw(req, res, cb)`
55-
56-
Minimal body parsing without any formatting.
57-
58-
### `text(req, res, cb)`
59-
60-
Converts request body to string.
61-
62-
### `urlencoded(req, res, cb)`
63-
64-
Parses request body using `new URLSearchParams`.
65-
66-
### `json(req, res, cb)`
67-
68-
Parses request body using `JSON.parse`.
69-
70-
### `multipart(req, res, cb)`
71-
72-
Parses request body using `multipart/form-data` content type and boundary. Supports files as well.
73-
74-
```js
75-
// curl -F "textfield=textfield" -F "someother=textfield with text" localhost:3000
76-
await multipart()(req, res, (err) => void err && console.log(err))
77-
res.end(req.body) // { textfield: ["textfield"], someother: ["textfield with text"] }
78-
```
79-
80-
### `custom(fn)(req, res, cb)`
81-
82-
Custom function for `parsec`.
83-
84-
```js
85-
// curl -d "this text must be uppercased" localhost:3000
86-
await custom(
87-
req,
88-
(d) => d.toUpperCase(),
89-
(err) => {}
90-
)
91-
res.end(req.body) // "THIS TEXT MUST BE UPPERCASED"
92-
```
93-
9452
### What is "parsec"?
9553

9654
The parsec is a unit of length used to measure large distances to astronomical objects outside the Solar System.

src/index.ts

Lines changed: 86 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import type { EventEmitter } from 'node:events'
1+
import { Buffer } from 'node:buffer'
22
import type { IncomingMessage, ServerResponse as Response } from 'node:http'
33

44
type NextFunction = (err?: any) => void
55

6-
// Extend the request object with body
6+
/**
7+
* Request extension with a body
8+
*/
79
export type ReqWithBody<T = any> = IncomingMessage & {
810
body?: T
9-
} & EventEmitter
11+
}
1012

1113
export const hasBody = (method: string) => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
1214

@@ -15,45 +17,76 @@ const defaultPayloadLimit = 104857600 // 100KB
1517
export type LimitErrorFn = (limit: number) => Error
1618

1719
export type ParserOptions = Partial<{
20+
/**
21+
* Limit payload size (in bytes)
22+
* @default '100KB'
23+
*/
1824
payloadLimit: number
25+
/**
26+
* Custom error function for payload limit
27+
*/
1928
payloadLimitErrorFn: LimitErrorFn
2029
}>
2130

2231
const defaultErrorFn: LimitErrorFn = (payloadLimit) => new Error(`Payload too large. Limit: ${payloadLimit} bytes`)
2332

2433
// Main function
2534
export const p =
26-
<T = any>(fn: (body: any) => any, payloadLimit = defaultPayloadLimit, payloadLimitErrorFn: LimitErrorFn = defaultErrorFn) =>
35+
<T = any>(
36+
fn: (body: Buffer) => void,
37+
payloadLimit = defaultPayloadLimit,
38+
payloadLimitErrorFn: LimitErrorFn = defaultErrorFn
39+
) =>
2740
async (req: ReqWithBody<T>, _res: Response, next: (err?: any) => void) => {
2841
try {
29-
let body = ''
42+
const body: Buffer[] = []
3043

3144
for await (const chunk of req) {
32-
if (body.length > payloadLimit) throw payloadLimitErrorFn(payloadLimit)
33-
body += chunk
45+
const totalSize = body.reduce((total, buffer) => total + buffer.byteLength, 0)
46+
if (totalSize > payloadLimit) throw payloadLimitErrorFn(payloadLimit)
47+
body.push(chunk as Buffer)
3448
}
3549

36-
return fn(body)
50+
return fn(Buffer.concat(body))
3751
} catch (e) {
3852
next(e)
3953
}
4054
}
4155

56+
/**
57+
* Parse payload with a custom function
58+
* @param fn
59+
*/
4260
const custom =
43-
<T = any>(fn: (body: any) => any) =>
61+
<T = any>(fn: (body: Buffer) => any) =>
4462
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
4563
if (hasBody(req.method!)) req.body = await p<T>(fn)(req, _res, next)
4664
next()
4765
}
4866

67+
/**
68+
* Parse JSON payload
69+
* @param options
70+
*/
4971
const json =
5072
({ payloadLimit, payloadLimitErrorFn }: ParserOptions = {}) =>
5173
async (req: ReqWithBody, res: Response, next: NextFunction) => {
5274
if (hasBody(req.method!)) {
53-
req.body = await p((x) => (x ? JSON.parse(x.toString()) : {}), payloadLimit, payloadLimitErrorFn)(req, res, next)
75+
req.body = await p(
76+
(x) => {
77+
const str = td.decode(x)
78+
return str ? JSON.parse(str) : {}
79+
},
80+
payloadLimit,
81+
payloadLimitErrorFn
82+
)(req, res, next)
5483
} else next()
5584
}
5685

86+
/**
87+
* Parse raw payload
88+
* @param options
89+
*/
5790
const raw =
5891
({ payloadLimit, payloadLimitErrorFn }: ParserOptions = {}) =>
5992
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
@@ -62,46 +95,54 @@ const raw =
6295
} else next()
6396
}
6497

98+
const td = new TextDecoder()
99+
/**
100+
* Stringify request payload
101+
* @param param0
102+
* @returns
103+
*/
65104
const text =
66105
({ payloadLimit, payloadLimitErrorFn }: ParserOptions = {}) =>
67106
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
68107
if (hasBody(req.method!)) {
69-
req.body = await p((x) => x.toString(), payloadLimit, payloadLimitErrorFn)(req, _res, next)
108+
req.body = await p((x) => td.decode(x), payloadLimit, payloadLimitErrorFn)(req, _res, next)
70109
} else next()
71110
}
72111

112+
/**
113+
* Parse urlencoded payload
114+
* @param options
115+
*/
73116
const urlencoded =
74117
({ payloadLimit, payloadLimitErrorFn }: ParserOptions = {}) =>
75118
async (req: ReqWithBody, _res: Response, next: NextFunction) => {
76119
if (hasBody(req.method!)) {
77120
req.body = await p(
78-
(x) => {
79-
const urlSearchParam = new URLSearchParams(x.toString())
80-
return Object.fromEntries(urlSearchParam.entries())
81-
},
121+
(x) => Object.fromEntries(new URLSearchParams(x.toString()).entries()),
82122
payloadLimit,
83123
payloadLimitErrorFn
84124
)(req, _res, next)
85125
} else next()
86126
}
87127

88128
const getBoundary = (contentType: string) => {
89-
// Extract the boundary from the Content-Type header
90129
const match = /boundary=(.+);?/.exec(contentType)
91130
return match ? `--${match[1]}` : null
92131
}
93132

94133
const defaultFileSizeLimitErrorFn: LimitErrorFn = (limit) => new Error(`File too large. Limit: ${limit} bytes`)
95134

96-
const parseMultipart = (body: string, boundary: string, { fileCountLimit, fileSizeLimit, fileSizeLimitErrorFn = defaultFileSizeLimitErrorFn }: MultipartOptions) => {
97-
// Split the body into an array of parts
135+
const parseMultipart = (
136+
body: string,
137+
boundary: string,
138+
{ fileCountLimit, fileSizeLimit, fileSizeLimitErrorFn = defaultFileSizeLimitErrorFn }: MultipartOptions
139+
) => {
98140
const parts = body.split(new RegExp(`${boundary}(--)?`)).filter((part) => !!part && /content-disposition/i.test(part))
99141
const parsedBody: Record<string, (File | string)[]> = {}
100142

101143
if (fileCountLimit && parts.length > fileCountLimit) throw new Error(`Too many files. Limit: ${fileCountLimit}`)
102144

103-
// Parse each part into a form data object
104-
// biome-ignore lint/complexity/noForEach: <explanation>
145+
// biome-ignore lint/complexity/noForEach: for...of fails
105146
parts.forEach((part) => {
106147
const [headers, ...lines] = part.split('\r\n').filter((part) => !!part)
107148
const data = lines.join('\r\n').trim()
@@ -120,28 +161,45 @@ const parseMultipart = (body: string, boundary: string, { fileCountLimit, fileSi
120161
parsedBody[name] = parsedBody[name] ? [...parsedBody[name], file] : [file]
121162
return
122163
}
123-
// This is a regular field
124164
parsedBody[name] = parsedBody[name] ? [...parsedBody[name], data] : [data]
125165
return
126166
})
127167

128168
return parsedBody
129169
}
130170
type MultipartOptions = Partial<{
171+
/**
172+
* Limit number of files
173+
*/
131174
fileCountLimit: number
175+
/**
176+
* Limit file size (in bytes)
177+
*/
132178
fileSizeLimit: number
179+
/**
180+
* Custom error function for file size limit
181+
*/
133182
fileSizeLimitErrorFn: LimitErrorFn
134183
}>
135-
184+
/**
185+
* Parse multipart form data (supports files as well)
186+
*
187+
* Does not restrict total payload size by default
188+
* @param options
189+
*/
136190
const multipart =
137-
({ payloadLimit, payloadLimitErrorFn, ...opts }: MultipartOptions & ParserOptions = {}) =>
191+
({ payloadLimit = Number.POSITIVE_INFINITY, payloadLimitErrorFn, ...opts }: MultipartOptions & ParserOptions = {}) =>
138192
async (req: ReqWithBody, res: Response, next: NextFunction) => {
139193
if (hasBody(req.method!)) {
140-
req.body = await p((x) => {
141-
const boundary = getBoundary(req.headers['content-type']!)
142-
if (boundary) return parseMultipart(x, boundary, opts)
143-
return {}
144-
}, payloadLimit, payloadLimitErrorFn)(req, res, next)
194+
req.body = await p(
195+
(x) => {
196+
const boundary = getBoundary(req.headers['content-type']!)
197+
if (boundary) return parseMultipart(td.decode(x), boundary, opts)
198+
return {}
199+
},
200+
payloadLimit,
201+
payloadLimitErrorFn
202+
)(req, res, next)
145203
next()
146204
} else next()
147205
}

0 commit comments

Comments
 (0)