Skip to content

Commit af03c6b

Browse files
committed
http: add writeInformation to send arbitrary 1xx status codes
Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent b5da751 commit af03c6b

7 files changed

Lines changed: 306 additions & 34 deletions

File tree

doc/api/http.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2702,6 +2702,35 @@ been transmitted are equal or not.
27022702
Attempting to set a header field name or value that contains invalid characters
27032703
will result in a [`TypeError`][] being thrown.
27042704

2705+
### `response.writeInformation(statusCode[, headers][, callback])`
2706+
2707+
<!-- YAML
2708+
added: REPLACEME
2709+
-->
2710+
2711+
* `statusCode` {number} An HTTP 1xx informational status code, between `100`
2712+
and `199` inclusive, excluding `101` (Switching Protocols) which is only
2713+
available through the [`'upgrade'`][] event.
2714+
* `headers` {Object|Array} An optional set of headers to send with the
2715+
informational response. Accepts the same shapes as
2716+
[`response.writeHead()`][].
2717+
* `callback` {Function} Optional, called once the message has been written
2718+
to the socket.
2719+
2720+
Sends an arbitrary HTTP/1.1 1xx informational response to the client. This
2721+
is a generic equivalent of [`response.writeContinue()`][],
2722+
[`response.writeProcessing()`][] and [`response.writeEarlyHints()`][], and
2723+
can be called multiple times before the final response. After the final
2724+
response headers have been sent (via [`response.writeHead()`][] or an
2725+
implicit header), calling this method throws `ERR_HTTP_HEADERS_SENT`.
2726+
2727+
Clients receive these responses via the [`'information'`][information event]
2728+
event on `http.ClientRequest`.
2729+
2730+
```js
2731+
response.writeInformation(110, { 'X-Progress': '50%' });
2732+
```
2733+
27052734
### `response.writeProcessing()`
27062735

27072736
<!-- YAML
@@ -4712,7 +4741,9 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
47124741
[`response.write()`]: #responsewritechunk-encoding-callback
47134742
[`response.write(data, encoding)`]: #responsewritechunk-encoding-callback
47144743
[`response.writeContinue()`]: #responsewritecontinue
4744+
[`response.writeEarlyHints()`]: #responsewriteearlyhintshints-callback
47154745
[`response.writeHead()`]: #responsewriteheadstatuscode-statusmessage-headers
4746+
[`response.writeProcessing()`]: #responsewriteprocessing
47164747
[`server.close()`]: #serverclosecallback
47174748
[`server.headersTimeout`]: #serverheaderstimeout
47184749
[`server.keepAliveTimeoutBuffer`]: #serverkeepalivetimeoutbuffer
@@ -4733,4 +4764,5 @@ const agent2 = new http.Agent({ proxyEnv: process.env });
47334764
[`writable.destroyed`]: stream.md#writabledestroyed
47344765
[`writable.uncork()`]: stream.md#writableuncork
47354766
[`writable.write()`]: stream.md#writablewritechunk-encoding-callback
4767+
[information event]: #event-information
47364768
[initial delay]: net.md#socketsetkeepaliveenable-initialdelay

