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
12 changes: 10 additions & 2 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const querystring = require('node:querystring')
const eos = require('end-of-stream')
const { pipeline } = require('node:stream')
const undici = require('undici')
const { safeParse: parseContentType } = require('fast-content-type-parse')
const { stripHttp1ConnectionHeaders, getConnectionHeaders } = require('./utils')
const http2 = require('node:http2')

Expand Down Expand Up @@ -35,6 +36,12 @@ function isUndiciInstance (obj) {
isRequestable(obj)
}

// A media type is case-insensitive and may carry parameters (RFC 9110 §8.3.1),
// so `text/event-stream; charset=utf-8` is the same type as `text/event-stream`.
function isServerSentEvents (contentType) {
return parseContentType(contentType ?? '').type === 'text/event-stream'
}

function buildRequest (opts) {
const isHttp2 = !!opts.http2
if (Array.isArray(opts.base) && opts.base.length === 1) {
Expand Down Expand Up @@ -148,7 +155,7 @@ function buildRequest (opts) {
req.on('error', done)
req.on('response', res => {
// remove timeout for sse connections
if (res.headers['content-type'] === 'text/event-stream') {
if (isServerSentEvents(res.headers['content-type'])) {
req.setTimeout(0)
}
done(null, { statusCode: res.statusCode, headers: res.headers, stream: res })
Expand Down Expand Up @@ -263,7 +270,7 @@ function buildRequest (opts) {
})
req.on('response', headers => {
// remove timeout for sse connections
if (headers['content-type'] === 'text/event-stream') {
if (isServerSentEvents(headers['content-type'])) {
req.setTimeout(0)
http2Client.setTimeout(0)
}
Expand All @@ -276,6 +283,7 @@ function buildRequest (opts) {

module.exports = buildRequest
module.exports.TimeoutError = TimeoutError
module.exports.isServerSentEvents = isServerSentEvents

function unixRequest (opts) {
delete opts.port
Expand Down
38 changes: 38 additions & 0 deletions test/http-timeout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,41 @@ test('http sse removes timeout test', async (t) => {
})
t.assert.strictEqual(statusCode, 200)
})

test('http sse removes timeout when content-type has parameters', async (t) => {
// A media type is case-insensitive and may carry parameters (RFC 9110 §8.3.1).
// `text/event-stream; charset=utf-8` is what Starlette's EventSourceResponse
// and Spring emit, and it must be treated as SSE just the same. The parsing
// itself is covered deterministically in test/sse-content-type.test.js; this
// test only confirms such a response flows through the proxy end to end, the
// same shape as the sibling above — no timers, no clock race.
const target = Fastify()
t.after(() => target.close())

target.get('/', (_request, reply) => {
t.assert.ok('request arrives')

reply.header('content-type', 'text/event-stream; charset=utf-8').status(200).send('data: hello\n\n')
})

await target.listen({ port: 0 })

const instance = Fastify()
t.after(() => instance.close())

instance.register(From, { http: { requestOptions: { timeout: 100 } } })

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.server.address().port}/`)
})

await instance.listen({ port: 0 })

const { statusCode, body } = await request(`http://localhost:${instance.server.address().port}/`, {
dispatcher: new Agent({
pipelining: 0
})
})
t.assert.strictEqual(statusCode, 200)
t.assert.strictEqual(await body.text(), 'data: hello\n\n')
})
42 changes: 42 additions & 0 deletions test/http2-timeout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,45 @@ test('http2 sse removes request and session timeout test', async (t) => {
instance.close()
target.close()
})

test('http2 sse removes request and session timeout when content-type is uppercase and has parameters', async (t) => {
// A media type is case-insensitive and may carry parameters (RFC 9110 §8.3.1),
// so `Text/Event-Stream;charset=UTF-8` must be treated as SSE. The parsing is
// covered deterministically in test/sse-content-type.test.js; this only
// confirms it flows through the http2 path, the same shape as the sibling
// above — no timers, no clock race.
const target = Fastify({ http2: true, sessionTimeout: 0 })

target.get('/', (_request, reply) => {
t.assert.ok('request arrives')

reply.hijack()
reply.raw.writeHead(200, { 'content-type': 'Text/Event-Stream;charset=UTF-8' })
reply.raw.end('data: hello\n\n')
})

await target.listen({ port: 0 })

const instance = Fastify()

instance.register(From, {
base: `http://localhost:${target.server.address().port}`,
http2: { sessionTimeout: 100 }
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.server.address().port}/`)
})

await instance.listen({ port: 0 })

// instance must close before target: the SSE response disarms the plugin's
// http2 session timeout, and before Node 24 an http2 server waits in close()
// for open sessions — only instance.close() destroys that session.
t.after(() => instance.close())
t.after(() => target.close())

const { statusCode, body } = await request(`http://localhost:${instance.server.address().port}/`, { dispatcher: new Agent({ pipelining: 0 }) })
t.assert.strictEqual(statusCode, 200)
t.assert.strictEqual(await body.text(), 'data: hello\n\n')
})
38 changes: 38 additions & 0 deletions test/sse-content-type.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict'

const { test } = require('node:test')
const { isServerSentEvents } = require('../lib/request')

// The regression this guards is purely about parsing the response content-type:
// a media type is case-insensitive and may carry parameters (RFC 9110 §8.3.1),
// so `text/event-stream; charset=utf-8` is the same type as `text/event-stream`.
// This is a pure function, so it is tested without a server, a socket or a
// timer — the strict `=== 'text/event-stream'` this replaced fails every case
// below except the first, which is exactly the bug.

test('isServerSentEvents recognises the bare media type', (t) => {
t.assert.strictEqual(isServerSentEvents('text/event-stream'), true)
})

test('isServerSentEvents recognises a media type with parameters', (t) => {
// Starlette's EventSourceResponse and Spring both emit this.
t.assert.strictEqual(isServerSentEvents('text/event-stream; charset=utf-8'), true)
t.assert.strictEqual(isServerSentEvents('text/event-stream;charset=utf-8'), true)
})

test('isServerSentEvents is case-insensitive on type and parameters', (t) => {
t.assert.strictEqual(isServerSentEvents('Text/Event-Stream'), true)
t.assert.strictEqual(isServerSentEvents('Text/Event-Stream;charset=UTF-8'), true)
})

test('isServerSentEvents rejects other media types', (t) => {
t.assert.strictEqual(isServerSentEvents('application/json'), false)
t.assert.strictEqual(isServerSentEvents('text/plain'), false)
// Not a prefix match: a longer type that merely starts the same must not pass.
t.assert.strictEqual(isServerSentEvents('text/event-stream-plus'), false)
})

test('isServerSentEvents tolerates a missing or empty content-type', (t) => {
t.assert.strictEqual(isServerSentEvents(undefined), false)
t.assert.strictEqual(isServerSentEvents(''), false)
})