Skip to content

Commit 4d08718

Browse files
committed
http: add 'sendingHeaders' event for response header manipulation
Signed-off-by: Sebastian Beltran <bjohansebas@gmail.com>
1 parent a1074b8 commit 4d08718

3 files changed

Lines changed: 188 additions & 1 deletion

File tree

doc/api/http.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2086,6 +2086,57 @@ emitted when the last segment of the response headers and body have been
20862086
handed off to the operating system for transmission over the network. It
20872087
does not imply that the client has received anything yet.
20882088

2089+
### Event: `'sendingHeaders'`
2090+
2091+
<!-- YAML
2092+
added: REPLACEME
2093+
-->
2094+
2095+
Emitted synchronously, exactly once, immediately before the status line and
2096+
response headers are serialized and sent to the client. The listener is called
2097+
with `this` bound to the response.
2098+
2099+
This is the moment at which the response is about to become committed: when the
2100+
event fires, [`response.headersSent`][] is still `false`, so a listener may make
2101+
final changes to the outgoing message. From inside the listener it is valid to:
2102+
2103+
* read headers with [`response.getHeader()`][] / [`response.getHeaders()`][];
2104+
* add, replace, or remove headers with [`response.setHeader()`][],
2105+
[`outgoingMessage.appendHeader()`][], and [`response.removeHeader()`][];
2106+
* change [`response.statusCode`][] and [`response.statusMessage`][].
2107+
2108+
The event is emitted regardless of how the headers are flushed: an explicit call
2109+
to [`response.writeHead()`][], implicit headers triggered by the first
2110+
[`response.write()`][] or [`response.end()`][], or [`response.flushHeaders()`][].
2111+
Because it is a single emission point, multiple independent listeners (for
2112+
example logging, session, and content-negotiation middleware) can each register
2113+
without coordinating with one another. Listeners run in registration order.
2114+
2115+
```js
2116+
const server = http.createServer((req, res) => {
2117+
res.on('sendingHeaders', function() {
2118+
// Set a header at the last possible moment, based on final state.
2119+
this.setHeader('X-Response-Time', `${Date.now() - req.startTime}ms`);
2120+
if (!this.getHeader('Content-Type')) {
2121+
this.setHeader('Content-Type', 'text/plain');
2122+
}
2123+
});
2124+
2125+
res.end('hello');
2126+
});
2127+
```
2128+
2129+
A header passed inline to `response.writeHead()` is visible to the listener and
2130+
can be modified there, including the array form:
2131+
2132+
```js
2133+
res.on('sendingHeaders', function() {
2134+
// 'X-Inline' was passed to writeHead() below; it can still be removed here.
2135+
this.removeHeader('X-Inline');
2136+
});
2137+
res.writeHead(200, ['X-Inline', 'value', 'Content-Type', 'text/plain']);
2138+
```
2139+
20892140
### `response.addTrailers(headers)`
20902141

20912142
<!-- YAML
@@ -4741,6 +4792,7 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
47414792
[`net.Socket`]: net.md#class-netsocket
47424793
[`net.createConnection()`]: net.md#netcreateconnectionoptions-connectlistener
47434794
[`new URL()`]: url.md#new-urlinput-base
4795+
[`outgoingMessage.appendHeader()`]: #outgoingmessageappendheadername-value
47444796
[`outgoingMessage.setHeader(name, value)`]: #outgoingmessagesetheadername-value
47454797
[`outgoingMessage.setHeaders()`]: #outgoingmessagesetheadersheaders
47464798
[`outgoingMessage.socket`]: #outgoingmessagesocket
@@ -4758,7 +4810,13 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
47584810
[`request.writableFinished`]: #requestwritablefinished
47594811
[`request.write(data, encoding)`]: #requestwritechunk-encoding-callback
47604812
[`response.end()`]: #responseenddata-encoding-callback
4813+
[`response.flushHeaders()`]: #responseflushheaders
47614814
[`response.getHeader()`]: #responsegetheadername
4815+
[`response.getHeaders()`]: #responsegetheaders
4816+
[`response.headersSent`]: #responseheaderssent
4817+
[`response.removeHeader()`]: #responseremoveheadername
4818+
[`response.statusCode`]: #responsestatuscode
4819+
[`response.statusMessage`]: #responsestatusmessage
47624820
[`response.setHeader()`]: #responsesetheadername-value
47634821
[`response.socket`]: #responsesocket
47644822
[`response.strictContentLength`]: #responsestrictcontentlength

lib/_http_server.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,8 +429,14 @@ function writeHead(statusCode, reason, obj) {
429429
}
430430
this.statusCode = statusCode;
431431

