Skip to content

Commit 412bba4

Browse files
authored
feat: add assertBodySize util and bodyLimit middleware (#1222)
1 parent 66b4cbe commit 412bba4

File tree

8 files changed

+207
-18
lines changed

8 files changed

+207
-18
lines changed

docs/2.utils/1.request.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ icon: material-symbols-light:input
1010

1111
<!-- automd:jsdocs src="../../src/utils/body.ts" -->
1212

13+
### `assertBodySize(event, limit)`
14+
15+
Asserts that request body size is within the specified limit.
16+
17+
If body size exceeds the limit, throws a `413` Request Entity Too Large response error.
18+
19+
**Example:**
20+
21+
```ts
22+
app.get("/", async (event) => {
23+
await assertBodySize(event, 10 * 1024 * 1024); // 10MB
24+
const data = await event.req.formData();
25+
});
26+
```
27+
1328
### `readBody(event)`
1429

1530
Reads request body and tries to parse using JSON.parse or URLSearchParams.
@@ -31,22 +46,23 @@ You can use a simple function to validate the body or use a Standard-Schema comp
3146
**Example:**
3247

3348
```ts
49+
function validateBody(body: any) {
50+
return typeof body === "object" && body !== null;
51+
}
3452
app.get("/", async (event) => {
35-
const body = await readValidatedBody(event, (body) => {
36-
return typeof body === "object" && body !== null;
37-
});
53+
const body = await readValidatedBody(event, validateBody);
3854
});
3955
```
4056

4157
**Example:**
4258

4359
```ts
4460
import { z } from "zod";
61+
const objectSchema = z.object({
62+
name: z.string().min(3).max(20),
63+
age: z.number({ coerce: true }).positive().int(),
64+
});
4565
app.get("/", async (event) => {
46-
const objectSchema = z.object({
47-
name: z.string().min(3).max(20),
48-
age: z.number({ coerce: true }).positive().int(),
49-
});
5066
const body = await readValidatedBody(event, objectSchema);
5167
});
5268
```

docs/2.utils/9.more.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ Checks if the input is an object with `{ req: Request }` signature.
4949

5050
<!-- automd:jsdocs src="../../src/utils/middleware.ts" -->
5151

52+
### `bodyLimit(limit)`
53+
54+
Define a middleware that checks whether request body size is within specified limit.
55+
56+
If body size exceeds the limit, throws a `413` Request Entity Too Large response error. If you need custom handling for this case, use `assertBodySize` instead.
57+
5258
### `onError(hook)`
5359

5460
Define a middleware that runs when an error occurs.

pnpm-workspace.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
packages:
2-
- "playground"
3-
- "examples"
2+
- playground
3+
- examples
4+
5+
ignoredBuiltDependencies:
6+
- esbuild

src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,12 @@ export {
115115

116116
// Middleware
117117

118-
export { onError, onRequest, onResponse } from "./utils/middleware.ts";
118+
export {
119+
onError,
120+
onRequest,
121+
onResponse,
122+
bodyLimit,
123+
} from "./utils/middleware.ts";
119124

120125
// Proxy
121126

@@ -129,7 +134,7 @@ export {
129134

130135
// Body
131136

132-
export { readBody, readValidatedBody } from "./utils/body.ts";
137+
export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts";
133138

134139
// Cookie
135140

src/utils/body.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,22 @@ export async function readValidatedBody<
6969
* You can use a simple function to validate the body or use a Standard-Schema compatible library like `zod` to define a schema.
7070
*
7171
* @example
72+
* function validateBody(body: any) {
73+
* return typeof body === "object" && body !== null;
74+
* }
75+
*
7276
* app.get("/", async (event) => {
73-
* const body = await readValidatedBody(event, (body) => {
74-
* return typeof body === "object" && body !== null;
75-
* });
77+
* const body = await readValidatedBody(event, validateBody);
7678
* });
7779
* @example
7880
* import { z } from "zod";
7981
*
82+
* const objectSchema = z.object({
83+
* name: z.string().min(3).max(20),
84+
* age: z.number({ coerce: true }).positive().int(),
85+
* });
86+
*
8087
* app.get("/", async (event) => {
81-
* const objectSchema = z.object({
82-
* name: z.string().min(3).max(20),
83-
* age: z.number({ coerce: true }).positive().int(),
84-
* });
8588
* const body = await readValidatedBody(event, objectSchema);
8689
* });
8790
*
@@ -98,3 +101,60 @@ export async function readValidatedBody(
98101
const _body = await readBody(event);
99102
return validateData(_body, validate);
100103
}
104+
105+
/**
106+
* Asserts that request body size is within the specified limit.
107+
*
108+
* If body size exceeds the limit, throws a `413` Request Entity Too Large response error.
109+
*
110+
* @example
111+
* app.get("/", async (event) => {
112+
* await assertBodySize(event, 10 * 1024 * 1024); // 10MB
113+
* const data = await event.req.formData();
114+
* });
115+
*
116+
* @param event HTTP event
117+
* @param limit Body size limit in bytes
118+
*/
119+
export async function assertBodySize(
120+
event: HTTPEvent,
121+
limit: number,
122+
): Promise<void> {
123+
const isWithin = await isBodySizeWithin(event, limit);
124+
if (!isWithin) {
125+
throw new HTTPError({
126+
status: 413,
127+
statusText: "Request Entity Too Large",
128+
message: `Request body size exceeds the limit of ${limit} bytes`,
129+
});
130+
}
131+
}
132+
133+
// Internal util for now. We can export later if needed
134+
async function isBodySizeWithin(
135+
event: HTTPEvent,
136+
limit: number,
137+
): Promise<boolean> {
138+
const req = event.req;
139+
if (req.body === null) {
140+
return true;
141+
}
142+
143+
const bodyLen = req.headers.get("content-length");
144+
if (bodyLen !== null && !req.headers.has("transfer-encoding")) {
145+
return +bodyLen <= limit;
146+
}
147+
148+
const reader = req.clone().body!.getReader();
149+
let chunk = await reader.read();
150+
let size = 0;
151+
while (!chunk.done) {
152+
size += chunk.value.byteLength;
153+
if (size > limit) {
154+
return false;
155+
}
156+
chunk = await reader.read();
157+
}
158+
159+
return true;
160+
}

src/utils/middleware.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { MaybePromise } from "../types/_utils.ts";
44

55
import type { H3Event } from "../event.ts";
66
import type { Middleware } from "../types/handler.ts";
7+
import { assertBodySize } from "./body.ts";
78

89
/**
910
* Define a middleware that runs on each request.
@@ -63,3 +64,19 @@ export function onError(
6364
}
6465
};
6566
}
67+
68+
/**
69+
* Define a middleware that checks whether request body size is within specified limit.
70+
*
71+
* If body size exceeds the limit, throws a `413` Request Entity Too Large response error.
72+
* If you need custom handling for this case, use `assertBodySize` instead.
73+
*
74+
* @param limit Body size limit in bytes
75+
* @see {assertBodySize}
76+
*/
77+
export function bodyLimit(limit: number): Middleware {
78+
return async (event, next) => {
79+
await assertBodySize(event, limit);
80+
return next();
81+
};
82+
}

test/unit/body-limit.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { expect, it, describe } from "vitest";
2+
import { mockEvent, assertBodySize, HTTPError } from "../../src/index.ts";
3+
4+
describe("body limit (unit)", () => {
5+
const streamBytesFrom = (it: Iterable<any, any>) =>
6+
new ReadableStream({
7+
start(c) {
8+
for (const part of it) c.enqueue(part);
9+
c.close();
10+
},
11+
}).pipeThrough(new TextEncoderStream());
12+
13+
describe("assertBodySize", () => {
14+
it("buffered body", async () => {
15+
const BODY = "a small request body";
16+
17+
const eventMock = mockEvent("/", {
18+
method: "POST",
19+
body: BODY,
20+
});
21+
22+
await expect(
23+
assertBodySize(eventMock, BODY.length),
24+
).resolves.toBeUndefined();
25+
await expect(
26+
assertBodySize(eventMock, BODY.length + 10),
27+
).resolves.toBeUndefined();
28+
await expect(assertBodySize(eventMock, BODY.length - 2)).rejects.toThrow(
29+
HTTPError,
30+
);
31+
});
32+
33+
it("streaming body", async () => {
34+
const BODY_PARTS = [
35+
"parts",
36+
"of",
37+
"the",
38+
"body",
39+
"that",
40+
"are",
41+
"streamed",
42+
"in",
43+
];
44+
45+
const eventMock = mockEvent("/", {
46+
method: "POST",
47+
body: streamBytesFrom(BODY_PARTS),
48+
});
49+
50+
await expect(assertBodySize(eventMock, 100)).resolves.toBeUndefined();
51+
await expect(assertBodySize(eventMock, 10)).rejects.toThrow(HTTPError);
52+
});
53+
54+
it("streaming body with content-length header", async () => {
55+
const BODY_PARTS = [
56+
"parts",
57+
"of",
58+
"the",
59+
"body",
60+
"that",
61+
"are",
62+
"streamed",
63+
"in",
64+
];
65+
66+
const eventMock = mockEvent("/", {
67+
method: "POST",
68+
body: streamBytesFrom(BODY_PARTS),
69+
headers: {
70+
// Should ignore content-length
71+
"content-length": "7",
72+
"transfer-encoding": "chunked",
73+
},
74+
});
75+
76+
await expect(assertBodySize(eventMock, 100)).resolves.toBeUndefined();
77+
await expect(assertBodySize(eventMock, 10)).rejects.toThrow(HTTPError);
78+
});
79+
});
80+
});

test/unit/package.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ describe("h3 package", () => {
1919
"appendHeaders",
2020
"appendResponseHeader",
2121
"appendResponseHeaders",
22+
"assertBodySize",
2223
"assertMethod",
2324
"basicAuth",
25+
"bodyLimit",
2426
"callMiddleware",
2527
"clearResponseHeaders",
2628
"clearSession",

0 commit comments

Comments
 (0)