http: add writeEarlyHints function to ServerResponse

Co-Authored-By: Matteo Collina <matteo.collina@gmail.com>
Co-Authored-By: Livia Medeiros <livia@cirno.name>
PR-URL: https://github.com/nodejs/node/pull/44180
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: LiviaMedeiros <livia@cirno.name>
This commit is contained in:
Wing 2022-08-17 20:22:53 +02:00 committed by GitHub
parent 97e8bda5b2
commit 58ab0e2821
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 576 additions and 2 deletions

View File

@ -2129,10 +2129,41 @@ buffer. Returns `false` if all or part of the data was queued in user memory.
added: v0.3.0
-->
Sends a HTTP/1.1 100 Continue message to the client, indicating that
Sends an HTTP/1.1 100 Continue message to the client, indicating that
the request body should be sent. See the [`'checkContinue'`][] event on
`Server`.
### `response.writeEarlyHints(links[, callback])`
<!-- YAML
added: REPLACEME
-->
* `links` {string|Array}
* `callback` {Function}
Sends an HTTP/1.1 103 Early Hints message to the client with a Link header,
indicating that the user agent can preload/preconnect the linked resources.
The `links` can be a string or an array of strings containing the values
of the `Link` header. The optional `callback` argument will be called when
the response message has been written.
**Example**
```js
const earlyHintsLink = '</styles.css>; rel=preload; as=style';
response.writeEarlyHints(earlyHintsLink);
const earlyHintsLinks = [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
];
response.writeEarlyHints(earlyHintsLinks);
const earlyHintsCallback = () => console.log('early hints message sent');
response.writeEarlyHints(earlyHintsLinks, earlyHintsCallback);
```
### `response.writeHead(statusCode[, statusMessage][, headers])`
<!-- YAML

View File

@ -4005,6 +4005,32 @@ Sends a status `100 Continue` to the client, indicating that the request body
should be sent. See the [`'checkContinue'`][] event on `Http2Server` and
`Http2SecureServer`.
### `response.writeEarlyHints(links)`
<!-- YAML
added: REPLACEME
-->
* `links` {string|Array}
Sends a status `103 Early Hints` to the client with a Link header,
indicating that the user agent can preload/preconnect the linked resources.
The `links` can be a string or an array of strings containing the values
of the `Link` header.
**Example**
```js
const earlyHintsLink = '</styles.css>; rel=preload; as=style';
response.writeEarlyHints(earlyHintsLink);
const earlyHintsLinks = [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
];
response.writeEarlyHints(earlyHintsLinks);
```
#### `response.writeHead(statusCode[, statusMessage][, headers])`
<!-- YAML

View File

@ -80,7 +80,8 @@ const {
} = codes;
const {
validateInteger,
validateBoolean
validateBoolean,
validateLinkHeaderValue
} = require('internal/validators');
const Buffer = require('buffer').Buffer;
const { setInterval, clearInterval } = require('timers');
@ -295,6 +296,43 @@ ServerResponse.prototype.writeProcessing = function writeProcessing(cb) {
this._writeRaw('HTTP/1.1 102 Processing\r\n\r\n', 'ascii', cb);
};
ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(links, cb) {
let head = 'HTTP/1.1 103 Early Hints\r\n';
if (typeof links === 'string') {
validateLinkHeaderValue(links, 'links');
head += 'Link: ' + links + '\r\n';
} else if (ArrayIsArray(links)) {
if (!links.length) {
return;
}
head += 'Link: ';
for (let i = 0; i < links.length; i++) {
const link = links[i];
validateLinkHeaderValue(link, 'links');
head += link;
if (i !== links.length - 1) {
head += ', ';
}
}
head += '\r\n';
} else {
throw new ERR_INVALID_ARG_VALUE(
'links',
links,
'must be an array or string of format "</styles.css>; rel=preload; as=style"'
);
}
head += '\r\n';
this._writeRaw(head, 'ascii', cb);
};
ServerResponse.prototype._implicitHeader = function _implicitHeader() {
this.writeHead(this.statusCode);
};

View File

