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`.
Comment on lines +61 to +62
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for contentTypeNegotiation should clarify the behavior when no Accept header is present. Based on the test at line 433, when content negotiation is enabled and there's no Accept header, the response defaults to 'text/plain' (the first item in AVAILABLE_MEDIA_TYPES). This should be explicitly documented to avoid confusion.

Suggested change
responses will use `text/plain` or `text/html` based on the client's preferences. Defaults
to `false`.
responses will use `text/plain` or `text/html` based on the client's preferences. When
enabled and the request does not include an `Accept` header, the response defaults to
`text/plain` (the first available media type). Defaults to `false`.

Copilot uses AI. Check for mistakes.

> [!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
*/
Comment on lines 33 to 38
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment for this function says "Create a minimal HTML document", but the function has been renamed from createHtmlDocument to createHtmlBody and now returns a Buffer instead of a string. The documentation should be updated to clarify that it returns a Buffer containing the HTML body.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "fallback response content type negotiation enabled" is unclear. Consider revising to "content type negotiation enabled" for clarity, as "fallback" is ambiguous in this context.

Suggested change
// fallback response content type negotiation enabled
// response content type negotiation enabled

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "If text/plain fallback is enabled" is misleading. The option is named contentTypeNegotiation, not "text/plain fallback", and it enables negotiation between both text/plain and text/html. Consider updating the comment to "If content type negotiation is enabled" for clarity.

Suggested change
// If text/plain fallback is enabled, negotiate content type
// If content type negotiation is enabled, negotiate content type

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "default to plain text" is misleading in this context. This case is reached when preferredType is 'text/plain' OR when defaultContentType is 'text/plain' (and no preference was determined). The comment should be removed or clarified to "text/plain response" to avoid confusion.

Suggested change
// default to plain text
// text/plain response

Copilot uses AI. Check for mistakes.
body = Buffer.from(msg, 'utf8')
contentType = TEXT_CONTENT_TYPE
break
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The switch statement lacks a default case, which could result in body and contentType being undefined if an unexpected value is used. While the validation at line 91-93 should prevent this, adding a default case would make the code more defensive and prevent potential runtime errors if the validation logic changes in the future.

Suggested change
break
break
default:
// Defensive fallback: use the configured defaultContentType
if (defaultContentType === 'text/plain') {
body = Buffer.from(msg, 'utf8')
contentType = TEXT_CONTENT_TYPE
} else {
body = createHtmlBody(msg)
contentType = HTML_CONTENT_TYPE
}
break

Copilot uses AI. Check for mistakes.
}

// 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