├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── History.md ├── LICENSE ├── Readme.md ├── index.js ├── package-lock.json ├── package.json └── test └── test.js /.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 | -------------------------------------------------------------------------------- /.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: [18.x, 20.x, 22.x, 24.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm run test:coverage 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v5 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 4.1.0 / 2018-05-22 3 | ================== 4 | 5 | * improve: reduce stack trace by removing useless function call (#95) 6 | 7 | 4.0.0 / 2017-04-12 8 | ================== 9 | 10 | * remove `any-promise` as a dependency 11 | 12 | 3.2.1 / 2016-10-26 13 | ================== 14 | 15 | * revert add variadric support #65 - introduced an unintended breaking change 16 | 17 | 3.2.0 / 2016-10-25 18 | ================== 19 | 20 | * fix #60 infinite loop when calling next https://github.com/koajs/compose/pull/61 21 | * add variadric support https://github.com/koajs/compose/pull/65 22 | 23 | 3.1.0 / 2016-03-17 24 | ================== 25 | 26 | * add linting w/ standard 27 | * use `any-promise` so that the promise engine is configurable 28 | 29 | 3.0.0 / 2015-10-19 30 | ================== 31 | 32 | * change middleware signature to `async (ctx, next) => await next()` for `koa@2`. 33 | See https://github.com/koajs/compose/pull/27 for more information. 34 | 35 | 2.3.0 / 2014-05-01 36 | ================== 37 | 38 | * remove instrumentation 39 | 40 | 2.2.0 / 2014-01-22 41 | ================== 42 | 43 | * add `fn._name` for debugging 44 | 45 | 2.1.0 / 2013-12-22 46 | ================== 47 | 48 | * add debugging support 49 | * improve performance ~15% 50 | 51 | 2.0.1 / 2013-12-21 52 | ================== 53 | 54 | * update co to v3 55 | * use generator delegation 56 | 57 | 2.0.0 / 2013-11-07 58 | ================== 59 | 60 | * change middleware signature expected 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2013 TJ Holowaychuk tj@apex.sh 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 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # koa-compose 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Node.js CI](https://github.com/koajs/compose/actions/workflows/node.js.yml/badge.svg?branch=master&event=push)](https://github.com/koajs/compose/actions/workflows/node.js.yml) 6 | [![Test coverage][codecov-image]][codecov-url] 7 | [![Dependency Status][david-image]][david-url] 8 | [![License][license-image]][license-url] 9 | [![Downloads][downloads-image]][downloads-url] 10 | 11 | Compose middleware specifically for Koa. 12 | 13 | ## Installation 14 | 15 | ```js 16 | $ npm install koa-compose 17 | ``` 18 | 19 | ## Maintainers 20 | 21 | - Lead: @jonathanong [@jongleberry](https://twitter.com/jongleberry) 22 | 23 | ## API 24 | 25 | ### fn = compose([a, b, c, ...]) 26 | 27 | Compose the given middleware and return middleware. 28 | 29 | ## License 30 | 31 | MIT 32 | 33 | [npm-image]: https://img.shields.io/npm/v/koa-compose.svg?style=flat-square 34 | [npm-url]: https://npmjs.org/package/koa-compose 35 | [codecov-image]: https://img.shields.io/codecov/c/github/koajs/compose/next.svg?style=flat-square 36 | [codecov-url]: https://codecov.io/github/koajs/compose 37 | [david-image]: http://img.shields.io/david/koajs/compose.svg?style=flat-square 38 | [david-url]: https://david-dm.org/koajs/compose 39 | [license-image]: http://img.shields.io/npm/l/koa-compose.svg?style=flat-square 40 | [license-url]: LICENSE 41 | [downloads-image]: http://img.shields.io/npm/dm/koa-compose.svg?style=flat-square 42 | [downloads-url]: https://npmjs.org/package/koa-compose 43 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * @param {Array} middleware 5 | * @return {Function} 6 | */ 7 | const composeSlim = (middleware) => async (ctx, next) => { 8 | const dispatch = (i) => async () => { 9 | const fn = i === middleware.length 10 | ? next 11 | : middleware[i] 12 | if (!fn) return 13 | return await fn(ctx, dispatch(i + 1)) 14 | } 15 | return dispatch(0)() 16 | } 17 | 18 | /** @typedef {import("koa").Middleware} Middleware */ 19 | 20 | /** 21 | * Compose `middleware` returning 22 | * a fully valid middleware comprised 23 | * of all those which are passed. 24 | * 25 | * @param {...(Middleware | Middleware[])} middleware 26 | * @return {Middleware} 27 | * @api public 28 | */ 29 | 30 | const compose = (...middleware) => { 31 | const funcs = middleware.flat() 32 | 33 | for (const fn of funcs) { 34 | if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!') 35 | } 36 | 37 | if (process.env.NODE_ENV === 'production') return composeSlim(funcs) 38 | 39 | return async (ctx, next) => { 40 | const dispatch = async (i) => { 41 | const fn = i === funcs.length 42 | ? next 43 | : funcs[i] 44 | if (!fn) return 45 | 46 | let nextCalled = false 47 | let nextResolved = false 48 | const nextProxy = async () => { 49 | if (nextCalled) throw Error('next() called multiple times') 50 | nextCalled = true 51 | try { 52 | return await dispatch(i + 1) 53 | } finally { 54 | nextResolved = true 55 | } 56 | } 57 | const result = await fn(ctx, nextProxy) 58 | if (nextCalled && !nextResolved) { 59 | throw Error( 60 | 'Middleware resolved before downstream.\n\tYou are probably missing an await or return' 61 | ) 62 | } 63 | return result 64 | } 65 | return dispatch(0) 66 | } 67 | } 68 | 69 | /** 70 | * Expose compositor. 71 | */ 72 | 73 | module.exports = compose 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-compose", 3 | "description": "compose Koa middleware", 4 | "repository": "koajs/compose", 5 | "version": "4.2.0", 6 | "keywords": [ 7 | "koa", 8 | "middleware", 9 | "compose" 10 | ], 11 | "files": [ 12 | "index.js" 13 | ], 14 | "devDependencies": { 15 | "c8": "^10.1.3", 16 | "codecov": "^3.0.0", 17 | "koa": "^2.13.4", 18 | "snazzy": "^9.0.0", 19 | "standard": "^16.0.3" 20 | }, 21 | "scripts": { 22 | "test": "node --test", 23 | "test:coverage": "c8 --reporter=lcov --reporter=text-summary node --test", 24 | "lint": "standard", 25 | "lint:fix": "standard --fix | snazzy" 26 | }, 27 | "license": "MIT" 28 | } 29 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint-env jest */ 4 | const { describe, it, beforeEach, after } = require('node:test') 5 | const compose = require('..') 6 | const assert = require('assert') 7 | 8 | function wait (ms) { 9 | return new Promise((resolve) => setTimeout(resolve, ms || 1)) 10 | } 11 | 12 | function isPromise (x) { 13 | return x && typeof x.then === 'function' 14 | } 15 | 16 | function testBaseFunctionality () { 17 | it('should work', async () => { 18 | const arr = [] 19 | const stack = [] 20 | 21 | stack.push(async (context, next) => { 22 | arr.push(1) 23 | await wait(1) 24 | await next() 25 | await wait(1) 26 | arr.push(6) 27 | }) 28 | 29 | stack.push(async (context, next) => { 30 | arr.push(2) 31 | await wait(1) 32 | await next() 33 | await wait(1) 34 | arr.push(5) 35 | }) 36 | 37 | stack.push(async (context, next) => { 38 | arr.push(3) 39 | await wait(1) 40 | await next() 41 | await wait(1) 42 | arr.push(4) 43 | }) 44 | 45 | await compose(stack)({}) 46 | assert.deepStrictEqual(arr.sort(), [1, 2, 3, 4, 5, 6]) 47 | }) 48 | 49 | it('should be able to be called twice', () => { 50 | const stack = [] 51 | 52 | stack.push(async (context, next) => { 53 | context.arr.push(1) 54 | await wait(1) 55 | await next() 56 | await wait(1) 57 | context.arr.push(6) 58 | }) 59 | 60 | stack.push(async (context, next) => { 61 | context.arr.push(2) 62 | await wait(1) 63 | await next() 64 | await wait(1) 65 | context.arr.push(5) 66 | }) 67 | 68 | stack.push(async (context, next) => { 69 | context.arr.push(3) 70 | await wait(1) 71 | await next() 72 | await wait(1) 73 | context.arr.push(4) 74 | }) 75 | 76 | const fn = compose(stack) 77 | const ctx1 = { arr: [] } 78 | const ctx2 = { arr: [] } 79 | const out = [1, 2, 3, 4, 5, 6] 80 | 81 | return fn(ctx1).then(() => { 82 | assert.deepEqual(out, ctx1.arr) 83 | return fn(ctx2) 84 | }).then(() => { 85 | assert.deepEqual(out, ctx2.arr) 86 | }) 87 | }) 88 | 89 | it('should create next functions that return a Promise', function () { 90 | const stack = [] 91 | const arr = [] 92 | for (let i = 0; i < 5; i++) { 93 | stack.push((context, next) => { 94 | const result = next() 95 | arr.push(result) 96 | return result 97 | }) 98 | } 99 | 100 | compose(stack)({}) 101 | 102 | for (const next of arr) { 103 | assert(isPromise(next), 'one of the functions next is not a Promise') 104 | } 105 | }) 106 | 107 | it('should work with 0 middleware', function () { 108 | return Promise.all([ 109 | compose()({}), 110 | compose([])({}), 111 | compose([], [])({}) 112 | ]) 113 | }) 114 | 115 | it('should only accept middleware as functions', () => { 116 | const badTypes = [1, true, 'string', {}, undefined, Symbol('test')]; 117 | [...badTypes, []].forEach((badType) => { 118 | assert.throws(() => compose([badType]), TypeError) 119 | }) 120 | badTypes.forEach((badType) => { 121 | assert.throws(() => compose(badType), TypeError) 122 | }) 123 | }) 124 | 125 | it('should work when yielding at the end of the stack', async () => { 126 | const stack = [] 127 | let called = false 128 | 129 | stack.push(async (ctx, next) => { 130 | await next() 131 | called = true 132 | }) 133 | 134 | await compose(stack)({}) 135 | assert(called) 136 | }) 137 | 138 | it('should reject on errors in middleware', () => { 139 | const stack = [] 140 | 141 | stack.push(() => { throw new Error() }) 142 | 143 | return assert.rejects( 144 | compose(stack)({}), 145 | /Error/ 146 | ) 147 | }) 148 | 149 | it('should keep the context', () => { 150 | const ctx = {} 151 | 152 | const stack = [] 153 | 154 | stack.push(async (ctx2, next) => { 155 | await next() 156 | assert.deepStrictEqual(ctx2, ctx) 157 | }) 158 | 159 | stack.push(async (ctx2, next) => { 160 | await next() 161 | assert.deepStrictEqual(ctx2, ctx) 162 | }) 163 | 164 | stack.push(async (ctx2, next) => { 165 | await next() 166 | assert.deepStrictEqual(ctx2, ctx) 167 | }) 168 | 169 | return compose(stack)(ctx) 170 | }) 171 | 172 | it('should catch downstream errors', async () => { 173 | const arr = [] 174 | const stack = [] 175 | 176 | stack.push(async (ctx, next) => { 177 | arr.push(1) 178 | try { 179 | arr.push(6) 180 | await next() 181 | arr.push(7) 182 | } catch (err) { 183 | arr.push(2) 184 | } 185 | arr.push(3) 186 | }) 187 | 188 | stack.push(async (ctx, next) => { 189 | arr.push(4) 190 | throw new Error() 191 | }) 192 | 193 | await compose(stack)({}) 194 | assert.deepStrictEqual(arr, [1, 6, 4, 2, 3]) 195 | }) 196 | 197 | it('should compose w/ next', () => { 198 | let called = false 199 | 200 | return compose([])({}, async () => { 201 | called = true 202 | }).then(function () { 203 | assert(called) 204 | }) 205 | }) 206 | 207 | it('should handle errors in wrapped non-async functions', () => { 208 | const stack = [] 209 | 210 | stack.push(function () { 211 | throw new Error() 212 | }) 213 | 214 | return assert.rejects( 215 | compose(stack)({}), 216 | /Error/ 217 | ) 218 | }) 219 | 220 | // https://github.com/koajs/compose/pull/27#issuecomment-143109739 221 | it('should compose w/ other compositions', () => { 222 | const called = [] 223 | 224 | return compose([ 225 | compose([ 226 | (ctx, next) => { 227 | called.push(1) 228 | return next() 229 | }, 230 | (ctx, next) => { 231 | called.push(2) 232 | return next() 233 | } 234 | ]), 235 | (ctx, next) => { 236 | called.push(3) 237 | return next() 238 | } 239 | ])({}).then(() => assert.deepEqual(called, [1, 2, 3])) 240 | }) 241 | 242 | it('should return a valid middleware', () => { 243 | let val = 0 244 | return compose([ 245 | compose([ 246 | (ctx, next) => { 247 | val++ 248 | return next() 249 | }, 250 | (ctx, next) => { 251 | val++ 252 | return next() 253 | } 254 | ]), 255 | (ctx, next) => { 256 | val++ 257 | return next() 258 | } 259 | ])({}).then(function () { 260 | assert.strictEqual(val, 3) 261 | }) 262 | }) 263 | 264 | it('should return last return value', () => { 265 | const stack = [] 266 | 267 | stack.push(async (context, next) => { 268 | const val = await next() 269 | assert.strictEqual(val, 2) 270 | return 1 271 | }) 272 | 273 | stack.push(async (context, next) => { 274 | const val = await next() 275 | assert.strictEqual(val, 0) 276 | return 2 277 | }) 278 | 279 | const next = () => 0 280 | return compose(stack)({}, next).then(function (val) { 281 | assert.strictEqual(val, 1) 282 | }) 283 | }) 284 | 285 | it('should not affect the original middleware array', () => { 286 | const middleware = [] 287 | const fn1 = (ctx, next) => { 288 | return next() 289 | } 290 | middleware.push(fn1) 291 | 292 | for (const fn of middleware) { 293 | assert.equal(fn, fn1) 294 | } 295 | 296 | compose(middleware) 297 | 298 | for (const fn of middleware) { 299 | assert.equal(fn, fn1) 300 | } 301 | }) 302 | 303 | it('should not get stuck on the passed in next', () => { 304 | const middleware = [(ctx, next) => { 305 | ctx.middleware++ 306 | return next() 307 | }] 308 | const ctx = { 309 | middleware: 0, 310 | next: 0 311 | } 312 | 313 | return compose(middleware)(ctx, (ctx, next) => { 314 | ctx.next++ 315 | return next() 316 | }).then(() => { 317 | assert.strictEqual(ctx.middleware, 1) 318 | }) 319 | }) 320 | } 321 | 322 | function testDevErrors () { 323 | it('should only accept middleware as functions', () => { 324 | assert.throws(() => compose([{}]), TypeError) 325 | }) 326 | 327 | it('should throw if next() is called multiple times', () => { 328 | return compose([ 329 | async (ctx, next) => { 330 | await next() 331 | await next() 332 | } 333 | ])({}).then(() => { 334 | throw new Error('boom') 335 | }, (err) => { 336 | assert(/multiple times/.test(err.message)) 337 | }) 338 | }) 339 | 340 | it('should detect disconnected promise chains', async () => { 341 | const middleware = [ 342 | (ctx, next) => next(), 343 | (ctx, next) => { 344 | next() 345 | }, 346 | async (ctx, next) => { 347 | await wait(1) 348 | return next() 349 | } 350 | ] 351 | await assert.rejects( 352 | compose(middleware)({}), 353 | /resolved before downstream/ 354 | ) 355 | }) 356 | } 357 | describe('Koa Compose', function () { 358 | const OLD_ENV = process.env 359 | 360 | beforeEach(() => { 361 | delete require.cache[require.resolve('..')] // Most important - it clears the cache 362 | process.env = { ...OLD_ENV, NODE_ENV: 'production' } // Make a copy 363 | }) 364 | 365 | after(() => { 366 | process.env = OLD_ENV // Restore old environment 367 | }) 368 | 369 | testBaseFunctionality() 370 | }) 371 | 372 | describe('Koa Compose - Dev', function () { 373 | testBaseFunctionality() 374 | testDevErrors() 375 | }) 376 | --------------------------------------------------------------------------------