@ -32,6 +32,7 @@ const {
HTTP2_HEADER_STATUS,
HTTP_STATUS_CONTINUE,
HTTP_STATUS_EARLY_HINTS,
HTTP_STATUS_EXPECTATION_FAILED,
HTTP_STATUS_METHOD_NOT_ALLOWED,
HTTP_STATUS_OK
@ -55,6 +56,7 @@ const {
const {
validateFunction,
validateString,
validateLinkHeaderValue,
} = require('internal/validators');
const {
kSocket,
@ -844,6 +846,49 @@ class Http2ServerResponse extends Stream {
});
return true;
}
writeEarlyHints(links) {
let linkHeaderValue = '';
if (typeof links === 'string') {
validateLinkHeaderValue(links, 'links');
linkHeaderValue += links;
} else if (ArrayIsArray(links)) {
if (!links.length) {
return;
}
linkHeaderValue += '';
for (let i = 0; i < links.length; i++) {
const link = links[i];
validateLinkHeaderValue(link, 'links');
linkHeaderValue += link;
if (i !== links.length - 1) {
linkHeaderValue += ', ';
}
}
} else {
throw new ERR_INVALID_ARG_VALUE(
'links',
links,
'must be an array or string of format "</styles.css>; rel=preload; as=style"'
);
}
const stream = this[kStream];
if (stream.headersSent || this[kState].closed)
return false;
stream.additionalHeaders({
[HTTP2_HEADER_STATUS]: HTTP_STATUS_EARLY_HINTS,
'Link': linkHeaderValue
});
return true;
}
}
function onServerStream(ServerRequest, ServerResponse,

View File

@ -258,6 +258,21 @@ function validateUnion(value, name, union) {
}
}
function validateLinkHeaderValue(value, name) {
const linkValueRegExp = /^(?:<[^>]*>;)\s*(?:rel=(")?[^;"]*\1;?)\s*(?:(?:as|anchor|title)=(")?[^;"]*\2)?$/;
if (
typeof value === 'undefined' ||
!RegExpPrototypeExec(linkValueRegExp, value)
) {
throw new ERR_INVALID_ARG_VALUE(
name,
value,
'must be an array or string of format "</styles.css>; rel=preload; as=style"'
);
}
}
module.exports = {
isInt32,
isUint32,
@ -280,4 +295,5 @@ module.exports = {
validateUndefined,
validateUnion,
validateAbortSignal,
validateLinkHeaderValue
};

View File

@ -0,0 +1,33 @@
'use strict';
const common = require('../common');
const assert = require('node:assert');
const http = require('node:http');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content\n';
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({ links: 'bad argument object' });
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0, common.mustCall(() => {
const req = http.request({
port: server.address().port, path: '/'
});
req.end();
debug('Client sending request...');
req.on('information', common.mustNotCall());
process.on('uncaughtException', (err) => {
debug(`Caught an exception: ${JSON.stringify(err)}`);
if (err.name === 'AssertionError') throw err;
assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
process.exit(0);
});
}));

View File

@ -0,0 +1,33 @@
'use strict';
const common = require('../common');
const assert = require('node:assert');
const http = require('node:http');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content\n';
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('bad argument value');
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0, common.mustCall(() => {
const req = http.request({
port: server.address().port, path: '/'
});
req.end();
debug('Client sending request...');
req.on('information', common.mustNotCall());
process.on('uncaughtException', (err) => {
debug(`Caught an exception: ${JSON.stringify(err)}`);
if (err.name === 'AssertionError') throw err;
assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
process.exit(0);
});
}));

View File

