Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
==================

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 56 additions & 19 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* @private
*/

const Negotiator = require('negotiator')
var debug = require('debug')('finalhandler')
var encodeUrl = require('encodeurl')
var escapeHtml = require('escape-html')
Expand All @@ -25,28 +26,35 @@ 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.
*
* @param {string} message
* @private
*/

function createHtmlDocument (message) {
var body = escapeHtml(message)
function createHtmlBody (message) {
const msg = escapeHtml(message)
.replaceAll('\n', '<br>')
.replaceAll(' ', ' &nbsp;')

return '<!DOCTYPE html>\n' +
'<html lang="en">\n' +
'<head>\n' +
'<meta charset="utf-8">\n' +
'<title>Error</title>\n' +
'</head>\n' +
'<body>\n' +
'<pre>' + body + '</pre>\n' +
'</body>\n' +
'</html>\n'
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>${msg}</pre>
</body>
</html>
`

return Buffer.from(html, 'utf8')
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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

Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading