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' +
- '
' + body + '\n' + - '\n' + - '\n' + const html = ` + + + +
${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) {