From 4d54d159d0b45caf809fbf0570dc286cee1de4d8 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Sat, 21 Feb 2026 10:44:19 +0000 Subject: [PATCH] Add opt-in 405 Method Not Allowed responses Collect Allow header from matched routes when enabled --- index.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/router.js | 27 ++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/index.js b/index.js index 4358aeb..6e1280d 100644 --- a/index.js +++ b/index.js @@ -65,6 +65,7 @@ function Router (options) { router.caseSensitive = opts.caseSensitive router.mergeParams = opts.mergeParams + router.methodNotAllowed = opts.methodNotAllowed router.params = {} router.strict = opts.strict router.stack = [] @@ -155,6 +156,7 @@ Router.prototype.handle = function handle (req, res, callback) { let idx = 0 let methods + let methodNotAllowed const protohost = getProtohost(req.url) || '' let removed = '' const self = this @@ -179,6 +181,14 @@ Router.prototype.handle = function handle (req, res, callback) { done = wrap(done, generateOptionsResponder(res, methods)) } + if (self.methodNotAllowed) { + methodNotAllowed = { + methods: [], + methodMatched: false + } + done = wrap(done, generateMethodNotAllowedResponder(req, res, methodNotAllowed)) + } + // setup basic req values req.baseUrl = parentUrl req.originalUrl = req.originalUrl || req.url @@ -260,11 +270,19 @@ Router.prototype.handle = function handle (req, res, callback) { const method = req.method const hasMethod = route._handlesMethod(method) + if (methodNotAllowed && hasMethod) { + methodNotAllowed.methodMatched = true + } + // build up automatic options response if (!hasMethod && method === 'OPTIONS' && methods) { methods.push.apply(methods, route._methods()) } + if (!hasMethod && methodNotAllowed && method !== 'OPTIONS') { + methodNotAllowed.methods.push.apply(methodNotAllowed.methods, route._methods()) + } + // don't even bother matching route if (!hasMethod && method !== 'HEAD') { match = false @@ -468,6 +486,25 @@ function generateOptionsResponder (res, methods) { } } +/** + * Generate a callback that will make a 405 response. + * + * @param {IncomingMessage} req + * @param {OutgoingMessage} res + * @param {object} methodNotAllowed + * @private + */ + +function generateMethodNotAllowedResponder (req, res, methodNotAllowed) { + return function onDone (fn, err) { + if (err || methodNotAllowed.methodMatched || methodNotAllowed.methods.length === 0 || req.method === 'OPTIONS') { + return fn(err) + } + + trySendMethodNotAllowedResponse(res, methodNotAllowed.methods, fn) + } +} + /** * Get pathname of request. * @@ -689,6 +726,17 @@ function restore (fn, obj) { } } +/** + * Send a 405 response. + * + * @private + */ + +function sendMethodNotAllowedResponse (res, methods) { + res.statusCode = 405 + sendOptionsResponse(res, methods) +} + /** * Send an OPTIONS response. * @@ -728,6 +776,20 @@ function trySendOptionsResponse (res, methods, next) { } } +/** + * Try to send a 405 response. + * + * @private + */ + +function trySendMethodNotAllowedResponse (res, methods, next) { + try { + sendMethodNotAllowedResponse(res, methods) + } catch (err) { + next(err) + } +} + /** * Wrap a function * diff --git a/test/router.js b/test/router.js index b440e40..10ae50b 100644 --- a/test/router.js +++ b/test/router.js @@ -312,6 +312,33 @@ describe('Router', function () { }) }) + describe('with "methodNotAllowed" option', function () { + it('should default to 404 for unsupported methods', function (done) { + const router = new Router() + const server = createServer(router) + + router.get('/users', saw) + router.post('/users', saw) + + request(server) + .put('/users') + .expect(404, done) + }) + + it('should respond with 405 and Allow header when enabled', function (done) { + const router = new Router({ methodNotAllowed: true }) + const server = createServer(router) + + router.get('/users', saw) + router.post('/users', saw) + + request(server) + .put('/users') + .expect('Allow', 'GET, HEAD, POST') + .expect(405, done) + }) + }) + methods.slice().sort().forEach(function (method) { if (method === 'connect') { // CONNECT is tricky and supertest doesn't support it