http2: make early hints generic

PR-URL: https://github.com/nodejs/node/pull/44820
Fixes: https://github.com/nodejs/node/issues/44816
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Minwoo Jung <nodecorelab@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
This commit is contained in:
Yagiz Nizipli 2022-10-06 18:03:47 +01:00 committed by GitHub
parent aacd742e91
commit 37f1e4bf4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 322 additions and 167 deletions

View File

@ -2137,32 +2137,41 @@ 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])`
### `response.writeEarlyHints(hints[, callback])`
<!-- YAML
added: REPLACEME
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/44820
description: Allow passing hints as an object.
-->
* `links` {string|Array}
* `hints` {Object}
* `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 `hints` is an object containing the values of headers to be sent with
early hints message. 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);
response.writeEarlyHints({
'link': earlyHintsLink,
});
const earlyHintsLinks = [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
];
response.writeEarlyHints(earlyHintsLinks);
response.writeEarlyHints({
'link': earlyHintsLinks,
'x-trace-id': 'id for diagnostics'
});
const earlyHintsCallback = () => console.log('early hints message sent');
response.writeEarlyHints(earlyHintsLinks, earlyHintsCallback);

View File

@ -81,7 +81,8 @@ const {
const {
validateInteger,
validateBoolean,
validateLinkHeaderValue
validateLinkHeaderValue,
validateObject
} = require('internal/validators');
const Buffer = require('buffer').Buffer;
const { setInterval, clearInterval } = require('timers');
@ -296,36 +297,27 @@ 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) {
ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(hints, 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;
validateObject(hints, 'hints');
if (hints.link === null || hints.link === undefined) {
return;
}
const link = validateLinkHeaderValue(hints.link);
if (link.length === 0) {
return;
}
head += 'Link: ' + link + '\r\n';
for (const key of ObjectKeys(hints)) {
if (key !== 'link') {
head += key + ': ' + hints[key] + '\r\n';
}
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';

View File

@ -57,6 +57,7 @@ const {
validateFunction,
validateString,
validateLinkHeaderValue,
validateObject,
} = require('internal/validators');
const {
kSocket,
@ -847,34 +848,21 @@ class Http2ServerResponse extends Stream {
return true;
}
writeEarlyHints(links) {
let linkHeaderValue = '';
writeEarlyHints(hints) {
validateObject(hints, 'hints');
if (typeof links === 'string') {
validateLinkHeaderValue(links, 'links');
linkHeaderValue += links;
} else if (ArrayIsArray(links)) {
if (!links.length) {
return;
const headers = ObjectCreate(null);
const linkHeaderValue = validateLinkHeaderValue(hints.link);
for (const key of ObjectKeys(hints)) {
if (key !== 'link') {
headers[key] = hints[key];
}
}
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"'
);
if (linkHeaderValue.length === 0) {
return false;
}
const stream = this[kStream];
@ -883,8 +871,9 @@ class Http2ServerResponse extends Stream {
return false;
stream.additionalHeaders({
...headers,
[HTTP2_HEADER_STATUS]: HTTP_STATUS_EARLY_HINTS,
'Link': linkHeaderValue
'Link': linkHeaderValue,
});
return true;

View File

@ -403,9 +403,13 @@ function validateUnion(value, name, union) {
}
}
function validateLinkHeaderValue(value, name) {
const linkValueRegExp = /^(?:<[^>]*>;)\s*(?:rel=(")?[^;"]*\1;?)\s*(?:(?:as|anchor|title)=(")?[^;"]*\2)?$/;
const linkValueRegExp = /^(?:<[^>]*>;)\s*(?:rel=(")?[^;"]*\1;?)\s*(?:(?:as|anchor|title)=(")?[^;"]*\2)?$/;
/**
* @param {any} value
* @param {string} name
*/
function validateLinkHeaderFormat(value, name) {
if (
typeof value === 'undefined' ||
!RegExpPrototypeExec(linkValueRegExp, value)
@ -424,6 +428,42 @@ const validateInternalField = hideStackFrames((object, fieldKey, className) => {
}
});
/**
* @param {any} hints
* @return {string}
*/
function validateLinkHeaderValue(hints) {
if (typeof hints === 'string') {
validateLinkHeaderFormat(hints, 'hints');
return hints;
} else if (ArrayIsArray(hints)) {
const hintsLength = hints.length;
let result = '';
if (hintsLength === 0) {
return result;
}
for (let i = 0; i < hintsLength; i++) {
const link = hints[i];
validateLinkHeaderFormat(link, 'hints');
result += link;
if (i !== hintsLength - 1) {
result += ', ';
}
}
return result;
}
throw new ERR_INVALID_ARG_VALUE(
'hints',
hints,
'must be an array or string of format "</styles.css>; rel=preload; as=style"'
);
}
module.exports = {
isInt32,
isUint32,

View File

@ -1,33 +0,0 @@
'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

@ -8,7 +8,7 @@ const testResBody = 'response content\n';
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('bad argument value');
res.writeEarlyHints('bad argument type');
debug('Server sending full response...');
res.end(testResBody);
@ -27,7 +27,7 @@ server.listen(0, common.mustCall(() => {
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');
assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE');
process.exit(0);
});
}));

View File

@ -11,7 +11,9 @@ const testResBody = 'response content\n';
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('</styles.css>; rel=preload; as=style');
res.writeEarlyHints({
link: '</styles.css>; rel=preload; as=style'
});
debug('Server sending full response...');
res.end(testResBody);
@ -53,10 +55,12 @@ const testResBody = 'response content\n';
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',
]);
res.writeEarlyHints({
link: [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
]
});
debug('Server sending full response...');
res.end(testResBody);
@ -100,7 +104,147 @@ const testResBody = 'response content\n';
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([]);
res.writeEarlyHints({
link: []
});
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();
}));
}
{
// Happy flow - object argument with string
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({
'link': '</styles.css>; rel=preload; as=style',
'x-trace-id': 'id for diagnostics'
});
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'
);
assert.strictEqual(res.headers['x-trace-id'], 'id for diagnostics');
}));
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 - object argument with array of strings
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({
'link': [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
],
'x-trace-id': 'id for diagnostics'
});
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'
);
assert.strictEqual(res.headers['x-trace-id'], 'id for diagnostics');
}));
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 object
const server = http.createServer(common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({});
debug('Server sending full response...');
res.end(testResBody);

View File

@ -9,30 +9,34 @@ const debug = require('node:util').debuglog('test');
const testResBody = 'response content';
const server = http2.createServer();
{
// Invalid object value
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({ links: 'bad argument object' });
const server = http2.createServer();
debug('Server sending full response...');
res.end(testResBody);
}));
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('this should not be here');
server.listen(0);
debug('Server sending full response...');
res.end(testResBody);
}));
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
server.listen(0);
debug('Client sending request...');
server.on('listening', common.mustCall(() => {
const client = http2.connect(`http://localhost:${server.address().port}`);
const req = client.request();
req.on('headers', common.mustNotCall());
debug('Client sending request...');
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);
});
}));
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_TYPE');
process.exit(0);
});
}));
}

View File

@ -0,0 +1,42 @@
'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';
{
// Invalid link header value
const server = http2.createServer();
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints({ link: BigInt(100) });
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

@ -1,38 +0,0 @@
'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

@ -16,7 +16,9 @@ const testResBody = 'response content';
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints('</styles.css>; rel=preload; as=style');
res.writeEarlyHints({
link: '</styles.css>; rel=preload; as=style'
});
debug('Server sending full response...');
res.end(testResBody);
@ -59,10 +61,12 @@ const testResBody = 'response content';
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',
]);
res.writeEarlyHints({
link: [
'</styles.css>; rel=preload; as=style',
'</scripts.js>; rel=preload; as=script',
]
});
debug('Server sending full response...');
res.end(testResBody);
@ -108,7 +112,9 @@ const testResBody = 'response content';
server.on('request', common.mustCall((req, res) => {
debug('Server sending early hints...');
res.writeEarlyHints([]);
res.writeEarlyHints({
link: []
});
debug('Server sending full response...');
res.end(testResBody);