Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

http: add writeEarlyHints function to ServerResponse #44180

Merged
merged 6 commits into from Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 32 additions & 1 deletion doc/api/http.md
Expand Up @@ -2127,10 +2127,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])`

wingleung marked this conversation as resolved.
Show resolved Hide resolved
<!-- 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
Expand Down
26 changes: 26 additions & 0 deletions doc/api/http2.md
Expand Up @@ -3991,6 +3991,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
Expand Down
40 changes: 39 additions & 1 deletion lib/_http_server.js
Expand Up @@ -80,7 +80,8 @@ const {
} = codes;
const {
validateInteger,
validateBoolean
validateBoolean,
validateLinkHeaderValue
} = require('internal/validators');
const Buffer = require('buffer').Buffer;
const { setInterval, clearInterval } = require('timers');
Expand Down Expand Up @@ -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') {
wingleung marked this conversation as resolved.
Show resolved Hide resolved
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);
};
Expand Down
45 changes: 45 additions & 0 deletions lib/internal/http2/compat.js
Expand Up @@ -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
Expand All @@ -55,6 +56,7 @@ const {
const {
validateFunction,
validateString,
validateLinkHeaderValue,
} = require('internal/validators');
const {
kSocket,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions lib/internal/validators.js
Expand Up @@ -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,
Expand All @@ -280,4 +295,5 @@ module.exports = {
validateUndefined,
validateUnion,
validateAbortSignal,
validateLinkHeaderValue
};
33 changes: 33 additions & 0 deletions test/parallel/test-http-early-hints-invalid-argument-type.js
@@ -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);
});
}));
33 changes: 33 additions & 0 deletions test/parallel/test-http-early-hints-invalid-argument.js
@@ -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);
});
}));