milliparsec 4.0.0 → 5.0.1

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,87 +1,124 @@
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
- next(e);
18
+ next === null || next === void 0 ? void 0 : 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);
15
- next();
26
+ if (hasBody(req.method))
27
+ req.body = await p(fn)(req, _res, next);
28
+ next === null || next === void 0 ? void 0 : 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
- else
23
- next();
41
+ next === null || next === void 0 ? void 0 : next();
24
42
  };
25
- const raw = () => async (req, _res, next) => {
43
+ /**
44
+ * Parse raw payload
45
+ * @param options
46
+ */
47
+ const raw = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
26
48
  if (hasBody(req.method)) {
27
- req.body = await p((x) => x)(req, _res, next);
28
- next();
49
+ req.body = await p((x) => x, payloadLimit, payloadLimitErrorFn)(req, _res, next);
29
50
  }
30
- else
31
- next();
51
+ next === null || next === void 0 ? void 0 : next();
32
52
  };
33
- const text = () => async (req, _res, next) => {
53
+ const td = new TextDecoder();
54
+ /**
55
+ * Stringify request payload
56
+ * @param param0
57
+ * @returns
58
+ */
59
+ const text = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
34
60
  if (hasBody(req.method)) {
35
- req.body = await p((x) => x.toString())(req, _res, next);
36
- next();
61
+ req.body = await p((x) => td.decode(x), payloadLimit, payloadLimitErrorFn)(req, _res, next);
37
62
  }
38
- else
39
- next();
63
+ next === null || next === void 0 ? void 0 : next();
40
64
  };
41
- const urlencoded = () => async (req, res, next) => {
65
+ /**
66
+ * Parse urlencoded payload
67
+ * @param options
68
+ */
69
+ const urlencoded = ({ payloadLimit, payloadLimitErrorFn } = {}) => async (req, _res, next) => {
42
70
  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();
71
+ req.body = await p((x) => Object.fromEntries(new URLSearchParams(x.toString()).entries()), payloadLimit, payloadLimitErrorFn)(req, _res, next);
48
72
  }
49
- else
50
- next();
73
+ next === null || next === void 0 ? void 0 : next();
51
74
  };
52
75
  const getBoundary = (contentType) => {
53
76
  const match = /boundary=(.+);?/.exec(contentType);
54
77
  return match ? `--${match[1]}` : null;
55
78
  };
56
- const parseMultipart = (body, boundary) => {
79
+ const defaultFileSizeLimitErrorFn = (limit) => new Error(`File too large. Limit: ${limit} bytes`);
80
+ const defaultFileSizeLimit = 200 * 1024 * 1024;
81
+ const parseMultipart = (body, boundary, { fileCountLimit, fileSizeLimit = defaultFileSizeLimit, fileSizeLimitErrorFn = defaultFileSizeLimitErrorFn }) => {
57
82
  const parts = body.split(new RegExp(`${boundary}(--)?`)).filter((part) => !!part && /content-disposition/i.test(part));
58
83
  const parsedBody = {};
59
- parts.map((part) => {
84
+ if (fileCountLimit && parts.length > fileCountLimit)
85
+ throw new Error(`Too many files. Limit: ${fileCountLimit}`);
86
+ // biome-ignore lint/complexity/noForEach: for...of fails
87
+ parts.forEach((part) => {
60
88
  const [headers, ...lines] = part.split('\r\n').filter((part) => !!part);
61
89
  const data = lines.join('\r\n').trim();
90
+ if (data.length > fileSizeLimit)
91
+ throw fileSizeLimitErrorFn(fileSizeLimit);
92
+ // Extract the name and filename from the headers
62
93
  const name = /name="(.+?)"/.exec(headers)[1];
63
94
  const filename = /filename="(.+?)"/.exec(headers);
64
95
  if (filename) {
65
96
  const contentTypeMatch = /Content-Type: (.+)/i.exec(data);
66
97
  const fileContent = data.slice(contentTypeMatch[0].length + 2);
67
- return Object.assign(parsedBody, {
68
- [name]: new File([fileContent], filename[1], { type: contentTypeMatch[1] })
69
- });
98
+ const file = new File([fileContent], filename[1], { type: contentTypeMatch[1] });
99
+ parsedBody[name] = parsedBody[name] ? [...parsedBody[name], file] : [file];
100
+ return;
70
101
  }
71
- return Object.assign(parsedBody, { [name]: data });
102
+ parsedBody[name] = parsedBody[name] ? [...parsedBody[name], data] : [data];
103
+ return;
72
104
  });
73
105
  return parsedBody;
74
106
  };
75
- const multipart = () => async (req, res, next) => {
107
+ /**
108
+ * Parse multipart form data (supports files as well)
109
+ *
110
+ * Does not restrict total payload size by default.
111
+ * @param options
112
+ */
113
+ const multipart = ({ payloadLimit = Number.POSITIVE_INFINITY, payloadLimitErrorFn, ...opts } = {}) => async (req, res, next) => {
76
114
  if (hasBody(req.method)) {
77
115
  req.body = await p((x) => {
78
116
  const boundary = getBoundary(req.headers['content-type']);
79
117
  if (boundary)
80
- return parseMultipart(x, boundary);
81
- })(req, res, next);
82
- next();
118
+ return parseMultipart(td.decode(x), boundary, opts);
119
+ return {};
120
+ }, payloadLimit, payloadLimitErrorFn)(req, res, next);
83
121
  }
84
- else
85
- next();
122
+ next === null || next === void 0 ? void 0 : next();
86
123
  };
87
124
  export { custom, json, raw, text, urlencoded, multipart };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "milliparsec",
3
- "version": "4.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "tiniest body parser in the universe",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
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
+ "@tinyhttp/app": "^2.4.0",
26
+ "@types/node": "^22.7.4",
26
27
  "c8": "10.1.2",
27
28
  "supertest-fetch": "^2.0.0",
28
- "tsm": "^2.3.0",
29
- "typescript": "^5.5.3",
30
- "uvu": "^0.5.6"
29
+ "tsx": "^4.19.1",
30
+ "typescript": "^5.6.2"
31
31
  },
32
32
  "files": [
33
33
  "dist"
@@ -36,9 +36,10 @@
36
36
  "provenance": true
37
37
  },
38
38
  "scripts": {
39
- "test": "uvu -r tsm",
39
+ "test": "tsx --test test.ts",
40
40
  "test:coverage": "c8 --include=src pnpm test",
41
41
  "test:report": "c8 report --reporter=text-lcov > coverage.lcov",
42
- "build": "tsc -p tsconfig.build.json"
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "check": "biome check --write"
43
44
  }
44
45
  }