diff --git a/HISTORY.md b/HISTORY.md index 8fb8417..d3537d3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,10 @@ +unreleased +========== + + * feat: add `contentTypeNegotiation` option which enables `text/plain` and `text/html` + responses based on the `Accept` header + * feat: add `defaultContentType` option to configure the default response content type + v2.1.1. / 2025-12-01 ================== diff --git a/README.md b/README.md index e40f729..71c1a27 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,23 @@ Provide a function to be called with the `err` when it exists. Can be used for writing errors to a central location without excessive function generation. Called as `onerror(err, req, res)`. +#### options.contentTypeNegotiation + +Enables content type negotiation based on the `Accept` header. When enabled, error +responses will use `text/plain` or `text/html` based on the client's preferences. Defaults +to `false`. + +> [!WARNING] +> This will be enabled by default in the next major version. + +#### options.defaultContentType + +The fallback content type for responses when content negotiation is disabled or no preferred type can be determined. +Allowed Values are `text/html` or `text/plain`. Defaults to `text/html`. + +> [!WARNING] +> The default will be changed to `text/plain` in the next major version. + ## Examples ### always 404 diff --git a/index.js b/index.js index bf15e48..b3df846 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ * @private */ +const Negotiator = require('negotiator') var debug = require('debug')('finalhandler') var encodeUrl = require('encodeurl') var escapeHtml = require('escape-html') @@ -25,6 +26,10 @@ var statuses = require('statuses') var isFinished = onFinished.isFinished +const AVAILABLE_MEDIA_TYPES = ['text/plain', 'text/html'] +const HTML_CONTENT_TYPE = 'text/html; charset=utf-8' +const TEXT_CONTENT_TYPE = 'text/plain; charset=utf-8' + /** * Create a minimal HTML document. * @@ -32,21 +37,24 @@ var isFinished = onFinished.isFinished * @private */ -function createHtmlDocument (message) { - var body = escapeHtml(message) +function createHtmlBody (message) { + const msg = escapeHtml(message) .replaceAll('\n', '
') .replaceAll(' ', '  ') - return '\n' + - '\n' + - '\n' + - '\n' + - 'Error\n' + - '\n' + - '\n' + - '
' + body + '
\n' + - '\n' + - '\n' + const html = ` + + + +Error + + +
${msg}
+ + +` + + return Buffer.from(html, 'utf8') } /** @@ -75,6 +83,15 @@ function finalhandler (req, res, options) { // get error callback var onerror = opts.onerror + // fallback response content type negotiation enabled + const contentTypeNegotiation = opts.contentTypeNegotiation === true + + // default content type for responses + const defaultContentType = opts.defaultContentType || 'text/html' + if (!AVAILABLE_MEDIA_TYPES.includes(defaultContentType)) { + throw new Error('defaultContentType must be one of: ' + AVAILABLE_MEDIA_TYPES.join(', ')) + } + return function (err) { var headers var msg @@ -123,8 +140,31 @@ function finalhandler (req, res, options) { return } + let preferredType + // If text/plain fallback is enabled, negotiate content type + if (contentTypeNegotiation) { + // negotiate + const negotiator = new Negotiator(req) + preferredType = negotiator.mediaType(AVAILABLE_MEDIA_TYPES) + } + + // construct body + let body + let contentType + switch (preferredType || defaultContentType) { + case 'text/html': + body = createHtmlBody(msg) + contentType = HTML_CONTENT_TYPE + break + case 'text/plain': + // default to plain text + body = Buffer.from(msg, 'utf8') + contentType = TEXT_CONTENT_TYPE + break + } + // send response - send(req, res, status, headers, msg) + send(req, res, status, headers, body, contentType) } } @@ -241,11 +281,8 @@ function getResponseStatusCode (res) { * @private */ -function send (req, res, status, headers, message) { +function send (req, res, status, headers, body, contentType) { function write () { - // response body - var body = createHtmlDocument(message) - // response status res.statusCode = status @@ -268,8 +305,8 @@ function send (req, res, status, headers, message) { res.setHeader('X-Content-Type-Options', 'nosniff') // standard headers - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.setHeader('Content-Length', Buffer.byteLength(body, 'utf8')) + res.setHeader('Content-Type', contentType) + res.setHeader('Content-Length', body.length) if (req.method === 'HEAD') { res.end() diff --git a/package.json b/package.json index 869ddc2..dc61533 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", + "negotiator": "^1.0.0", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" diff --git a/test/test.js b/test/test.js index 516e088..9401591 100644 --- a/test/test.js +++ b/test/test.js @@ -15,6 +15,41 @@ var shouldHaveStatusMessage = utils.shouldHaveStatusMessage var shouldNotHaveBody = utils.shouldNotHaveBody var shouldNotHaveHeader = utils.shouldNotHaveHeader +describe('options.defaultContentType', function () { + it('should accept "text/html" or "text/plain"', function () { + assert.doesNotThrow(function () { + finalhandler({}, {}, { defaultContentType: 'text/html' }) + }) + assert.doesNotThrow(function () { + finalhandler({}, {}, { defaultContentType: 'text/plain' }) + }) + }) + + it('should accept null or undefined', function () { + assert.doesNotThrow(function () { + finalhandler({}, {}, { defaultContentType: null }) + }) + assert.doesNotThrow(function () { + finalhandler({}, {}, { defaultContentType: undefined }) + }) + }) + + it('should throw when invalid value is used', function () { + assert.throws(function () { + finalhandler({}, {}, { defaultContentType: 'application/json' }) + }) + assert.throws(function () { + finalhandler({}, {}, { defaultContentType: 1234 }) + }) + assert.throws(function () { + finalhandler({}, {}, { defaultContentType: true }) + }) + assert.throws(function () { + finalhandler({}, {}, { defaultContentType: { foo: 'bar' } }) + }) + }) +}) + var topDescribe = function (type, createServer) { var wrapper = function wrapper (req) { if (type === 'http2') { @@ -297,6 +332,148 @@ var topDescribe = function (type, createServer) { test.write(buf) test.expect(404, done) }) + + describe('when HTML acceptable', function () { + it('should respond with HTML when contentTypeNegotiation is true', function (done) { + var server = createServer(null, { contentTypeNegotiation: true }) + wrapper(request(server) + .get('/foo')) + .set('Accept', 'text/html') + .expect('Content-Type', 'text/html; charset=utf-8') + .expect(404, /= 400', function (done) { var server = createServer(function (req, res) {