432+
// When a pre-serialization headers listener is registered we always
433+
// materialize the header collection (kOutHeaders), including headers passed
434+
// inline to writeHead(), so the listener gets a single, unified, mutable
435+
// view of every outgoing header.
436+
const hasHeadersListeners = this.listenerCount('sendingHeaders') > 0;
437+
432438
let headers;
433-
if (this[kOutHeaders]) {
439+
if (this[kOutHeaders] || hasHeadersListeners) {
434440
// Slow-case: when progressive API and header fields are passed.
435441
let k;
436442
if (ArrayIsArray(obj)) {
@@ -467,6 +473,24 @@ function writeHead(statusCode, reason, obj) {
467473
headers = obj;
468474
}
469475

476+
// Fire the pre-serialization hook. Listeners run synchronously with `this`
477+
// bound to the response, immediately before the status line and header block
478+
// are serialized to the wire. They may still mutate headers via
479+
// setHeader()/appendHeader()/removeHeader() and change statusCode/
480+
// statusMessage. It is a single choke point reached by every code path
481+
// (explicit writeHead(), implicit headers from write()/end(), and
482+
// flushHeaders()).
483+
if (hasHeadersListeners) {
484+
this.emit('sendingHeaders');
485+
// Re-read state a listener may have changed. Assigning `res.statusCode`
486+
// does not validate, so re-check the range after the listener ran.
487+
headers = this[kOutHeaders];
488+
statusCode = this.statusCode | 0;
489+
if (statusCode < 100 || statusCode > 999) {
490+
throw new ERR_HTTP_INVALID_STATUS_CODE(statusCode);
491+
}
492+
}
493+
470494
if (checkInvalidHeaderChar(this.statusMessage))
471495
throw new ERR_INVALID_CHAR('statusMessage');
472496

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const http = require('http');
6+
7+
// 1) Fires once, before serialization, for an implicit-header response (end()).
8+
{
9+
const server = http.createServer((req, res) => {
10+
let fired = 0;
11+
res.on('sendingHeaders', common.mustCall(function() {
12+
fired++;
13+
// Headers not yet sent, still mutable.
14+
assert.strictEqual(this.headersSent, false);
15+
this.setHeader('X-Added-In-Hook', 'yes');
16+
}));
17+
res.end('body');
18+
// Synchronous: hook has already run by the time end() returns.
19+
assert.strictEqual(fired, 1);
20+
});
21+
22+
server.listen(0, common.mustCall(() => {
23+
http.get({ port: server.address().port }, common.mustCall((res) => {
24+
assert.strictEqual(res.headers['x-added-in-hook'], 'yes');
25+
res.resume().on('end', common.mustCall(() => server.close()));
26+
}));
27+
}));
28+
}
29+
30+
// 2) Listener may change the status code, and it is reflected on the wire.
31+
{
32+
const server = http.createServer((req, res) => {
33+
res.on('sendingHeaders', common.mustCall(function() {
34+
this.statusCode = 503;
35+
this.statusMessage = 'Service Unavailable';
36+
}));
37+
res.writeHead(200);
38+
res.end();
39+
});
40+
41+
server.listen(0, common.mustCall(() => {
42+
http.get({ port: server.address().port }, common.mustCall((res) => {
43+
assert.strictEqual(res.statusCode, 503);
44+
res.resume().on('end', common.mustCall(() => server.close()));
45+
}));
46+
}));
47+
}
48+
49+
// 3) Listener sees and can remove headers passed INLINE to writeHead(),
50+
// including the array form.
51+
{
52+
const server = http.createServer((req, res) => {
53+
res.on('sendingHeaders', common.mustCall(function() {
54+
assert.strictEqual(this.getHeader('X-Inline'), 'a');
55+
this.removeHeader('X-Inline');
56+
this.setHeader('X-From-Hook', 'b');
57+
}));
58+
res.writeHead(200, ['X-Inline', 'a', 'Content-Type', 'text/plain']);
59+
res.end('ok');
60+
});
61+
62+
server.listen(0, common.mustCall(() => {
63+
http.get({ port: server.address().port }, common.mustCall((res) => {
64+
assert.strictEqual(res.headers['x-inline'], undefined);
65+
assert.strictEqual(res.headers['x-from-hook'], 'b');
66+
assert.strictEqual(res.headers['content-type'], 'text/plain');
67+
res.resume().on('end', common.mustCall(() => server.close()));
68+
}));
69+
}));
70+
}
71+
72+
// 4) Multiple listeners all run (FIFO, EventEmitter semantics).
73+
{
74+
const order = [];
75+
const server = http.createServer((req, res) => {
76+
res.on('sendingHeaders', common.mustCall(() => order.push(1)));
77+
res.on('sendingHeaders', common.mustCall(() => order.push(2)));
78+
res.end();
79+
assert.deepStrictEqual(order, [1, 2]);
80+
});
81+
82+
server.listen(0, common.mustCall(() => {
83+
http.get({ port: server.address().port }, common.mustCall((res) => {
84+
res.resume().on('end', common.mustCall(() => server.close()));
85+
}));
86+
}));
87+
}
88+
89+
// 5) flushHeaders() also triggers the hook exactly once.
90+
{
91+
const server = http.createServer((req, res) => {
92+
res.on('sendingHeaders', common.mustCall(function() {
93+
this.setHeader('X-Flushed', '1');
94+
}));
95+
res.flushHeaders();
96+
res.end();
97+
});
98+
99+
server.listen(0, common.mustCall(() => {
100+
http.get({ port: server.address().port }, common.mustCall((res) => {
101+
assert.strictEqual(res.headers['x-flushed'], '1');
102+
res.resume().on('end', common.mustCall(() => server.close()));
103+
}));
104+
}));
105+
}

0 commit comments

Comments
 (0)