milliparsec 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,6 @@
3
3
  <img src="logo.png" width="400px" />
4
4
  <br /><br />
5
5
 
6
- ![Vulnerabilities][vulns-badge-url]
7
6
  [![Version][v-badge-url]][npm-url] [![Coverage][cov-img]][cov-url] [![Github actions][gh-actions-img]][github-actions] [![Downloads][dl-badge-url]][npm-url]
8
7
 
9
8
  </div>
@@ -15,8 +14,7 @@ Check out [deno-libs/parsec](https://github.com/deno-libs/parsec) for Deno port.
15
14
 
16
15
  ## Features
17
16
 
18
- - built with `async` / `await`
19
- - 🛠 JSON / raw / urlencoded data support
17
+ - 🛠 JSON / raw / urlencoded / multipart support
20
18
  - 📦 tiny package size (8KB dist size)
21
19
  - 🔥 no dependencies
22
20
  - ✨ [tinyhttp](https://github.com/tinyhttp/tinyhttp) and Express support
@@ -43,7 +41,7 @@ import { createServer } from 'node:http'
43
41
  import { json } from 'milliparsec'
44
42
 
45
43
  const server = createServer(async (req: ReqWithBody, res) => {
46
- await json()(req, res, (err) => void err && console.log(err))
44
+ await json()(req, res, (err) => void err && res.end(err))
47
45
 
48
46
  res.setHeader('Content-Type', 'application/json')
49
47
 
@@ -51,67 +49,10 @@ const server = createServer(async (req: ReqWithBody, res) => {
51
49
  })
52
50
  ```
53
51
 
54
- ### Web frameworks integration
55
-
56
- #### tinyhttp
57
-
58
- ```ts
59
- import { App } from '@tinyhttp/app'
60
- import { urlencoded } from 'milliparsec'
61
-
62
- new App()
63
- .use(urlencoded())
64
- .post('/', (req, res) => void res.send(req.body))
65
- .listen(3000, () => console.log(`Started on http://localhost:3000`))
66
- ```
67
-
68
- ## API
69
-
70
- ### `raw(req, res, cb)`
71
-
72
- Minimal body parsing without any formatting.
73
-
74
- ### `text(req, res, cb)`
75
-
76
- Converts request body to string.
77
-
78
- ### `urlencoded(req, res, cb)`
79
-
80
- Parses request body using `new URLSearchParams`.
81
-
82
- ### `json(req, res, cb)`
83
-
84
- Parses request body using `JSON.parse`.
85
-
86
- ### `multipart(req, res, cb)`
87
-
88
- Parses request body using `multipart/form-data` content type and boundary. Supports files as well.
89
-
90
- ```js
91
- // curl -F "textfield=textfield" -F "someother=textfield with text" localhost:3000
92
- await multipart()(req, res, (err) => void err && console.log(err))
93
- res.end(req.body) // { textfield: "textfield", someother: "textfield with text" }
94
- ```
95
-
96
- ### `custom(fn)(req, res, cb)`
97
-
98
- Custom function for `parsec`.
99
-
100
- ```js
101
- // curl -d "this text must be uppercased" localhost:3000
102
- await custom(
103
- req,
104
- (d) => d.toUpperCase(),
105
- (err) => {}
106
- )
107
- res.end(req.body) // "THIS TEXT MUST BE UPPERCASED"
108
- ```
109
-
110
52
  ### What is "parsec"?
111
53
 
112
54
  The parsec is a unit of length used to measure large distances to astronomical objects outside the Solar System.
113
55
 
114
- [vulns-badge-url]: https://img.shields.io/snyk/vulnerabilities/npm/milliparsec.svg?style=for-the-badge&color=25608B&label=vulns
115
56
  [v-badge-url]: https://img.shields.io/npm/v/milliparsec.svg?style=for-the-badge&color=25608B&logo=npm&label=
116
57
  [npm-url]: https://www.npmjs.com/package/milliparsec
117
58
  [dl-badge-url]: https://img.shields.io/npm/dt/milliparsec?style=for-the-badge&color=25608B
package/dist/index.d.ts CHANGED
@@ -1,15 +1,71 @@
1
- import type { EventEmitter } from 'node:events';
1
+ import { Buffer } from 'node:buffer';
2
2
  import type { IncomingMessage, ServerResponse as Response } from 'node:http';
3
3
  type NextFunction = (err?: any) => void;
4
+ /**
5
+ * Request extension with a body
6
+ */
4
7
  export type ReqWithBody<T = any> = IncomingMessage & {
5
8
  body?: T;
6
- } & EventEmitter;
9
+ };
7
10
  export declare const hasBody: (method: string) => boolean;
8
- export declare const p: <T = any>(fn: (body: any) => any) => (req: ReqWithBody<T>, _res: Response, next: (err?: any) => void) => Promise<any>;
9
- declare const custom: <T = any>(fn: (body: any) => any) => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
10
- declare const json: () => (req: ReqWithBody, res: Response, next: NextFunction) => Promise<void>;
11
- declare const raw: () => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
12
- declare const text: () => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
13
- declare const urlencoded: () => (req: ReqWithBody, res: Response, next: NextFunction) => Promise<void>;
14
- declare const multipart: () => (req: ReqWithBody, res: Response, next: NextFunction) => Promise<void>;
11
+ export type LimitErrorFn = (limit: number) => Error;
12
+ export type ParserOptions = Partial<{
13
+ /**
14
+ * Limit payload size (in bytes)
15
+ * @default '100KB'
16
+ */
17
+ payloadLimit: number;
18
+ /**
19
+ * Custom error function for payload limit
20
+ */
21
+ payloadLimitErrorFn: LimitErrorFn;
22
+ }>;
23
+ export declare const p: <T = any>(fn: (body: Buffer) => void, payloadLimit?: number, payloadLimitErrorFn?: LimitErrorFn) => (req: ReqWithBody<T>, _res: Response, next: (err?: any) => void) => Promise<void>;
24
+ /**
25
+ * Parse payload with a custom function
26
+ * @param fn
27
+ */
28
+ declare const custom: <T = any>(fn: (body: Buffer) => any) => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
29
+ /**
30
+ * Parse JSON payload
31
+ * @param options
32
+ */
33
+ declare const json: ({ payloadLimit, payloadLimitErrorFn }?: ParserOptions) => (req: ReqWithBody, res: Response, next: NextFunction) => Promise<void>;
34
+ /**
35
+ * Parse raw payload
36
+ * @param options
37
+ */
38
+ declare const raw: ({ payloadLimit, payloadLimitErrorFn }?: ParserOptions) => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
39
+ /**
40
+ * Stringify request payload
41
+ * @param param0
42
+ * @returns
43
+ */
44
+ declare const text: ({ payloadLimit, payloadLimitErrorFn }?: ParserOptions) => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
45
+ /**
46
+ * Parse urlencoded payload
47
+ * @param options
48
+ */
49
+ declare const urlencoded: ({ payloadLimit, payloadLimitErrorFn }?: ParserOptions) => (req: ReqWithBody, _res: Response, next: NextFunction) => Promise<void>;
50
+ type MultipartOptions = Partial<{
51
+ /**
52
+ * Limit number of files
53
+ */
54
+ fileCountLimit: number;
55
+ /**
56
+ * Limit file size (in bytes)
57
+ */
58
+ fileSizeLimit: number;
59
+ /**
60
+ * Custom error function for file size limit
61
+ */
62
+ fileSizeLimitErrorFn: LimitErrorFn;
63
+ }>;
64
+ /**
65
+ * Parse multipart form data (supports files as well)
66
+ *
67
+ * Does not restrict total payload size by default.
68
+ * @param options
69
+ */
70
+ declare const multipart: ({ payloadLimit, payloadLimitErrorFn, ...opts }?: MultipartOptions & ParserOptions) => (req: ReqWithBody, res: Response, next: NextFunction) => Promise<void>;
15
71
  export { custom, json, raw, text, urlencoded, multipart };
package/dist/index.js CHANGED
@@ -1,50 +1,77 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  export const hasBody = (method) => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
2
- export const p = (fn) => async (req, _res, next) => {
3
+ const defaultPayloadLimit = 102400; // 100KB
4
+ const defaultErrorFn = (payloadLimit) => new Error(`Payload too large. Limit: ${payloadLimit} bytes`);
5
+ // Main function
6
+ export const p = (fn, payloadLimit = defaultPayloadLimit, payloadLimitErrorFn = defaultErrorFn) => async (req, _res, next) => {
3
7
  try {
4
- let body = '';
5
- for await (const chunk of req)
6
- body += chunk;
7
- return fn(body);
8
+ const body = [];
9
+ for await (const chunk of req) {
10
+ const totalSize = body.reduce((total, buffer) => total + buffer.byteLength, 0);
11
+ if (totalSize > payloadLimit)
12
+ throw payloadLimitErrorFn(payloadLimit);
13
+ body.push(chunk);
14
+ }
15
+ return fn(Buffer.concat(body));
8
16
  }
9
17
  catch (e) {
10
18
  next(e);
11
19
  }
12
20
  };
21
+ /**
22
+ * Parse payload with a custom function
23
+ * @param fn
24
+ */
13
25
  const custom = (fn) => async (req, _res, next) => {
14
- req.body = await p(fn)(req, _res, next);
26
+ if (hasBody(req.method))
27
+ req.body = await p(fn)(req, _res, next);
15
28
  next();
16
29
  };
17
- const json = () => async (req, res, next) => {
30
+ /**
31
+ * Parse JSON payload
32
+ * @param options
33
+ */
34
+ const json = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, res, next) => {
18
35
  if (hasBody(req.method)) {
19
- req.body = await p((x) => (x ? JSON.parse(x.toString()) : {}))(req, res, next);
20
- next();
36
+ req.body = await p((x) => {
37
+ const str = td.decode(x);
38
+ return str ? JSON.parse(str) : {};
39
+ }, payloadLimit, payloadLimitErrorFn)(req, res, next);
21
40
  }
22
41
  else
23
42
  next();
24
43
  };
25
- const raw = () => async (req, _res, next) => {
44
+ /**
45
+ * Parse raw payload
46
+ * @param options
47
+ */
48
+ const raw = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
26
49
  if (hasBody(req.method)) {
27
- req.body = await p((x) => x)(req, _res, next);
28
- next();
50
+ req.body = await p((x) => x, payloadLimit, payloadLimitErrorFn)(req, _res, next);
29
51
  }
30
52
  else
31
53
  next();
32
54
  };
33
- const text = () => async (req, _res, next) => {
55
+ const td = new TextDecoder();
56
+ /**
57
+ * Stringify request payload
58
+ * @param param0
59
+ * @returns
60
+ */
61
+ const text = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
34
62
  if (hasBody(req.method)) {
35
- req.body = await p((x) => x.toString())(req, _res, next);
36
- next();
63
+ req.body = await p((x) => td.decode(x), payloadLimit, payloadLimitErrorFn)(req, _res, next);
37
64
  }
38
65
  else
39
66
  next();
40
67
  };
41
- const urlencoded = () => async (req, res, next) => {
68
+ /**
69
+ * Parse urlencoded payload
70
+ * @param options
71
+ */
72
+ const urlencoded = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
42
73
  if (hasBody(req.method)) {
43
- req.body = await p((x) => {
44
- const urlSearchParam = new URLSearchParams(x.toString());
45
- return Object.fromEntries(urlSearchParam.entries());
46
- })(req, res, next);
47
- next();
74
+ req.body = await p((x) => Object.fromEntries(new URLSearchParams(x.toString()).entries()), payloadLimit, payloadLimitErrorFn)(req, _res, next);
48
75
  }
49
76
  else
50
77
  next();
@@ -53,33 +80,48 @@ const getBoundary = (contentType) => {
53
80
  const match = /boundary=(.+);?/.exec(contentType);
54
81
  return match ? `--${match[1]}` : null;
55
82
  };
56
- const parseMultipart = (body, boundary) => {
83
+ const defaultFileSizeLimitErrorFn = (limit) => new Error(`File too large. Limit: ${limit} bytes`);
84
+ const defaultFileSizeLimit = 200 * 1024 * 1024;
85
+ const parseMultipart = (body, boundary, { fileCountLimit, fileSizeLimit = defaultFileSizeLimit, fileSizeLimitErrorFn = defaultFileSizeLimitErrorFn }) => {
57
86
  const parts = body.split(new RegExp(`${boundary}(--)?`)).filter((part) => !!part && /content-disposition/i.test(part));
58
87
  const parsedBody = {};
59
- parts.map((part) => {
88
+ if (fileCountLimit && parts.length > fileCountLimit)
89
+ throw new Error(`Too many files. Limit: ${fileCountLimit}`);
90
+ // biome-ignore lint/complexity/noForEach: for...of fails
91
+ parts.forEach((part) => {
60
92
  const [headers, ...lines] = part.split('\r\n').filter((part) => !!part);
61
93
  const data = lines.join('\r\n').trim();
94
+ if (data.length > fileSizeLimit)
95
+ throw fileSizeLimitErrorFn(fileSizeLimit);
96
+ // Extract the name and filename from the headers
62
97
  const name = /name="(.+?)"/.exec(headers)[1];
63
98
  const filename = /filename="(.+?)"/.exec(headers);
64
99
  if (filename) {
65
100
  const contentTypeMatch = /Content-Type: (.+)/i.exec(data);
66
101
  const fileContent = data.slice(contentTypeMatch[0].length + 2);
67
- return Object.assign(parsedBody, {
68
- [name]: new File([fileContent], filename[1], { type: contentTypeMatch[1] })
69
- });
102
+ const file = new File([fileContent], filename[1], { type: contentTypeMatch[1] });
103
+ parsedBody[name] = parsedBody[name] ? [...parsedBody[name], file] : [file];
104
+ return;
70
105
  }
71
- return Object.assign(parsedBody, { [name]: data });
106
+ parsedBody[name] = parsedBody[name] ? [...parsedBody[name], data] : [data];
107
+ return;
72
108
  });
73
109
  return parsedBody;
74
110
  };
75
- const multipart = () => async (req, res, next) => {
111
+ /**
112
+ * Parse multipart form data (supports files as well)
113
+ *
114
+ * Does not restrict total payload size by default.
115
+ * @param options
116
+ */
117
+ const multipart = ({ payloadLimit = Number.POSITIVE_INFINITY, payloadLimitErrorFn, ...opts } = {}) => async (req, res, next) => {
76
118
  if (hasBody(req.method)) {
77
119
  req.body = await p((x) => {
78
120
  const boundary = getBoundary(req.headers['content-type']);
79
121
  if (boundary)
80
- return parseMultipart(x, boundary);
81
- })(req, res, next);
82
- next();
122
+ return parseMultipart(td.decode(x), boundary, opts);
123
+ return {};
124
+ }, payloadLimit, payloadLimitErrorFn)(req, res, next);
83
125
  }
84
126
  else
85
127
  next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "milliparsec",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "tiniest body parser in the universe",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,12 @@
21
21
  },
22
22
  "exports": "./dist/index.js",
23
23
  "devDependencies": {
24
- "@biomejs/biome": "1.8.3",
25
- "@types/node": "^20.14.9",
24
+ "@biomejs/biome": "1.9.3",
25
+ "@types/node": "^22.7.4",
26
26
  "c8": "10.1.2",
27
27
  "supertest-fetch": "^2.0.0",
28
- "tsm": "^2.3.0",
29
- "typescript": "^5.5.3",
30
- "uvu": "^0.5.6"
28
+ "tsx": "^4.19.1",
29
+ "typescript": "^5.6.2"
31
30
  },
32
31
  "files": [
33
32
  "dist"
@@ -36,7 +35,7 @@
36
35
  "provenance": true
37
36
  },
38
37
  "scripts": {
39
- "test": "uvu -r tsm",
38
+ "test": "tsx --test test.ts",
40
39
  "test:coverage": "c8 --include=src pnpm test",
41
40
  "test:report": "c8 report --reporter=text-lcov > coverage.lcov",
42
41
  "build": "tsc -p tsconfig.build.json"