doc/api/http2.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4848,6 +4848,30 @@ response.writeEarlyHints({
48484848
});
48494849
```
48504850

4851+
#### `response.writeInformation(statusCode[, headers])`
4852+
4853+
<!-- YAML
4854+
added: REPLACEME
4855+
-->
4856+
4857+
* `statusCode` {number} An HTTP 1xx informational status code, between `100`
4858+
and `199` inclusive, excluding `101` (Switching Protocols) which is not
4859+
allowed in HTTP/2.
4860+
* `headers` {Object} An optional object of headers to send with the
4861+
informational response.
4862+
4863+
Sends an arbitrary HTTP 1xx informational response, equivalent in HTTP/2 to a
4864+
`HEADERS` frame whose `:status` pseudo-header is a 1xx code. May be called
4865+
multiple times before the final response. After the final response headers
4866+
have been sent, this method is a no-op and returns `false`.
4867+
4868+
This is the generic equivalent of [`response.writeContinue()`][] and
4869+
[`response.writeEarlyHints()`][].
4870+
4871+
```js
4872+
response.writeInformation(110, { 'X-Progress': '50%' });
4873+
```
4874+
48514875
#### `response.writeHead(statusCode[, statusMessage][, headers])`
48524876

48534877
<!-- YAML
@@ -5057,6 +5081,7 @@ you need to implement any fall-back behavior yourself.
50575081
[`response.write()`]: #responsewritechunk-encoding-callback
50585082
[`response.write(data, encoding)`]: http.md#responsewritechunk-encoding-callback
50595083
[`response.writeContinue()`]: #responsewritecontinue
5084+
[`response.writeEarlyHints()`]: #responsewriteearlyhintshints
50605085
[`response.writeHead()`]: #responsewriteheadstatuscode-statusmessage-headers
50615086
[`server.close()`]: #serverclosecallback
50625087
[`server.maxHeadersCount`]: http.md#servermaxheaderscount

lib/_http_server.js

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -311,18 +311,66 @@ ServerResponse.prototype.detachSocket = function detachSocket(socket) {
311311
this.socket = null;
312312
};
313313

314+
ServerResponse.prototype.writeInformation = function writeInformation(
315+
statusCode, headers, cb) {
316+
if (this._header) {
317+
throw new ERR_HTTP_HEADERS_SENT('write');
318+
}
319+
320+
validateInteger(statusCode, 'statusCode', 100, 199);
321+
if (statusCode === 101) {
322+
throw new ERR_HTTP_INVALID_STATUS_CODE(statusCode);
323+
}
324+
325+
const statusMessage = STATUS_CODES[statusCode] || 'unknown';
326+
let head = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`;
327+
328+
if (headers !== undefined && headers !== null) {
329+
if (ArrayIsArray(headers)) {
330+
if (headers.length && ArrayIsArray(headers[0])) {
331+
for (let i = 0; i < headers.length; i++) {
332+
const entry = headers[i];
333+
head += processInformationHeader(entry[0], entry[1]);
334+
}
335+
} else {
336+
if (headers.length % 2 !== 0) {
337+
throw new ERR_INVALID_ARG_VALUE('headers', headers);
338+
}
339+
for (let i = 0; i < headers.length; i += 2) {
340+
head += processInformationHeader(headers[i], headers[i + 1]);
341+
}
342+
}
343+
} else {
344+
validateObject(headers, 'headers');
345+
const keys = ObjectKeys(headers);
346+
for (let i = 0; i < keys.length; i++) {
347+
const key = keys[i];
348+
head += processInformationHeader(key, headers[key]);
349+
}
350+
}
351+
}
352+
353+
head += '\r\n';
354+
355+
return this._writeRaw(head, 'ascii', cb);
356+
};
357+
358+
function processInformationHeader(name, value) {
359+
validateHeaderName(name);
360+
validateHeaderValue(name, value);
361+
return `${name}: ${value}\r\n`;
362+
}
363+
314364
ServerResponse.prototype.writeContinue = function writeContinue(cb) {
315-
this._writeRaw('HTTP/1.1 100 Continue\r\n\r\n', 'ascii', cb);
365+
this.writeInformation(100, null, cb);
316366
this._sent100 = true;
317367
};
318368

319369
ServerResponse.prototype.writeProcessing = function writeProcessing(cb) {
320-
this._writeRaw('HTTP/1.1 102 Processing\r\n\r\n', 'ascii', cb);
370+
this.writeInformation(102, null, cb);
321371
};
322372

