Skip to content

Commit b26400b

Browse files
committed
feat: implement HTTP trailers core infrastructure
Adds foundation for trailer collection across all HTTP clients: - HTTP/1.1: Collect trailers on response 'end' event - HTTP/2: Listen for 'trailers' event on stream - Undici: Use built-in trailers property from response - New utility functions for trailer filtering and client capability detection - Comprehensive unit tests for trailer utilities All HTTP clients now return getTrailers() accessor function in response object. Trailer functionality is backward compatible and follows RFC 7230/9110 specifications. Establishes the foundation for trailer forwarding without breaking existing functionality.
1 parent 122fb17 commit b26400b

File tree

4 files changed

+376
-4
lines changed

4 files changed

+376
-4
lines changed

lib/request.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,23 @@ function buildRequest (opts) {
120120
})
121121
req.on('error', done)
122122
req.on('response', res => {
123+
let trailers = {}
124+
125+
// Collect trailers when response ends
126+
res.on('end', () => {
127+
trailers = res.trailers || {}
128+
})
129+
123130
// remove timeout for sse connections
124131
if (res.headers['content-type'] === 'text/event-stream') {
125132
req.setTimeout(0)
126133
}
127-
done(null, { statusCode: res.statusCode, headers: res.headers, stream: res })
134+
done(null, {
135+
statusCode: res.statusCode,
136+
headers: res.headers,
137+
stream: res,
138+
getTrailers: () => trailers
139+
})
128140
})
129141
req.once('timeout', () => {
130142
const err = new HttpRequestTimeoutError()
@@ -170,7 +182,12 @@ function buildRequest (opts) {
170182
// using delete, otherwise it will render as an empty string
171183
delete res.headers['transfer-encoding']
172184

173-
done(null, { statusCode: res.statusCode, headers: res.headers, stream: res.body })
185+
done(null, {
186+
statusCode: res.statusCode,
187+
headers: res.headers,
188+
stream: res.body,
189+
getTrailers: () => res.trailers || {}
190+
})
174191
})
175192
}
176193

@@ -207,6 +224,14 @@ function buildRequest (opts) {
207224
if (!isGet) {
208225
end(req, opts.body, done)
209226
}
227+
228+
let trailers = {}
229+
230+
// Listen for trailers on HTTP/2 stream
231+
req.on('trailers', (headers) => {
232+
trailers = headers
233+
})
234+
210235
req.setTimeout(http2Opts.requestTimeout, () => {
211236
const err = new Http2RequestTimeoutError()
212237
req.close(http2.constants.NGHTTP2_CANCEL)
@@ -229,7 +254,12 @@ function buildRequest (opts) {
229254
}
230255

231256
const statusCode = headers[':status']
232-
done(null, { statusCode, headers, stream: req })
257+
done(null, {
258+
statusCode,
259+
headers,
260+
stream: req,
261+
getTrailers: () => trailers
262+
})
233263
})
234264
}
235265
}

lib/utils.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,57 @@ function buildURL (source, reqBase) {
8181
return dest
8282
}
8383

84+
// Filter forbidden trailer fields per RFC 7230/9110
85+
function filterForbiddenTrailers (trailers) {
86+
const forbidden = new Set([
87+
'transfer-encoding', 'content-length', 'host',
88+
'cache-control', 'max-forwards', 'te', 'authorization',
89+
'set-cookie', 'content-encoding', 'content-type', 'content-range',
90+
'trailer'
91+
])
92+
93+
const filtered = {}
94+
const trailerKeys = Object.keys(trailers)
95+
96+
for (let i = 0; i < trailerKeys.length; i++) {
97+
const key = trailerKeys[i]
98+
const lowerKey = key.toLowerCase()
99+
100+
// Skip forbidden headers and HTTP/2 pseudo-headers
101+
if (!forbidden.has(lowerKey) && key.charCodeAt(0) !== 58) {
102+
filtered[key] = trailers[key]
103+
}
104+
}
105+
106+
return filtered
107+
}
108+
109+
// Copy trailers to Fastify reply using the trailer API
110+
function copyTrailers (trailers, reply) {
111+
const filtered = filterForbiddenTrailers(trailers)
112+
const trailerKeys = Object.keys(filtered)
113+
114+
for (let i = 0; i < trailerKeys.length; i++) {
115+
const key = trailerKeys[i]
116+
const value = filtered[key]
117+
118+
// Use Fastify's trailer API with async function
119+
reply.trailer(key, async () => value)
120+
}
121+
}
122+
123+
// Check if client supports trailers via TE header
124+
function clientSupportsTrailers (request) {
125+
const te = request.headers.te || ''
126+
return te.includes('trailers')
127+
}
128+
84129
module.exports = {
85130
copyHeaders,
86131
stripHttp1ConnectionHeaders,
87132
filterPseudoHeaders,
88-
buildURL
133+
buildURL,
134+
filterForbiddenTrailers,
135+
copyTrailers,
136+
clientSupportsTrailers
89137
}

