Skip to content

Commit

Permalink
http: add writeEarlyHints function to ServerResponse
Browse files Browse the repository at this point in the history
Co-Authored-By: Matteo Collina <matteo.collina@gmail.com>
Co-Authored-By: Livia Medeiros <livia@cirno.name>
PR-URL: #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>
  • Loading branch information
3 people authored and danielleadams committed Oct 11, 2022
1 parent 3abb607 commit 4c869c8
Show file tree
Hide file tree
Showing 11 changed files with 576 additions and 2 deletions.
33 changes: 32 additions & 1 deletion doc/api/http.md
Expand Up @@ -2120,10 +2120,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
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 {
Expand Down Expand Up @@ -300,6 +301,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);
};
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 @@ -403,6 +403,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 @@ -425,4 +440,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);
});
}));

0 comments on commit 4c869c8

Please sign in to comment.