323373
ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(hints, cb) {
324-
let head = 'HTTP/1.1 103 Early Hints\r\n';
325-
326374
validateObject(hints, 'hints');
327375

328376
if (hints.link === null || hints.link === undefined) {
@@ -339,22 +387,16 @@ ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(hints, cb) {
339387
throw new ERR_INVALID_CHAR('header content', 'Link');
340388
}
341389

342-
head += 'Link: ' + link + '\r\n';
343-
390+
const headers = { __proto__: null, Link: link };
344391
const keys = ObjectKeys(hints);
345392
for (let i = 0; i < keys.length; i++) {
346393
const key = keys[i];
347394
if (key !== 'link') {
348-
validateHeaderName(key);
349-
const value = hints[key];
350-
validateHeaderValue(key, value);
351-
head += key + ': ' + value + '\r\n';
395+
headers[key] = hints[key];
352396
}
353397
}
354398

355-
head += '\r\n';
356-
357-
this._writeRaw(head, 'ascii', cb);
399+
this.writeInformation(103, headers, cb);
358400
};
359401

360402
ServerResponse.prototype._implicitHeader = function _implicitHeader() {

lib/internal/http2/compat.js

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -901,17 +901,39 @@ class Http2ServerResponse extends Stream {
901901
this[kStream].respond(headers, options);
902902
}
903903

904-
// TODO doesn't support callbacks
905-
writeContinue() {
904+
writeInformation(statusCode, headers) {
905+
if (typeof statusCode !== 'number' ||
906+
statusCode < 100 || statusCode > 199) {
907+
throw new ERR_HTTP2_STATUS_INVALID(statusCode);
908+
}
909+
if (statusCode === 101) {
910+
throw new ERR_HTTP2_STATUS_INVALID(statusCode);
911+
}
912+
906913
const stream = this[kStream];
914+
907915
if (stream.headersSent || this[kState].closed)
908916
return false;
909-
stream.additionalHeaders({
910-
[HTTP2_HEADER_STATUS]: HTTP_STATUS_CONTINUE,
911-
});
917+
918+
const outHeaders = { __proto__: null };
919+
if (headers !== undefined && headers !== null) {
920+
validateObject(headers, 'headers');
921+
const keys = ObjectKeys(headers);
922+
for (let i = 0; i < keys.length; i++) {
923+
outHeaders[keys[i]] = headers[keys[i]];
924+
}
925+
}
926+
outHeaders[HTTP2_HEADER_STATUS] = statusCode;
927+
928+
stream.additionalHeaders(outHeaders);
912929
return true;
913930
}
914931

932+
// TODO doesn't support callbacks
933+
writeContinue() {
934+
return this.writeInformation(HTTP_STATUS_CONTINUE);
935+
}
936+
915937
writeEarlyHints(hints) {
916938
validateObject(hints, 'hints');
917939

@@ -929,18 +951,9 @@ class Http2ServerResponse extends Stream {
929951
return false;
930952
}
931953

932-
const stream = this[kStream];
954+
headers.Link = linkHeaderValue;
933955

934-
if (stream.headersSent || this[kState].closed)
935-
return false;
936-
937-
stream.additionalHeaders({
938-
...headers,
939-
[HTTP2_HEADER_STATUS]: HTTP_STATUS_EARLY_HINTS,
940-
'Link': linkHeaderValue,
941-
});
942-
943-
return true;
956+
return this.writeInformation(HTTP_STATUS_EARLY_HINTS, headers);
944957
}
945958
}
946959

test/parallel/test-http-information-headers.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ const countdown = new Countdown(2, () => server.close());
99

1010
const server = http.createServer(common.mustCallAtLeast((req, res) => {
1111
console.error('Server sending informational message #1...');
12-
// These function calls may rewritten as necessary
13-
// to call res.writeHead instead
14-
res._writeRaw('HTTP/1.1 102 Processing\r\n');
15-
res._writeRaw('Foo: Bar\r\n');
16-
res._writeRaw('\r\n', common.mustCall());
12+
res.writeInformation(102, { Foo: 'Bar' }, common.mustCall());
1713
console.error('Server sending full response...');
1814
res.writeHead(200, {
1915
'Content-Type': 'text/plain',
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
const common = require('../common');
3+
const assert = require('node:assert');
4+
const http = require('node:http');
5+
6+
// Happy flow: arbitrary 1xx status with custom headers, observed by the
7+
// client's 'information' event.
8+
{
9+
const server = http.createServer(common.mustCall((req, res) => {
10+
res.writeInformation(110, { 'X-Progress': '50%', 'X-Stage': 'reading' });
11+
res.writeInformation(199, [['X-Custom', 'one'], ['X-Custom-2', 'two']]);
12+
res.end('done');
13+
}));
14+
15+
server.listen(0, common.mustCall(() => {
16+
const req = http.request({ port: server.address().port });
17+
18+
const seen = [];
19+
req.on('information', (res) => {
20+
seen.push({
21+
statusCode: res.statusCode,
22+
headers: res.headers,
23+
});
24+
});
25+
26+
req.on('response', common.mustCall((res) => {
27+
assert.strictEqual(res.statusCode, 200);
28+
29+
assert.strictEqual(seen.length, 2);
30+
assert.strictEqual(seen[0].statusCode, 110);
31+
assert.strictEqual(seen[0].headers['x-progress'], '50%');
32+
assert.strictEqual(seen[0].headers['x-stage'], 'reading');
33+
assert.strictEqual(seen[1].statusCode, 199);
34+
assert.strictEqual(seen[1].headers['x-custom'], 'one');
35+
assert.strictEqual(seen[1].headers['x-custom-2'], 'two');
36+
37+
res.resume();
38+
res.on('end', common.mustCall(() => server.close()));
39+
}));
40+
41+
req.end();
42+
}));
43+
}
44+
45+
// Headers argument is optional / nullable.
46+
{
47+
const server = http.createServer(common.mustCall((req, res) => {
48+
res.writeInformation(150);
49+
res.writeInformation(151, null);
50+
res.end();
51+
}));
52+
53+
server.listen(0, common.mustCall(() => {
54+
const req = http.request({ port: server.address().port });
55+
let count = 0;
56+
req.on('information', () => count++);
57+
req.on('response', common.mustCall((res) => {
58+
assert.strictEqual(count, 2);
59+
res.resume();
60+
res.on('end', common.mustCall(() => server.close()));
61+
}));
62+
req.end();
63+
}));
64+
}
65+
66+
// Error cases.
67+
{
68+
const server = http.createServer(common.mustCall((req, res) => {
69+
assert.throws(() => res.writeInformation(101),
70+
{ code: 'ERR_HTTP_INVALID_STATUS_CODE' });
71+
assert.throws(() => res.writeInformation(99),
72+
{ code: 'ERR_OUT_OF_RANGE' });
73+
assert.throws(() => res.writeInformation(200),
74+
{ code: 'ERR_OUT_OF_RANGE' });
75+
assert.throws(() => res.writeInformation('100'),
76+
{ code: 'ERR_INVALID_ARG_TYPE' });
77+
assert.throws(() => res.writeInformation(150, { 'X-Bad\n': 'v' }),
78+
{ code: 'ERR_INVALID_HTTP_TOKEN' });
79+
80+
res.writeHead(200);
81+
assert.throws(() => res.writeInformation(150),
82+
{ code: 'ERR_HTTP_HEADERS_SENT' });
83+
res.end();
84+
}));
85+
86+
server.listen(0, common.mustCall(() => {
87+
const req = http.request({ port: server.address().port });
88+
req.on('response', common.mustCall((res) => {
89+
res.resume();
90+
res.on('end', common.mustCall(() => server.close()));
91+
}));
92+
req.end();
93+
}));
94+
}

0 commit comments

Comments
 (0)