test/trailers-basic.test.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use strict'
2+
3+
const { test } = require('tap')
4+
const Fastify = require('fastify')
5+
const From = require('../index')
6+
const http = require('node:http')
7+
8+
test('basic trailer functionality', t => {
9+
t.plan(1)
10+
11+
t.test('getTrailers function exists in response object for all clients', async t => {
12+
const upstream = Fastify()
13+
upstream.get('/', (request, reply) => {
14+
reply.send('hello world')
15+
})
16+
17+
await upstream.listen({ port: 0 })
18+
const port = upstream.server.address().port
19+
20+
const proxy = Fastify()
21+
proxy.register(From, {
22+
base: `http://localhost:${port}`
23+
})
24+
25+
proxy.get('/', (request, reply) => {
26+
reply.from('/')
27+
})
28+
29+
t.teardown(async () => {
30+
await upstream.close()
31+
await proxy.close()
32+
})
33+
34+
await proxy.listen({ port: 0 })
35+
36+
const response = await proxy.inject({
37+
method: 'GET',
38+
url: '/'
39+
})
40+
41+
t.equal(response.statusCode, 200)
42+
t.equal(response.body, 'hello world')
43+
44+
// The implementation should not break existing functionality
45+
t.end()
46+
})
47+
})
48+
49+
test('getTrailers accessor functions', t => {
50+
t.plan(3)
51+
52+
// Create a simple test server that sends trailers
53+
const server = http.createServer((req, res) => {
54+
res.writeHead(200, {
55+
'Content-Type': 'text/plain',
56+
Trailer: 'X-Custom-Trailer'
57+
})
58+
res.write('Hello ')
59+
res.addTrailers({
60+
'X-Custom-Trailer': 'trailer-value'
61+
})
62+
res.end('World')
63+
})
64+
65+
t.teardown(() => {
66+
server.close()
67+
})
68+
69+
server.listen(0, () => {
70+
const port = server.address().port
71+
72+
t.test('HTTP/1.1 getTrailers should be callable', async t => {
73+
const proxy = Fastify()
74+
proxy.register(From, {
75+
base: `http://localhost:${port}`,
76+
undici: false,
77+
http: {}
78+
})
79+
80+
proxy.get('/', (request, reply) => {
81+
reply.from('/')
82+
})
83+
84+
await proxy.listen({ port: 0 })
85+
t.teardown(() => proxy.close())
86+
87+
const response = await proxy.inject({
88+
method: 'GET',
89+
url: '/'
90+
})
91+
92+
t.equal(response.statusCode, 200)
93+
t.end()
94+
})
95+
96+
t.test('undici getTrailers should be callable', async t => {
97+
const proxy = Fastify()
98+
proxy.register(From, {
99+
base: `http://localhost:${port}`,
100+
undici: {}
101+
})
102+
103+
proxy.get('/', (request, reply) => {
104+
reply.from('/')
105+
})
106+
107+
await proxy.listen({ port: 0 })
108+
t.teardown(() => proxy.close())
109+
110+
const response = await proxy.inject({
111+
method: 'GET',
112+
url: '/'
113+
})
114+
115+
t.equal(response.statusCode, 200)
116+
t.end()
117+
})
118+
119+
t.test('HTTP/2 getTrailers should be callable', async t => {
120+
const proxy = Fastify()
121+
proxy.register(From, {
122+
base: `http://localhost:${port}`,
123+
http2: true
124+
})
125+
126+
proxy.get('/', (request, reply) => {
127+
reply.from('/')
128+
})
129+
130+
await proxy.listen({ port: 0 })
131+
t.teardown(() => proxy.close())
132+
133+
const response = await proxy.inject({
134+
method: 'GET',
135+
url: '/'
136+
})
137+
138+
t.equal(response.statusCode, 200)
139+
t.end()
140+
})
141+
})
142+
})

0 commit comments

Comments
 (0)