diff --git a/lib/_http_server.js b/lib/_http_server.js index 7e96a0b2bb3397..d118d870f64100 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -23,9 +23,11 @@ const { ArrayIsArray, + ArrayPrototypeEvery, Error, MathMin, ObjectKeys, + ObjectValues, ObjectSetPrototypeOf, RegExpPrototypeExec, ReflectApply, @@ -51,7 +53,9 @@ const { ConnectionsList } = internalBinding('http_parser'); const { kUniqueHeaders, parseUniqueHeadersOption, - OutgoingMessage + OutgoingMessage, + validateHeaderName, + validateHeaderValue, } = require('_http_outgoing'); const { kOutHeaders, @@ -84,7 +88,6 @@ const { const { validateInteger, validateBoolean, - validateLinkHeaderValue, validateObject } = require('internal/validators'); const Buffer = require('buffer').Buffer; @@ -305,20 +308,25 @@ ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(hints, cb) { validateObject(hints, 'hints'); - if (hints.link === null || hints.link === undefined) { - return; - } - - const link = validateLinkHeaderValue(hints.link); + const empty = ArrayPrototypeEvery( + ObjectValues(hints), + (hint) => ArrayIsArray(hint) && hint.length === 0 + ); - if (link.length === 0) { - return; + if (empty) { + return false; } - head += 'Link: ' + link + '\r\n'; - for (const key of ObjectKeys(hints)) { - if (key !== 'link') { + const hint = hints[key]; + validateHeaderName(key); + validateHeaderValue(key, hint); + + if (ArrayIsArray(hint)) { + for (let i = 0; i < hint.length; i++) { + head += key + ': ' + hint[i] + '\r\n'; + } + } else { head += key + ': ' + hints[key] + '\r\n'; } } diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js index dec5b734a2490f..eacbf362b71275 100644 --- a/lib/internal/http2/compat.js +++ b/lib/internal/http2/compat.js @@ -2,11 +2,13 @@ const { ArrayIsArray, + ArrayPrototypeEvery, ArrayPrototypePush, Boolean, FunctionPrototypeBind, ObjectAssign, ObjectKeys, + ObjectValues, ObjectPrototypeHasOwnProperty, Proxy, ReflectApply, @@ -55,7 +57,6 @@ const { const { validateFunction, validateString, - validateLinkHeaderValue, validateObject, } = require('internal/validators'); const { @@ -850,17 +851,12 @@ class Http2ServerResponse extends Stream { writeEarlyHints(hints) { validateObject(hints, 'hints'); - const headers = { __proto__: null }; - - const linkHeaderValue = validateLinkHeaderValue(hints.link); - - for (const key of ObjectKeys(hints)) { - if (key !== 'link') { - headers[key] = hints[key]; - } - } + const empty = ArrayPrototypeEvery( + ObjectValues(hints), + (hint) => ArrayIsArray(hint) && hint.length === 0 + ); - if (linkHeaderValue.length === 0) { + if (empty) { return false; } @@ -870,9 +866,8 @@ class Http2ServerResponse extends Stream { return false; stream.additionalHeaders({ - ...headers, + ...hints, [HTTP2_HEADER_STATUS]: HTTP_STATUS_EARLY_HINTS, - 'Link': linkHeaderValue, }); return true; diff --git a/lib/internal/validators.js b/lib/internal/validators.js index 50b3016ab78ec2..05c1f2a02a3c57 100644 --- a/lib/internal/validators.js +++ b/lib/internal/validators.js @@ -459,67 +459,12 @@ function validateUnion(value, name, union) { } } -const linkValueRegExp = /^(?:<[^>]*>;)\s*(?:rel=(")?[^;"]*\1;?)\s*(?:(?:as|anchor|title|crossorigin|disabled|fetchpriority|rel|referrerpolicy)=(")?[^;"]*\2)?$/; - -/** - * @param {any} value - * @param {string} name - */ -function validateLinkHeaderFormat(value, name) { - if ( - typeof value === 'undefined' || - !RegExpPrototypeExec(linkValueRegExp, value) - ) { - throw new ERR_INVALID_ARG_VALUE( - name, - value, - 'must be an array or string of format "; rel=preload; as=style"' - ); - } -} - const validateInternalField = hideStackFrames((object, fieldKey, className) => { if (typeof object !== 'object' || object === null || !ObjectPrototypeHasOwnProperty(object, fieldKey)) { throw new ERR_INVALID_ARG_TYPE('this', className, object); } }); -/** - * @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 "; rel=preload; as=style"' - ); -} - module.exports = { isInt32, isUint32, @@ -545,6 +490,5 @@ module.exports = { validateUndefined, validateUnion, validateAbortSignal, - validateLinkHeaderValue, validateInternalField, }; diff --git a/test/parallel/test-http-early-hints.js b/test/parallel/test-http-early-hints.js index 7183d9516f6dda..57968c89f19cad 100644 --- a/test/parallel/test-http-early-hints.js +++ b/test/parallel/test-http-early-hints.js @@ -277,3 +277,91 @@ const testResBody = 'response content\n'; req.end(); })); } + +{ + // Happy flow - capitalized header name + + const server = http.createServer(common.mustCall((req, res) => { + debug('Server sending early hints...'); + res.writeEarlyHints({ + Link: '; 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, '; 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 - non-link header + + const server = http.createServer(common.mustCall((req, res) => { + debug('Server sending early hints...'); + res.writeEarlyHints({ + 'x-hint': 'value' + }); + + 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['x-hint'], 'value'); + })); + + 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(); + })); +} diff --git a/test/parallel/test-http2-compat-write-early-hints-invalid-argument-value.js b/test/parallel/test-http2-compat-write-early-hints-invalid-argument-value.js deleted file mode 100644 index d640f13fae6f5e..00000000000000 --- a/test/parallel/test-http2-compat-write-early-hints-invalid-argument-value.js +++ /dev/null @@ -1,42 +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'; - -{ - // 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); - }); - })); -} diff --git a/test/parallel/test-http2-compat-write-early-hints.js b/test/parallel/test-http2-compat-write-early-hints.js index d1f26d7c20bbc0..0a581cc0428bf9 100644 --- a/test/parallel/test-http2-compat-write-early-hints.js +++ b/test/parallel/test-http2-compat-write-early-hints.js @@ -145,3 +145,93 @@ const testResBody = 'response content'; })); })); } + +{ + // Happy flow - capitalized header name + + const server = http2.createServer(); + + server.on('request', common.mustCall((req, res) => { + debug('Server sending early hints...'); + res.writeEarlyHints({ + Link: '; 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, '; 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 - non-link header + + const server = http2.createServer(); + + server.on('request', common.mustCall((req, res) => { + debug('Server sending early hints...'); + res.writeEarlyHints({ + 'x-hint': '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.mustCall((headers) => { + assert.notStrictEqual(headers, undefined); + assert.strictEqual(headers[':status'], 103); + assert.strictEqual(headers['x-hint'], 'value'); + })); + + 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(); + })); + })); +} diff --git a/test/parallel/test-validators.js b/test/parallel/test-validators.js index a40139678eee65..63cf42e306605c 100644 --- a/test/parallel/test-validators.js +++ b/test/parallel/test-validators.js @@ -12,7 +12,6 @@ const { validateString, validateInt32, validateUint32, - validateLinkHeaderValue, } = require('internal/validators'); const { MAX_SAFE_INTEGER, MIN_SAFE_INTEGER } = Number; const outOfRangeError = { @@ -155,15 +154,3 @@ const invalidArgValueError = { code: 'ERR_INVALID_ARG_TYPE' })); } - -{ - // validateLinkHeaderValue type validation. - [ - ['; rel=preload; as=style', '; rel=preload; as=style'], - ['; rel=preload; title=hello', '; rel=preload; title=hello'], - ['; rel=preload; crossorigin=hello', '; rel=preload; crossorigin=hello'], - ['; rel=preload; disabled=true', '; rel=preload; disabled=true'], - ['; rel=preload; fetchpriority=high', '; rel=preload; fetchpriority=high'], - ['; rel=preload; referrerpolicy=origin', '; rel=preload; referrerpolicy=origin'], - ].forEach(([value, expected]) => assert.strictEqual(validateLinkHeaderValue(value), expected)); -}