├── .eslintignore ├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── tsup.config.ts ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── README.md ├── src └── index.ts └── test └── index.test.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | test/*.*.ts 2 | tsup.config.ts 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 5 8 | versioning-strategy: increase-if-necessary 9 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | const tsupConfig = defineConfig({ 4 | name: '@koa/body-parser', 5 | entry: ['src/*.ts'], 6 | target: 'esnext', 7 | format: ['cjs', 'esm'], 8 | dts: true, 9 | splitting: false, 10 | sourcemap: false, 11 | clean: true, 12 | platform: 'node' 13 | }) 14 | 15 | export default tsupConfig 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS # 2 | ################### 3 | .DS_Store 4 | .idea 5 | Thumbs.db 6 | tmp/ 7 | temp/ 8 | 9 | 10 | # Node.js # 11 | ################### 12 | node_modules 13 | 14 | 15 | # Build # 16 | ################### 17 | dist 18 | build 19 | 20 | # NYC # 21 | ################### 22 | coverage 23 | *.lcov 24 | .nyc_output 25 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branch: master 6 | pull_request: 7 | branch: master 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [20.x, 22.x, 24.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm run test 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "moduleResolution": "node", 10 | "removeComments": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true, 20 | "esModuleInterop": true, 21 | "emitDecoratorMetadata": true, 22 | "experimentalDecorators": true, 23 | "resolveJsonModule": true, 24 | "baseUrl": ".", 25 | "types": ["node"] 26 | }, 27 | "include": ["./src/**/*.ts"], 28 | "exclude": ["dist", "node_modules", "src/**/*.test.tsx"] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014-2016 Jonathan Ong me@jongleberry.com 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@koa/body-parsers", 3 | "version": "6.0.1", 4 | "description": "Koa body parsers collection", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.mjs", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "require": "./dist/index.js", 11 | "import": "./dist/index.mjs" 12 | } 13 | }, 14 | "files": [ 15 | "dist", 16 | "LICENSE", 17 | "README.md" 18 | ], 19 | "author": { 20 | "name": "Jonathan Ong", 21 | "email": "me@jongleberry.com", 22 | "url": "http://jongleberry.com", 23 | "twitter": "https://twitter.com/jongleberry" 24 | }, 25 | "scripts": { 26 | "prebuild": "rimraf dist", 27 | "build": "tsup", 28 | "lint": "ts-standard", 29 | "lint:fix": "npm run lint -- --fix", 30 | "pretest": "yarn run lint", 31 | "test": "node --require ts-node/register **/*.test.ts --test", 32 | "test:coverage": "c8 npm run test", 33 | "prepublishOnly": "npm run build" 34 | }, 35 | "keywords": [ 36 | "koa", 37 | "middleware", 38 | "body", 39 | "parser", 40 | "parsers" 41 | ], 42 | "license": "MIT", 43 | "peerDependencies": { 44 | "koa": ">= 2" 45 | }, 46 | "dependencies": { 47 | "raw-body": "^3.0.0" 48 | }, 49 | "devDependencies": { 50 | "@types/koa": "^3.0.0", 51 | "@types/node": "^24.2.1", 52 | "@types/supertest": "^6.0.3", 53 | "c8": "^10.1.3", 54 | "koa": "^3.0.1", 55 | "rimraf": "^6.0.1", 56 | "supertest": "^7.1.4", 57 | "ts-node": "^10.9.2", 58 | "ts-standard": "^12.0.2", 59 | "tsup": "^8.5.0", 60 | "typescript": "5.9" 61 | }, 62 | "enignes": { 63 | "node": ">=20" 64 | }, 65 | "repository": "koajs/body-parsers", 66 | "bugs": { 67 | "url": "https://github.com/koajs/body-parsers/issues" 68 | }, 69 | "homepage": "https://github.com/koajs/body-parsers#readme" 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [**@koa/body-parsers**](https://github.com/koajs/body-parsers) 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | 5 | A more functional version of body parsing. 6 | Use this module if you want to "lazily" parse the body. 7 | Other middleware automatically parse the body in the middleware chain, which might not be ideal as business logic like authentication, authorization, and routing are not done prior to body parsing. 8 | 9 | Includes a `json` and `urlencoded` parsers. 10 | 11 | ## API 12 | 13 | Initialization: 14 | 15 | ```js 16 | // import withBodyParsers from '@koa/body-parsers' 17 | import { withBodyParsers } from "@koa/body-parsers"; 18 | import Koa from "koa"; 19 | 20 | const app = new Koa(); 21 | withBodyParsers(app); 22 | 23 | // example usage 24 | app.use(async (ctx) => { 25 | const currentUser = UserService.getCurrentUser(ctx); 26 | ctx.assert(currentUser, 401); 27 | 28 | ctx.assert(ctx.request.is("json"), 415); 29 | const body = await ctx.request.json("100kb"); 30 | ctx.body = body; 31 | }); 32 | ``` 33 | 34 | Because this module is a plugin for the `context`, the API signature is different. 35 | 36 | ### Expect: 100-continue and ctx.response.writeContinue() 37 | 38 | `Expect: 100-continue` is automatically supported as long as you use `app.listen()`. 39 | Otherwise, create your server like this: 40 | 41 | ```js 42 | const fn = app.callback(); 43 | const server = http.createServer(); // or whatever server you use 44 | server.on("request", fn); // regular requests 45 | server.on("checkContinue", function (req, res) { 46 | // tag requests with `Expect: 100-continue` 47 | req.checkContinue = true; 48 | fn(req, res); 49 | }); 50 | ``` 51 | 52 | If `Expect: 100-continue` was sent to the client, 53 | this will automatically response with a "100-continue". 54 | Use this right before parsing the body. 55 | Automatically called by all following body parsers, 56 | but you would still have to call it if you're doing something like: 57 | 58 | ```js 59 | app.use(async (ctx) => { 60 | if (ctx.request.is("image/*")) { 61 | ctx.response.writeContinue(); 62 | const buffer = await ctx.request.buffer(); 63 | } 64 | }); 65 | ``` 66 | 67 | ### const body = await ctx.request.json([limit]) 68 | 69 | Get the JSON body of the request, if any. 70 | `limit` defaults to `100kb`. 71 | 72 | ### const body = await ctx.request.urlencoded([limit]) 73 | 74 | Get the traditional form body of the request, if any, 75 | `limit` defaults to `100kb`. 76 | 77 | ### const text = await ctx.request.text([limit]) 78 | 79 | Get the body of the request as a single `text` string. 80 | `limit` defaults to `100kb`. 81 | You could use this to create your own request body parser of some sort. 82 | 83 | ### const buffer = await ctx.request.buffer([limit]) 84 | 85 | Get the body of the request as a single `Buffer` instance. 86 | `limit` defaults to `1mb`. 87 | 88 | [npm-image]: https://img.shields.io/npm/v/@koa/body-parsers.svg?style=flat-square 89 | [npm-url]: https://npmjs.org/package/@koa/body-parsers 90 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import getRawBody from 'raw-body' 2 | import qs from 'querystring' 3 | import type Koa from 'koa' 4 | 5 | declare module 'koa' { 6 | interface Request { 7 | json: (limit?: string) => Promise 8 | _parse_json: (text: string) => any 9 | urlencoded: (limit?: string) => Promise 10 | _parse_urlencoded: (text: string) => any 11 | body: (limit?: string) => Promise 12 | text: (limit?: string) => Promise 13 | buffer: (limit?: string) => Promise 14 | length?: number 15 | } 16 | 17 | interface Response { 18 | writeContinue: () => this 19 | _checkedContinue?: boolean 20 | } 21 | } 22 | 23 | const requestExtensions = { 24 | async json (this: Koa.Request, limit?: string): Promise { 25 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 26 | if (!this.length) return await Promise.resolve() 27 | 28 | const parsedAsText = await this.text(limit) 29 | return this._parse_json(parsedAsText) 30 | }, 31 | 32 | _parse_json (this: Koa.Request, text: string): any { 33 | if ( 34 | (this.app as typeof this.app & { jsonStrict?: boolean }).jsonStrict !== 35 | false 36 | ) { 37 | text = text.trim() 38 | const first = text[0] 39 | if (first !== '{' && first !== '[') { 40 | this.ctx.throw(400, 'only json objects or arrays allowed') 41 | } 42 | } 43 | 44 | try { 45 | return JSON.parse(text) 46 | } catch (err) { 47 | this.ctx.throw(400, 'invalid json received') 48 | } 49 | }, 50 | 51 | async urlencoded (this: Koa.Request, limit?: string): Promise { 52 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 53 | if (!this.length) return await Promise.resolve() 54 | 55 | const parsedAsText = await this.text(limit) 56 | return this._parse_urlencoded(parsedAsText) 57 | }, 58 | 59 | _parse_urlencoded (this: Koa.Request, text: string): any { 60 | const qsParser = ( 61 | (this.app as typeof this.app & { querystring?: typeof qs }).querystring ?? 62 | qs 63 | ).parse 64 | 65 | try { 66 | return qsParser(text) 67 | } catch (err) { 68 | this.ctx.throw(400, 'invalid urlencoded received') 69 | } 70 | }, 71 | 72 | async body (this: Koa.Request, limit?: string): Promise { 73 | switch (this.is('urlencoded', 'json')) { 74 | case 'json': 75 | return await this.json(limit) 76 | case 'urlencoded': 77 | return await this.urlencoded(limit) 78 | default: 79 | return await Promise.resolve() 80 | } 81 | }, 82 | 83 | async text (this: Koa.Request, limit?: string): Promise { 84 | this.response.writeContinue() 85 | return await getRawBody(this.req, { 86 | limit: limit ?? '100kb', 87 | length: this.length, 88 | encoding: 'utf8' 89 | }) 90 | }, 91 | 92 | async buffer (this: Koa.Request, limit?: string): Promise { 93 | this.response.writeContinue() 94 | 95 | return await getRawBody(this.req, { 96 | limit: limit ?? '1mb', 97 | length: this.length 98 | }) 99 | } 100 | } 101 | 102 | const responseExtensions = { 103 | writeContinue (this: Koa.Response): Koa.Response { 104 | if ( 105 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 106 | !this._checkedContinue && 107 | 'checkContinue' in this.req && 108 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 109 | this.req.checkContinue 110 | ) { 111 | this.res.writeContinue() 112 | this._checkedContinue = true 113 | } 114 | 115 | return this 116 | } 117 | } 118 | 119 | export const withBodyParsers = (app: Koa): Koa => { 120 | Object.keys(requestExtensions).forEach((key) => { 121 | // @ts-expect-error 122 | app.request[key] = requestExtensions[key] 123 | }) 124 | 125 | Object.keys(responseExtensions).forEach((key) => { 126 | // @ts-expect-error 127 | app.response[key] = responseExtensions[key] 128 | }) 129 | 130 | return app 131 | } 132 | 133 | export default withBodyParsers 134 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import assert from "node:assert"; 4 | import http from "node:http"; 5 | import { describe, it } from "node:test"; 6 | import type { Socket } from "node:net"; 7 | 8 | import Koa from 'koa'; 9 | import request from "supertest"; 10 | 11 | import { withBodyParsers } from '../src' 12 | 13 | export const createApp = (): Koa => { 14 | const app = new Koa() 15 | withBodyParsers(app) 16 | 17 | return app 18 | } 19 | 20 | describe("Body Parsing", () => { 21 | describe(".request.json()", () => { 22 | it("should parse a json body", async () => { 23 | const app = createApp(); 24 | app.use(async (ctx) => { 25 | ctx.body = await ctx.request.json(); 26 | }); 27 | 28 | await request(app.callback()) 29 | .post("/") 30 | .send({ 31 | message: "lol", 32 | }) 33 | .expect(200) 34 | .expect(/"message"/) 35 | .expect(/"lol"/); 36 | }); 37 | 38 | it("should throw on non-objects in strict mode", async () => { 39 | const app = createApp(); 40 | app.use(async (ctx) => { 41 | ctx.body = await ctx.request.json(); 42 | }); 43 | 44 | await request(app.callback()) 45 | .post("/") 46 | .type("json") 47 | .send('"lol"') 48 | .expect(400) 49 | .expect("only json objects or arrays allowed"); 50 | }); 51 | 52 | it("should not throw on non-objects in non-strict mode", async () => { 53 | const app = createApp(); 54 | // @ts-expect-error 55 | app.jsonStrict = false; 56 | app.use(async (ctx) => { 57 | ctx.body = await ctx.request.json(); 58 | }); 59 | 60 | await request(app.callback()) 61 | .post("/") 62 | .type("json") 63 | .send('"lol"') 64 | .expect(200) 65 | .expect("lol"); 66 | }); 67 | 68 | it("should throw when parsing invalid JSON", async () => { 69 | const app = createApp(); 70 | app.use(async (ctx) => { 71 | ctx.body = await ctx.request.json(); 72 | }); 73 | 74 | await request(app.callback()) 75 | .post("/") 76 | .type("json") 77 | .send("{invalid:true}") 78 | .expect(400) 79 | .expect("invalid json received"); 80 | }); 81 | }); 82 | 83 | describe(".request.urlencoded()", () => { 84 | it("should parse a urlencoded body", async () => { 85 | const app = createApp(); 86 | app.use(async (ctx) => { 87 | ctx.body = await ctx.request.urlencoded(); 88 | }); 89 | 90 | await request(app.callback()) 91 | .post("/") 92 | .send("message=lol") 93 | .expect(200) 94 | .expect(/"message"/) 95 | .expect(/"lol"/); 96 | }); 97 | 98 | it("should return immediately when receiving an empty body", async () => { 99 | const app = createApp(); 100 | app.use(async (ctx) => { 101 | ctx.body = await ctx.request.urlencoded(); 102 | }); 103 | 104 | await request(app.callback()).post("/").send("").expect(204).expect(""); 105 | }); 106 | 107 | it("should throw when the underlying parser fails", async () => { 108 | const app = createApp(); 109 | // @ts-expect-error 110 | app.querystring = () => { 111 | throw new Error("parsing failed"); 112 | }; 113 | app.use(async (ctx) => { 114 | ctx.body = await ctx.request.urlencoded(); 115 | }); 116 | 117 | await request(app.callback()) 118 | .post("/") 119 | .send("boop") 120 | .expect(400) 121 | .expect("invalid urlencoded received"); 122 | }); 123 | }); 124 | 125 | describe(".request.text()", () => { 126 | it("should get the raw text body", async () => { 127 | const app = createApp(); 128 | app.use(async (ctx) => { 129 | ctx.body = await ctx.request.text(); 130 | assert.equal("string", typeof ctx.body); 131 | }); 132 | 133 | await request(app.callback()) 134 | .post("/") 135 | .send("message=lol") 136 | .expect(200) 137 | .expect("message=lol"); 138 | }); 139 | 140 | it("should throw if the body is too large", async () => { 141 | const app = createApp(); 142 | app.use(async (ctx) => { 143 | await ctx.request.text("1kb"); 144 | ctx.body = 204; 145 | }); 146 | 147 | await request(app.callback()) 148 | .post("/") 149 | .send(Buffer.alloc(2048)) 150 | .expect(413); 151 | }); 152 | }); 153 | 154 | describe(".request.buffer()", () => { 155 | it("should get the raw buffer body", async () => { 156 | const app = createApp(); 157 | app.use(async (ctx) => { 158 | ctx.type = "text"; 159 | ctx.body = await ctx.request.buffer(); 160 | assert(Buffer.isBuffer(ctx.body)); 161 | }); 162 | 163 | await request(app.callback()) 164 | .post("/") 165 | .send("message=lol") 166 | .expect(200) 167 | .expect("message=lol"); 168 | }); 169 | 170 | it("should throw if the body is too large", async () => { 171 | const app = createApp(); 172 | app.use(async (ctx) => { 173 | await ctx.request.buffer("1kb"); 174 | ctx.body = 204; 175 | }); 176 | 177 | await request(app.callback()) 178 | .post("/") 179 | .send(Buffer.alloc(2048)) 180 | .expect(413); 181 | }); 182 | }); 183 | 184 | describe(".request.body()", () => { 185 | it("should parse a json body", async () => { 186 | const app = createApp(); 187 | app.use(async (ctx) => { 188 | ctx.body = await ctx.request.body(); 189 | }); 190 | 191 | await request(app.callback()) 192 | .post("/") 193 | .send({ 194 | message: "lol", 195 | }) 196 | .expect(200) 197 | .expect(/"message"/) 198 | .expect(/"lol"/); 199 | }); 200 | 201 | it("should parse a urlencoded body", async () => { 202 | const app = createApp(); 203 | app.use(async (ctx) => { 204 | ctx.body = await ctx.request.body(); 205 | }); 206 | 207 | await request(app.callback()) 208 | .post("/") 209 | .send("message=lol") 210 | .expect(200) 211 | .expect(/"message"/) 212 | .expect(/"lol"/); 213 | }); 214 | }); 215 | 216 | describe("Expect: 100-continue", () => { 217 | const send100ContinueRequest = async ( 218 | port: number, 219 | path = "/" 220 | ): Promise => { 221 | return await new Promise((resolve, reject) => { 222 | const req = http.request({ 223 | port, 224 | path, 225 | headers: { 226 | expect: "100-continue", 227 | "content-type": "application/json", 228 | }, 229 | }); 230 | 231 | req.once("continue", function (this: Socket) { 232 | this.end(JSON.stringify({ message: "lol" })); 233 | }); 234 | 235 | req.once("response", () => resolve()); 236 | 237 | req.once("error", (err) => reject(err)); 238 | 239 | req.end(); 240 | }); 241 | }; 242 | 243 | it("should send 100-continue", async () => { 244 | const app = createApp(); 245 | app.use(async (ctx) => { 246 | ctx.body = await ctx.request.json(); 247 | }); 248 | 249 | const server = app.listen(); 250 | try { 251 | const addressInfo = server.address(); 252 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 253 | if (!addressInfo || typeof addressInfo === "string") { 254 | throw new Error("Server address not found"); 255 | } 256 | 257 | await send100ContinueRequest(addressInfo.port); 258 | } finally { 259 | server.close(); 260 | } 261 | }); 262 | 263 | it("should send 100-continue when not using app.listen()", async () => { 264 | const app = createApp(); 265 | app.use(async (ctx) => { 266 | ctx.body = await ctx.request.text(); 267 | }); 268 | 269 | const fn = app.callback(); 270 | const server = http.createServer(); 271 | 272 | server.on("checkContinue", (req, res) => { 273 | // Inform Node this is a 100-continue request 274 | // @ts-expect-error 275 | req.checkContinue = true; 276 | fn(req, res); 277 | }); 278 | 279 | await new Promise((resolve, reject) => { 280 | server.listen(async () => { 281 | try { 282 | const addressInfo = server.address(); 283 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 284 | if (!addressInfo || typeof addressInfo === "string") { 285 | throw new Error("Server address not found"); 286 | } 287 | 288 | await send100ContinueRequest(addressInfo.port); 289 | resolve(); 290 | } catch (err) { 291 | reject(err); 292 | } finally { 293 | server.close(); 294 | } 295 | }); 296 | }); 297 | }); 298 | }); 299 | }); 300 | --------------------------------------------------------------------------------