@ -0,0 +1,135 @@
'use strict';
const common = require('../common');
const assert = require('node:assert');
const http = require('node:http');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content\n';
{
// Happy flow - string argument
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('</styles.css>; rel=preload; as=style');
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0, common.mustCall(() => {
const req = http.request({
port: server.address().port, path: '/'
});
debug('Client sending request...');
req.on('information', common.mustCall((res) => {
assert.strictEqual(res.headers.link, '</styles.css>; rel=preload; as=style');
}));
req.on('response', common.mustCall((res) => {
let body = '';
assert.strictEqual(res.statusCode, 200, `Final status code was ${res.statusCode}, not 200.`);
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(body, testResBody);
server.close();
}));
}));
req.end();
}));
}
{
// Happy flow - array argument
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
]);
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0, common.mustCall(() => {
const req = http.request({
port: server.address().port, path: '/'
});
debug('Client sending request...');
req.on('information', common.mustCall((res) => {
assert.strictEqual(
res.headers.link,
'</styles.css>; rel=preload; as=style, </scripts.js>; rel=preload; as=script'
);
}));
req.on('response', common.mustCall((res) => {
let body = '';
assert.strictEqual(res.statusCode, 200, `Final status code was ${res.statusCode}, not 200.`);
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(body, testResBody);
server.close();
}));
}));
req.end();
}));
}
{
// Happy flow - empty array
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([]);
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0, common.mustCall(() => {
const req = http.request({
port: server.address().port, path: '/'
});
debug('Client sending request...');
req.on('information', common.mustNotCall());
req.on('response', common.mustCall((res) => {
let body = '';
assert.strictEqual(res.statusCode, 200, `Final status code was ${res.statusCode}, not 200.`);
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(body, testResBody);
server.close();
}));
}));
req.end();
}));
}

View File

@ -0,0 +1,38 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('node:assert');
const http2 = require('node:http2');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content';
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({ links: 'bad argument object' });
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
debug('Client sending request...');
req.on('headers', common.mustNotCall());
process.on('uncaughtException', (err) => {
debug(`Caught an exception: ${JSON.stringify(err)}`);
if (err.name === 'AssertionError') throw err;
assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
process.exit(0);
});
}));

View File

@ -0,0 +1,38 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('node:assert');
const http2 = require('node:http2');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content';
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('bad argument value');
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
debug('Client sending request...');
req.on('headers', common.mustNotCall());
process.on('uncaughtException', (err) => {
debug(`Caught an exception: ${JSON.stringify(err)}`);
if (err.name === 'AssertionError') throw err;
assert.strictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
process.exit(0);
});
}));

View File

@ -0,0 +1,141 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('node:assert');
const http2 = require('node:http2');
const debug = require('node:util').debuglog('test');
const testResBody = 'response content';
{
// Happy flow - string argument
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('</styles.css>; rel=preload; as=style');
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
debug('Client sending request...');
req.on('headers', common.mustCall((headers) => {
assert.notStrictEqual(headers, undefined);
assert.strictEqual(headers[':status'], 103);
assert.strictEqual(headers.link, '</styles.css>; rel=preload; as=style');
}));
req.on('response', common.mustCall((headers) => {
assert.strictEqual(headers[':status'], 200);
}));
let data = '';
req.on('data', common.mustCallAtLeast((d) => data += d));
req.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(data, testResBody);
client.close();
server.close();
}));
}));
}
{
// Happy flow - array argument
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
]);
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
debug('Client sending request...');
req.on('headers', common.mustCall((headers) => {
assert.notStrictEqual(headers, undefined);
assert.strictEqual(headers[':status'], 103);
assert.strictEqual(
headers.link,
'</styles.css>; rel=preload; as=style, </scripts.js>; rel=preload; as=script'
);
}));
req.on('response', common.mustCall((headers) => {
assert.strictEqual(headers[':status'], 200);
}));
let data = '';
req.on('data', common.mustCallAtLeast((d) => data += d));
req.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(data, testResBody);
client.close();
server.close();
}));
}));
}
{
// Happy flow - empty array
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([]);
debug('Server sending full response...');
res.end(testResBody);
}));
server.listen(0);
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
debug('Client sending request...');
req.on('headers', common.mustNotCall());
req.on('response', common.mustCall((headers) => {
assert.strictEqual(headers[':status'], 200);
}));
let data = '';
req.on('data', common.mustCallAtLeast((d) => data += d));
req.on('end', common.mustCall(() => {
debug('Got full response.');
assert.strictEqual(data, testResBody);
client.close();
server.close();
}));
}));
}