diff --git a/doc/api/http2.md b/doc/api/http2.md index 8bed0c935a..ddae4a8c32 100755 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -1233,41 +1233,6 @@ server.on('stream', (stream, headers, flags) => { added: REPLACEME --> -The `'timeout'` event is emitted when there is no activity on the Server for -a given number of milliseconds set using `http2server.setTimeout()`. - -### http2.getDefaultSettings() - - -* Returns: {[Settings Object][]} - -Returns an object containing the default settings for an `Http2Session` -instance. This method returns a new object instance every time it is called -so instances returned may be safely modified for use. - -### http2.getPackedSettings(settings) - - -* `settings` {[Settings Object][]} -* Returns: {Buffer} - -Returns a [Buffer][] instance containing serialized representation of the given -HTTP/2 settings as specified in the [HTTP/2][] specification. This is intended -for use with the `HTTP2-Settings` header field. - -```js -const http2 = require('http2'); - -const packed = http2.getPackedSettings({ enablePush: false }); - -console.log(packed.toString('base64')); -// Prints: AAIAAAAA -``` - ### http2.createServer(options[, onRequestHandler]) + +* Returns: {[Settings Object][]} + +Returns an object containing the default settings for an `Http2Session` +instance. This method returns a new object instance every time it is called +so instances returned may be safely modified for use. + +### http2.getPackedSettings(settings) + + +* `settings` {[Settings Object][]} +* Returns: {Buffer} + +Returns a [Buffer][] instance containing serialized representation of the given +HTTP/2 settings as specified in the [HTTP/2][] specification. This is intended +for use with the `HTTP2-Settings` header field. + +```js +const http2 = require('http2'); + +const packed = http2.getPackedSettings({ enablePush: false }); + +console.log(packed.toString('base64')); +// Prints: AAIAAAAA +``` + +### http2.getUnpackedSettings(buf) + + +* `buf` {Buffer|Uint8Array} The packed settings +* Returns: {[Settings Object][]} + +Returns a [Settings Object][] containing the deserialized settings from the +given `Buffer` as generated by `http2.getPackedSettings()`. + ### Headers Object Headers are represented as own-properties on JavaScript objects. The property diff --git a/lib/http2.js b/lib/http2.js index 8a34a20719..e964abf589 100644 --- a/lib/http2.js +++ b/lib/http2.js @@ -10,6 +10,7 @@ const { constants, getDefaultSettings, getPackedSettings, + getUnpackedSettings, createServer, createSecureServer, connect @@ -19,6 +20,7 @@ module.exports = { constants, getDefaultSettings, getPackedSettings, + getUnpackedSettings, createServer, createSecureServer, connect diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 03ea9f1818..ef112b18ce 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -154,6 +154,8 @@ E('ERR_HTTP2_INVALID_CONNECTION_HEADERS', E('ERR_HTTP2_INVALID_HEADER_VALUE', 'Value must not be undefined or null'); E('ERR_HTTP2_INVALID_INFO_STATUS', (code) => `Invalid informational status code: ${code}`); +E('ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH', + 'Packed settings length must be a multiple of six'); E('ERR_HTTP2_INVALID_PSEUDOHEADER', (name) => `"${name}" is an invalid pseudoheader or is used incorrectly`); E('ERR_HTTP2_INVALID_SESSION', 'The session has been destroyed'); diff --git a/lib/internal/http2/compat.js b/lib/internal/http2/compat.js index 9195285097..cd9a1fa2b7 100644 --- a/lib/internal/http2/compat.js +++ b/lib/internal/http2/compat.js @@ -320,7 +320,7 @@ class Http2ServerResponse extends Stream { code |= 0; if (code >= 100 && code < 200) throw new errors.RangeError('ERR_HTTP2_INFO_STATUS_NOT_ALLOWED'); - if (code < 200 || code > 999) + if (code < 200 || code > 599) throw new errors.RangeError('ERR_HTTP2_STATUS_INVALID', code); state.statusCode = code; } diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index b0dd59021e..1bdd57926c 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -17,6 +17,7 @@ const { URL } = require('url'); const { onServerStream } = require('internal/http2/compat'); const { utcDate } = require('internal/http'); const { _connectionListener: httpConnectionListener } = require('http'); +const { isUint8Array } = process.binding('util'); const { assertIsObject, @@ -97,6 +98,13 @@ const { HTTP2_HEADER_STATUS, HTTP2_HEADER_CONTENT_LENGTH, + NGHTTP2_SETTINGS_HEADER_TABLE_SIZE, + NGHTTP2_SETTINGS_ENABLE_PUSH, + NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, + NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE, + NGHTTP2_SETTINGS_MAX_FRAME_SIZE, + NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE, + HTTP2_METHOD_GET, HTTP2_METHOD_HEAD, HTTP2_METHOD_CONNECT, @@ -119,6 +127,11 @@ function sessionName(type) { } } +// Top level to avoid creating a closure +function emit() { + this.emit.apply(this, arguments); +} + // Called when a new block of headers has been received for a given // stream. The stream may or may not be new. If the stream is new, // create the associated Http2Stream instance and emit the 'stream' @@ -160,7 +173,7 @@ function onSessionHeaders(id, cat, flags, headers) { 'report this as a bug in Node.js'); } streams.set(id, stream); - process.nextTick(() => owner.emit('stream', stream, obj, flags)); + process.nextTick(emit.bind(owner, 'stream', stream, obj, flags)); } else { let event; let status; @@ -193,7 +206,7 @@ function onSessionHeaders(id, cat, flags, headers) { 'report this as a bug in Node.js'); } debug(`[${sessionName(owner[kType])}] emitting stream '${event}' event`); - process.nextTick(() => stream.emit(event, obj, flags)); + process.nextTick(emit.bind(stream, event, obj, flags)); } } @@ -214,18 +227,14 @@ function onSessionTrailers(id) { 'Internal HTTP/2 Failure. Stream does not exist. Please ' + 'report this as a bug in Node.js'); - // TODO(jasnell): mapToHeaders will throw synchronously if the headers - // are not valid. Try catch to keep it from bubbling up to the native - // layer so that we can emit. mapToHeaders can be refactored to take - // an optional callback or event emitter instance so it can emit errors - // async instead. - try { - const trailers = Object.create(null); - stream.emit('fetchTrailers', trailers); - return mapToHeaders(trailers, assertValidPseudoHeaderTrailer); - } catch (err) { - process.nextTick(() => stream.emit('error', err)); + const trailers = Object.create(null); + stream.emit('fetchTrailers', trailers); + const headersList = mapToHeaders(trailers, assertValidPseudoHeaderTrailer); + if (!Array.isArray(headersList)) { + process.nextTick(() => stream.emit('error', headersList)); + return; } + return headersList; } // Called when the stream is closed. The streamClosed event is emitted on the @@ -248,16 +257,15 @@ function onSessionStreamClose(id, code) { if (state.fd !== undefined) { debug(`Closing fd ${state.fd} for stream ${id}`); - fs.close(state.fd, (err) => { - if (err) - process.nextTick(() => stream.emit('error', err)); - }); + fs.close(state.fd, afterFDClose.bind(stream)); } - setImmediate(() => { - stream.destroy(); - debug(`[${sessionName(owner[kType])}] stream ${id} is closed`); - }); + setImmediate(stream.destroy.bind(stream)); +} + +function afterFDClose(err) { + if (err) + process.nextTick(() => this.emit('error', err)); } // Called when an error event needs to be triggered @@ -280,11 +288,17 @@ function onSessionRead(nread, buf, handle) { const state = stream[kState]; _unrefActive(this); // Reset the session timeout timer _unrefActive(stream); // Reset the stream timeout timer - if (!stream.push(buf)) { - assert(this.streamReadStop(id) === undefined, - `HTTP/2 Stream ${id} does not exist. Please report this as ' + - 'a bug in Node.js`); - state.reading = false; + + if (nread >= 0) { + if (!stream.push(buf)) { + assert(this.streamReadStop(id) === undefined, + `HTTP/2 Stream ${id} does not exist. Please report this as ' + + 'a bug in Node.js`); + state.reading = false; + } + } else { + // Last chunk was received. End the readable side. + stream.push(null); } } @@ -294,14 +308,20 @@ function onSettings(ack) { const owner = this[kOwner]; debug(`[${sessionName(owner[kType])}] new settings received`); _unrefActive(this); + let event = 'remoteSettings'; if (ack) { if (owner[kState].pendingAck > 0) owner[kState].pendingAck--; owner[kLocalSettings] = undefined; - process.nextTick(() => owner.emit('localSettings', owner.localSettings)); + event = 'localSettings'; } else { owner[kRemoteSettings] = undefined; - process.nextTick(() => owner.emit('remoteSettings', owner.remoteSettings)); + } + // Only emit the event if there are listeners registered + if (owner.listenerCount(event) > 0) { + const settings = event === 'localSettings' ? + owner.localSettings : owner.remoteSettings; + process.nextTick(emit.bind(owner, event, settings)); } } @@ -318,7 +338,15 @@ function onPriority(id, parent, weight, exclusive) { const stream = streams.get(id); const emitter = stream === undefined ? owner : stream; process.nextTick( - () => emitter.emit('priority', id, parent, weight, exclusive)); + emit.bind(emitter, 'priority', id, parent, weight, exclusive)); +} + +function emitFrameError(id, type, code) { + if (!this.emit('frameError', type, code, id)) { + const err = new errors.Error('ERR_HTTP2_FRAME_ERROR', type, code, id); + err.errno = code; + this.emit('error', err); + } } // Called by the native layer when an error has occurred sending a @@ -331,13 +359,17 @@ function onFrameError(id, type, code) { const streams = owner[kState].streams; const stream = streams.get(id); const emitter = stream !== undefined ? stream : owner; - process.nextTick(() => { - if (!emitter.emit('frameError', type, code, id)) { - const err = new errors.Error('ERR_HTTP2_FRAME_ERROR', type, code, id); - err.errno = code; - emitter.emit('error', err); - } - }); + process.nextTick(emitFrameError.bind(emitter, id, type, code)); +} + +function emitGoaway(state, code, lastStreamID, buf) { + this.emit('goaway', code, lastStreamID, buf); + // Tear down the session or destroy + if (!state.shuttingDown && !state.shutdown) { + this.shutdown({}, this.destroy.bind(this)); + } else { + this.destroy(); + } } // Called by the native layer when a goaway frame has been received @@ -345,15 +377,7 @@ function onGoawayData(code, lastStreamID, buf) { const owner = this[kOwner]; const state = owner[kState]; debug(`[${sessionName(owner[kType])}] goaway data received`); - process.nextTick(() => { - owner.emit('goaway', code, lastStreamID, buf); - // Tear down the session or destroy - if (!state.shuttingDown && !state.shutdown) { - owner.shutdown({}, () => { owner.destroy(); }); - } else { - owner.destroy(); - } - }); + process.nextTick(emitGoaway.bind(owner, state, code, lastStreamID, buf)); } // Returns the padding to use per frame. The selectPadding callback is set @@ -386,7 +410,14 @@ function requestOnConnect(headers, options) { // or an error code (if negative) validatePriorityOptions(options); const handle = session[kHandle]; - const ret = handle.submitRequest(mapToHeaders(headers), + + const headersList = mapToHeaders(headers); + if (!Array.isArray(headersList)) { + process.nextTick(() => this.emit('error', headersList)); + return; + } + + const ret = handle.submitRequest(headersList, !!options.endStream, options.parent | 0, options.weight | 0, @@ -507,7 +538,7 @@ function setupHandle(session, socket, type, options) { options.settings : Object.create(null); session.settings(settings); - process.nextTick(() => session.emit('connect', session, socket)); + process.nextTick(emit.bind(session, 'connect', session, socket)); }; } @@ -610,7 +641,7 @@ function doShutdown(options) { process.nextTick(() => this.emit('error', err)); return; } - process.nextTick(() => this.emit('shutdown', options)); + process.nextTick(emit.bind(this, 'shutdown', options)); debug(`[${sessionName(this[kType])}] shutdown is complete`); } @@ -630,6 +661,21 @@ function submitShutdown(options) { } } +function finishSessionDestroy(socket) { + if (!socket.destroyed) + socket.destroy(); + + // Destroy the handle + const handle = this[kHandle]; + if (handle !== undefined) { + handle.destroy(); + debug(`[${sessionName(this[kType])}] nghttp2session handle destroyed`); + } + + this.emit('close'); + debug(`[${sessionName(this[kType])}] nghttp2session destroyed`); +} + // Upon creation, the Http2Session takes ownership of the socket. The session // may not be ready to use immediately if the socket is not yet fully connected. class Http2Session extends EventEmitter { @@ -640,20 +686,8 @@ class Http2Session extends EventEmitter { constructor(type, options, socket) { super(); - assert(type === NGHTTP2_SESSION_SERVER || type === NGHTTP2_SESSION_CLIENT, - 'Invalid session type. Please report this as a bug in Node.js'); - - if (options && typeof options !== 'object') { - throw new errors.TypeError('ERR_INVALID_ARG_TYPE', - 'options', - 'object'); - } - - if (!(socket instanceof net.Socket)) { - throw new errors.TypeError('ERR_INVALID_ARG_TYPE', - 'socket', - 'net.Socket'); - } + // No validation is performed on the input parameters because this + // constructor is not exported directly for users. // If the session property already exists on the socket, // then it has already been bound to an Http2Session instance @@ -914,27 +948,14 @@ class Http2Session extends EventEmitter { // Disassociate from the socket and server const socket = this[kSocket]; - socket.pause(); + // socket.pause(); delete this[kSocket]; delete this[kServer]; state.destroyed = true; state.destroying = false; - setImmediate(() => { - if (!socket.destroyed) - socket.destroy(); - - // Destroy the handle - const handle = this[kHandle]; - if (handle !== undefined) { - handle.destroy(); - debug(`[${sessionName(this[kType])}] nghttp2session handle destroyed`); - } - - this.emit('close'); - debug(`[${sessionName(this[kType])}] nghttp2session destroyed`); - }); + setImmediate(finishSessionDestroy.bind(this, socket)); } // Graceful or immediate shutdown of the Http2Session. Graceful shutdown @@ -1200,7 +1221,7 @@ function streamOnSessionConnect() { debug(`[${sessionName(session[kType])}] session connected. emiting stream ` + 'connect'); this[kState].connecting = false; - process.nextTick(() => this.emit('connect')); + process.nextTick(emit.bind(this, 'connect')); } function streamOnceReady() { @@ -1230,11 +1251,12 @@ class Http2Stream extends Duplex { this.cork(); this[kSession] = session; - this[kState] = { + const state = this[kState] = { rst: false, rstCode: NGHTTP2_NO_ERROR, headersSent: false, - aborted: false + aborted: false, + closeHandler: onSessionClose.bind(this) }; this.once('ready', streamOnceReady); @@ -1243,12 +1265,12 @@ class Http2Stream extends Duplex { this.on('resume', streamOnResume); this.on('pause', streamOnPause); this.on('drain', streamOnDrain); - session.once('close', onSessionClose.bind(this)); + session.once('close', state.closeHandler); if (session[kState].connecting) { debug(`[${sessionName(session[kType])}] session is still connecting, ` + 'queuing stream init'); - this[kState].connecting = true; + state.connecting = true; session.once('connect', streamOnSessionConnect.bind(this)); } debug(`[${sessionName(session[kType])}] http2stream created`); @@ -1310,7 +1332,7 @@ class Http2Stream extends Duplex { _write(data, encoding, cb) { if (this[kID] === undefined) { - this.once('ready', () => this._write(data, encoding, cb)); + this.once('ready', this._write.bind(this, data, encoding, cb)); return; } _unrefActive(this); @@ -1333,7 +1355,7 @@ class Http2Stream extends Duplex { _writev(data, cb) { if (this[kID] === undefined) { - this.once('ready', () => this._writev(data, cb)); + this.once('ready', this._writev.bind(this, data, cb)); return; } _unrefActive(this); @@ -1360,7 +1382,7 @@ class Http2Stream extends Duplex { _read(nread) { if (this[kID] === undefined) { - this.once('ready', () => this._read(nread)); + this.once('ready', this._read.bind(this, nread)); return; } if (this.destroyed) { @@ -1389,7 +1411,7 @@ class Http2Stream extends Duplex { if (this[kID] === undefined) { debug( `[${sessionName(session[kType])}] queuing rstStream for new stream`); - this.once('ready', () => this.rstStream(code)); + this.once('ready', this.rstStream.bind(this, code)); return; } debug(`[${sessionName(session[kType])}] sending rstStream for stream ` + @@ -1430,7 +1452,7 @@ class Http2Stream extends Duplex { const session = this[kSession]; if (this[kID] === undefined) { debug(`[${sessionName(session[kType])}] queuing priority for new stream`); - this.once('ready', () => this.priority(options)); + this.once('ready', this.priority.bind(this, options)); return; } debug(`[${sessionName(session[kType])}] sending priority for stream ` + @@ -1464,13 +1486,14 @@ class Http2Stream extends Duplex { this[kSession].rstStream(this, code); } + + // Remove the close handler on the session + session.removeListener('close', this[kState].closeHandler); + // Unenroll the timer unenroll(this); - setImmediate(() => { - if (handle !== undefined) - handle.destroyStream(this[kID]); - }); + setImmediate(finishStreamDestroy.bind(this, handle)); session[kState].streams.delete(this[kID]); delete this[kSession]; @@ -1481,12 +1504,17 @@ class Http2Stream extends Duplex { const err = new errors.Error('ERR_HTTP2_STREAM_ERROR', code); process.nextTick(() => this.emit('error', err)); } - process.nextTick(() => this.emit('streamClosed', code)); + process.nextTick(emit.bind(this, 'streamClosed', code)); debug(`[${sessionName(session[kType])}] stream ${this[kID]} destroyed`); callback(err); } } +function finishStreamDestroy(handle) { + if (handle !== undefined) + handle.destroyStream(this[kID]); +} + function processHeaders(headers) { assertIsObject(headers, 'headers'); headers = Object.assign(Object.create(null), headers); @@ -1495,7 +1523,13 @@ function processHeaders(headers) { headers[HTTP2_HEADER_DATE] = utcDate(); const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; - if (statusCode < 200 || statusCode > 999) + // This is intentionally stricter than the HTTP/1 implementation, which + // allows values between 100 and 999 (inclusive) in order to allow for + // backwards compatibility with non-spec compliant code. With HTTP/2, + // we have the opportunity to start fresh with stricter spec copmliance. + // This will have an impact on the compatibility layer for anyone using + // non-standard, non-compliant status codes. + if (statusCode < 200 || statusCode > 599) throw new errors.RangeError('ERR_HTTP2_STATUS_INVALID', headers[HTTP2_HEADER_STATUS]); @@ -1527,6 +1561,53 @@ function processRespondWithFD(fd, headers) { } } +function doSendFD(session, options, fd, headers, err, stat) { + if (this.destroyed || session.destroyed) { + abort(this); + return; + } + if (err) { + process.nextTick(() => this.emit('error', err)); + return; + } + if (!stat.isFile()) { + err = new errors.Error('ERR_HTTP2_SEND_FILE'); + process.nextTick(() => this.emit('error', err)); + return; + } + + // Set the content-length by default + headers[HTTP2_HEADER_CONTENT_LENGTH] = stat.size; + if (typeof options.statCheck === 'function' && + options.statCheck.call(this, stat, headers) === false) { + return; + } + + const headersList = mapToHeaders(headers, + assertValidPseudoHeaderResponse); + if (!Array.isArray(headersList)) { + throw headersList; + } + + processRespondWithFD.call(this, fd, headersList); +} + +function afterOpen(session, options, headers, err, fd) { + const state = this[kState]; + if (this.destroyed || session.destroyed) { + abort(this); + return; + } + if (err) { + process.nextTick(() => this.emit('error', err)); + return; + } + state.fd = fd; + + fs.fstat(fd, doSendFD.bind(this, session, options, fd, headers)); +} + + class ServerHttp2Stream extends Http2Stream { constructor(session, id, options, headers) { super(session, options); @@ -1594,8 +1675,14 @@ class ServerHttp2Stream extends Http2Stream { options.endStream = true; } + const headersList = mapToHeaders(headers); + if (!Array.isArray(headersList)) { + // An error occurred! + throw headersList; + } + const ret = handle.submitPushPromise(this[kID], - mapToHeaders(headers), + headersList, options.endStream); let err; switch (ret) { @@ -1666,6 +1753,10 @@ class ServerHttp2Stream extends Http2Stream { } const headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); + if (!Array.isArray(headersList)) { + // An error occurred! + throw headersList; + } state.headersSent = true; // Close the writable side if the endStream option is set @@ -1720,9 +1811,13 @@ class ServerHttp2Stream extends Http2Stream { throw new errors.Error('ERR_HTTP2_PAYLOAD_FORBIDDEN', statusCode); } - processRespondWithFD.call(this, fd, - mapToHeaders(headers, - assertValidPseudoHeaderResponse)); + const headersList = mapToHeaders(headers, + assertValidPseudoHeaderResponse); + if (!Array.isArray(headersList)) { + throw headersList; + } + + processRespondWithFD.call(this, fd, headersList); } // Initiate a file response on this Http2Stream. The path is passed to @@ -1763,45 +1858,7 @@ class ServerHttp2Stream extends Http2Stream { throw new errors.Error('ERR_HTTP2_PAYLOAD_FORBIDDEN', statusCode); } - fs.open(path, 'r', (err, fd) => { - if (this.destroyed || session.destroyed) { - abort(this); - return; - } - if (err) { - process.nextTick(() => this.emit('error', err)); - return; - } - state.fd = fd; - - fs.fstat(fd, (err, stat) => { - if (this.destroyed || session.destroyed) { - abort(this); - return; - } - if (err) { - process.nextTick(() => this.emit('error', err)); - return; - } - if (!stat.isFile()) { - err = new errors.Error('ERR_HTTP2_SEND_FILE'); - process.nextTick(() => this.emit('error', err)); - return; - } - - // Set the content-length by default - headers[HTTP2_HEADER_CONTENT_LENGTH] = stat.size; - if (typeof options.statCheck === 'function' && - options.statCheck.call(this, stat, headers) === false) { - return; - } - - const headersList = mapToHeaders(headers, - assertValidPseudoHeaderResponse); - - processRespondWithFD.call(this, fd, headersList); - }); - }); + fs.open(path, 'r', afterOpen.bind(this, session, options, headers)); } // Sends a block of informational headers. In theory, the HTTP/2 spec @@ -1835,10 +1892,15 @@ class ServerHttp2Stream extends Http2Stream { _unrefActive(this); const handle = this[kSession][kHandle]; + + const headersList = mapToHeaders(headers, + assertValidPseudoHeaderResponse); + if (!Array.isArray(headersList)) { + throw headersList; + } + const ret = - handle.sendHeaders(this[kID], - mapToHeaders(headers, - assertValidPseudoHeaderResponse)); + handle.sendHeaders(this[kID], headersList); let err; switch (ret) { case NGHTTP2_ERR_NOMEM: @@ -2048,7 +2110,7 @@ function connectionListener(socket) { socket[kServer] = this; - process.nextTick(() => this.emit('session', session)); + process.nextTick(emit.bind(this, 'session', session)); } function initializeOptions(options) { @@ -2150,7 +2212,7 @@ function clientSessionOnError(error) { return; this.destroy(); this.removeListener('error', clientSocketOnError); - this.emit('error', error); + this.removeListener('error', clientSessionOnError); } function connect(authority, options, listener) { @@ -2194,7 +2256,7 @@ function connect(authority, options, listener) { session.on('error', clientSessionOnError); - session[kAuthority] = `${host}:${port}`; + session[kAuthority] = `${options.servername || host}:${port}`; session[kProtocol] = protocol; if (typeof listener === 'function') @@ -2252,11 +2314,76 @@ function getPackedSettings(settings) { return binding.packSettings(); } +function getUnpackedSettings(buf, options = {}) { + if (!isUint8Array(buf)) { + throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'buf', + ['Buffer', 'Uint8Array']); + } + if (buf.length % 6 !== 0) + throw new errors.RangeError('ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH'); + const settings = Object.create(null); + let offset = 0; + while (offset < buf.length) { + const id = buf.readUInt16BE(offset); + offset += 2; + const value = buf.readUInt32BE(offset); + switch (id) { + case NGHTTP2_SETTINGS_HEADER_TABLE_SIZE: + settings.headerTableSize = value; + break; + case NGHTTP2_SETTINGS_ENABLE_PUSH: + settings.enablePush = Boolean(value); + break; + case NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS: + settings.maxConcurrentStreams = value; + break; + case NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE: + settings.initialWindowSize = value; + break; + case NGHTTP2_SETTINGS_MAX_FRAME_SIZE: + settings.maxFrameSize = value; + break; + case NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE: + settings.maxHeaderListSize = value; + break; + } + offset += 4; + } + + if (options != null && options.validate) { + assertWithinRange('headerTableSize', + settings.headerTableSize, + 0, 2 ** 32 - 1); + assertWithinRange('initialWindowSize', + settings.initialWindowSize, + 0, 2 ** 32 - 1); + assertWithinRange('maxFrameSize', + settings.maxFrameSize, + 16384, 2 ** 24 - 1); + assertWithinRange('maxConcurrentStreams', + settings.maxConcurrentStreams, + 0, 2 ** 31 - 1); + assertWithinRange('maxHeaderListSize', + settings.maxHeaderListSize, + 0, 2 ** 32 - 1); + if (settings.enablePush !== undefined && + typeof settings.enablePush !== 'boolean') { + const err = new errors.TypeError('ERR_HTTP2_INVALID_SETTING_VALUE', + 'enablePush', settings.enablePush); + err.actual = settings.enablePush; + throw err; + } + } + + return settings; +} + // Exports module.exports = { constants, getDefaultSettings, getPackedSettings, + getUnpackedSettings, createServer, createSecureServer, connect diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 6ca41b6732..ea36444fad 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -29,6 +29,7 @@ const { HTTP2_HEADER_IF_RANGE, HTTP2_HEADER_IF_UNMODIFIED_SINCE, HTTP2_HEADER_LAST_MODIFIED, + HTTP2_HEADER_LOCATION, HTTP2_HEADER_MAX_FORWARDS, HTTP2_HEADER_PROXY_AUTHORIZATION, HTTP2_HEADER_RANGE, @@ -50,6 +51,9 @@ const { HTTP2_METHOD_HEAD } = binding.constants; +// This set is defined strictly by the HTTP/2 specification. Only +// :-prefixed headers defined by that specification may be added to +// this set. const kValidPseudoHeaders = new Set([ HTTP2_HEADER_STATUS, HTTP2_HEADER_METHOD, @@ -58,6 +62,8 @@ const kValidPseudoHeaders = new Set([ HTTP2_HEADER_PATH ]); +// This set contains headers that are permitted to have only a single +// value. Multiple instances must not be specified. const kSingleValueHeaders = new Set([ HTTP2_HEADER_STATUS, HTTP2_HEADER_METHOD, @@ -83,6 +89,7 @@ const kSingleValueHeaders = new Set([ HTTP2_HEADER_IF_RANGE, HTTP2_HEADER_IF_UNMODIFIED_SINCE, HTTP2_HEADER_LAST_MODIFIED, + HTTP2_HEADER_LOCATION, HTTP2_HEADER_MAX_FORWARDS, HTTP2_HEADER_PROXY_AUTHORIZATION, HTTP2_HEADER_RANGE, @@ -91,6 +98,10 @@ const kSingleValueHeaders = new Set([ HTTP2_HEADER_USER_AGENT ]); +// The HTTP methods in this set are specifically defined as assigning no +// meaning to the request payload. By default, unless the user explicitly +// overrides the endStream option on the request method, the endStream +// option will be defaulted to true when these methods are used. const kNoPayloadMethods = new Set([ HTTP2_METHOD_DELETE, HTTP2_METHOD_GET, @@ -345,7 +356,7 @@ function assertValidPseudoHeader(key) { if (!kValidPseudoHeaders.has(key)) { const err = new errors.Error('ERR_HTTP2_INVALID_PSEUDOHEADER', key); Error.captureStackTrace(err, assertValidPseudoHeader); - throw err; + return err; } } @@ -353,24 +364,25 @@ function assertValidPseudoHeaderResponse(key) { if (key !== ':status') { const err = new errors.Error('ERR_HTTP2_INVALID_PSEUDOHEADER', key); Error.captureStackTrace(err, assertValidPseudoHeaderResponse); - throw err; + return err; } } function assertValidPseudoHeaderTrailer(key) { const err = new errors.Error('ERR_HTTP2_INVALID_PSEUDOHEADER', key); Error.captureStackTrace(err, assertValidPseudoHeaderTrailer); - throw err; + return err; } function mapToHeaders(map, assertValuePseudoHeader = assertValidPseudoHeader) { - var ret = []; - var keys = Object.keys(map); + const ret = []; + const keys = Object.keys(map); + const singles = new Set(); for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = map[key]; - var val; + let key = keys[i]; + let value = map[key]; + let val; if (typeof key === 'symbol' || value === undefined || !key) continue; key = String(key).toLowerCase(); @@ -384,15 +396,23 @@ function mapToHeaders(map, break; default: if (kSingleValueHeaders.has(key)) - throw new errors.Error('ERR_HTTP2_HEADER_SINGLE_VALUE', key); + return new errors.Error('ERR_HTTP2_HEADER_SINGLE_VALUE', key); } } if (key[0] === ':') { - assertValuePseudoHeader(key); + const err = assertValuePseudoHeader(key); + if (err !== undefined) + return err; ret.unshift([key, String(value)]); } else { - if (isIllegalConnectionSpecificHeader(key, value)) - throw new errors.Error('ERR_HTTP2_INVALID_CONNECTION_HEADERS'); + if (kSingleValueHeaders.has(key)) { + if (singles.has(key)) + return new errors.Error('ERR_HTTP2_HEADER_SINGLE_VALUE', key); + singles.add(key); + } + if (isIllegalConnectionSpecificHeader(key, value)) { + return new errors.Error('ERR_HTTP2_INVALID_CONNECTION_HEADERS'); + } if (isArray) { for (var k = 0; k < value.length; k++) { val = String(value[k]); diff --git a/src/node_http2.cc b/src/node_http2.cc index 3e8714a2fd..5ad1352cc1 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -720,7 +720,7 @@ void Http2Session::SubmitGoaway(const FunctionCallbackInfo& args) { DEBUG_HTTP2("Http2Session: initiating immediate shutdown. " "last-stream-id: %d, code: %d, opaque-data: %d\n", - req->lastStreamID(), req->errorCode(), req->opaqueDataLength()); + lastStreamID, errorCode, length); int status = nghttp2_submit_goaway(session->session(), NGHTTP2_FLAG_NONE, lastStreamID, @@ -950,12 +950,16 @@ void Http2Session::OnDataChunk( obj->Set(context, env()->id_string(), Integer::New(isolate, stream->id())).FromJust(); - Local buf = Buffer::New(isolate, - chunk->buf.base, - chunk->buf.len, - FreeDataChunk, - chunk).ToLocalChecked(); - EmitData(chunk->buf.len, buf, obj); + ssize_t len = -1; + Local buf; + if (chunk != nullptr) { + len = chunk->buf.len; + buf = Buffer::New(isolate, + chunk->buf.base, len, + FreeDataChunk, + chunk).ToLocalChecked(); + } + EmitData(len, buf, obj); } void Http2Session::OnSettings(bool ack) { @@ -1278,6 +1282,13 @@ void Initialize(Local target, NODE_DEFINE_CONSTANT(constants, MAX_INITIAL_WINDOW_SIZE); NODE_DEFINE_CONSTANT(constants, NGHTTP2_DEFAULT_WEIGHT); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_HEADER_TABLE_SIZE); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_ENABLE_PUSH); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_MAX_FRAME_SIZE); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE); + NODE_DEFINE_CONSTANT(constants, PADDING_STRATEGY_NONE); NODE_DEFINE_CONSTANT(constants, PADDING_STRATEGY_MAX); NODE_DEFINE_CONSTANT(constants, PADDING_STRATEGY_CALLBACK); diff --git a/src/node_http2_core-inl.h b/src/node_http2_core-inl.h index 717d075fab..49ec63b59b 100644 --- a/src/node_http2_core-inl.h +++ b/src/node_http2_core-inl.h @@ -62,7 +62,7 @@ inline Nghttp2Stream* Nghttp2Session::FindStream(int32_t id) { } // Flushes any received queued chunks of data out to the JS layer -inline void Nghttp2Stream::FlushDataChunks() { +inline void Nghttp2Stream::FlushDataChunks(bool done) { while (data_chunks_head_ != nullptr) { DEBUG_HTTP2("Nghttp2Stream %d: emitting data chunk\n", id_); nghttp2_data_chunk_t* item = data_chunks_head_; @@ -71,6 +71,8 @@ inline void Nghttp2Stream::FlushDataChunks() { session_->OnDataChunk(this, item); } data_chunks_tail_ = nullptr; + if (done) + session_->OnDataChunk(this, nullptr); } // Passes all of the the chunks for a data frame out to the JS layer @@ -83,7 +85,9 @@ inline void Nghttp2Session::HandleDataFrame(const nghttp2_frame* frame) { Nghttp2Stream* stream = this->FindStream(id); // If the stream does not exist, something really bad happened CHECK_NE(stream, nullptr); - stream->FlushDataChunks(); + bool done = (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) == + NGHTTP2_FLAG_END_STREAM; + stream->FlushDataChunks(done); } // Passes all of the collected headers for a HEADERS frame out to the JS layer. diff --git a/src/node_http2_core.cc b/src/node_http2_core.cc index bf89e28fbc..4d9ab4a4df 100644 --- a/src/node_http2_core.cc +++ b/src/node_http2_core.cc @@ -175,7 +175,7 @@ ssize_t Nghttp2Session::OnStreamReadFD(nghttp2_session* session, void* user_data) { Nghttp2Session* handle = static_cast(user_data); DEBUG_HTTP2("Nghttp2Session %d: reading outbound file data for stream %d\n", - handle->sesion_type_, id); + handle->session_type_, id); Nghttp2Stream* stream = handle->FindStream(id); int fd = source->fd; diff --git a/src/node_http2_core.h b/src/node_http2_core.h index df8d2c00aa..10acd7736b 100644 --- a/src/node_http2_core.h +++ b/src/node_http2_core.h @@ -266,7 +266,7 @@ class Nghttp2Stream { DEBUG_HTTP2("Nghttp2Stream %d: freed\n", id_); } - inline void FlushDataChunks(); + inline void FlushDataChunks(bool done = false); // Resets the state of the stream instance to defaults inline void ResetState( diff --git a/test/parallel/test-http2-binding.js b/test/parallel/test-http2-binding.js index 11dc6e1263..c26549d361 100644 --- a/test/parallel/test-http2-binding.js +++ b/test/parallel/test-http2-binding.js @@ -3,7 +3,6 @@ require('../common'); const assert = require('assert'); -const Buffer = require('buffer').Buffer; assert.doesNotThrow(() => process.binding('http2')); @@ -22,14 +21,6 @@ assert.strictEqual(settings.maxFrameSize, 16384); assert.strictEqual(binding.nghttp2ErrorString(-517), 'GOAWAY has already been sent'); -const check = Buffer.from([0x00, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x05, - 0x00, 0x00, 0x40, 0x00, 0x00, 0x04, 0x00, 0x00, - 0xff, 0xff, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]); -const val = http2.getPackedSettings(http2.getDefaultSettings()); -assert.deepStrictEqual(val, check); - -assert.doesNotThrow(() => assert(Buffer.isBuffer(http2.getPackedSettings()))); - // assert constants are present assert(binding.constants); assert.strictEqual(typeof binding.constants, 'object'); @@ -205,7 +196,13 @@ const expectedNGConstants = { NGHTTP2_FLAG_ACK: 1, NGHTTP2_FLAG_PADDED: 8, NGHTTP2_FLAG_PRIORITY: 32, - NGHTTP2_DEFAULT_WEIGHT: 16 + NGHTTP2_DEFAULT_WEIGHT: 16, + NGHTTP2_SETTINGS_HEADER_TABLE_SIZE: 1, + NGHTTP2_SETTINGS_ENABLE_PUSH: 2, + NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS: 3, + NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE: 4, + NGHTTP2_SETTINGS_MAX_FRAME_SIZE: 5, + NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE: 6 }; const defaultSettings = { diff --git a/test/parallel/test-http2-client-unescaped-path.js b/test/parallel/test-http2-client-unescaped-path.js new file mode 100644 index 0000000000..d92d40492e --- /dev/null +++ b/test/parallel/test-http2-client-unescaped-path.js @@ -0,0 +1,37 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const http2 = require('http2'); + +const server = http2.createServer(); + +server.on('stream', common.mustNotCall()); + +const count = 32; + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + let remaining = count; + function maybeClose() { + if (--remaining === 0) { + server.close(); + client.destroy(); + } + } + + // nghttp2 will catch the bad header value for us. + function doTest(i) { + const req = client.request({ ':path': `bad${String.fromCharCode(i)}path` }); + req.on('error', common.expectsError({ + code: 'ERR_HTTP2_STREAM_ERROR', + type: Error, + message: 'Stream closed with error code 1' + })); + req.on('streamClosed', common.mustCall(maybeClose)); + } + + for (let i = 0; i <= count; i += 1) + doTest(i); +})); diff --git a/test/parallel/test-http2-client-upload.js b/test/parallel/test-http2-client-upload.js new file mode 100644 index 0000000000..4ce7da878e --- /dev/null +++ b/test/parallel/test-http2-client-upload.js @@ -0,0 +1,44 @@ +// Flags: --expose-http2 +'use strict'; + +// Verifies that uploading data from a client works + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); +const fs = require('fs'); +const path = require('path'); + +const loc = path.join(common.fixturesDir, 'person.jpg'); +let fileData; + +assert(fs.existsSync(loc)); + +fs.readFile(loc, common.mustCall((err, data) => { + assert.ifError(err); + fileData = data; + + const server = http2.createServer(); + + server.on('stream', common.mustCall((stream) => { + let data = Buffer.alloc(0); + stream.on('data', (chunk) => data = Buffer.concat([data, chunk])); + stream.on('end', common.mustCall(() => { + assert.deepStrictEqual(data, fileData); + })); + stream.respond(); + stream.end(); + })); + + server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request({ ':method': 'POST' }); + req.on('response', common.mustCall()); + req.resume(); + req.on('end', common.mustCall(() => { + server.close(); + client.destroy(); + })); + fs.createReadStream(loc).pipe(req); + })); +})); diff --git a/test/parallel/test-http2-compat-serverresponse-createpushresponse.js b/test/parallel/test-http2-compat-serverresponse-createpushresponse.js index 2562d8cf83..68e438d62f 100644 --- a/test/parallel/test-http2-compat-serverresponse-createpushresponse.js +++ b/test/parallel/test-http2-compat-serverresponse-createpushresponse.js @@ -7,82 +7,73 @@ const h2 = require('http2'); // Push a request & response -const server = h2.createServer(); -server.listen(0, common.mustCall(function() { - const port = server.address().port; - server.once('request', common.mustCall(function(request, response) { - assert.ok(response.stream.id % 2 === 1); - - response.write('This is a client-initiated response'); - response.on('finish', common.mustCall(function() { - server.close(); - })); - - const headers = { - ':path': '/pushed', - ':method': 'GET', - ':scheme': 'http', - ':authority': `localhost:${port}` - }; - - response.createPushResponse( - headers, - common.mustCall(function(error, pushResponse) { - assert.strictEqual(error, null); - assert.ok(pushResponse.stream.id % 2 === 0); - - pushResponse.write('This is a server-initiated response'); - - pushResponse.end(); - response.end(); - }) - ); +const pushExpect = 'This is a server-initiated response'; +const servExpect = 'This is a client-initiated response'; + +const server = h2.createServer((request, response) => { + assert.strictEqual(response.stream.id % 2, 1); + response.write(servExpect); + + response.createPushResponse({ + ':path': '/pushed', + ':method': 'GET' + }, common.mustCall((error, push) => { + assert.ifError(error); + assert.strictEqual(push.stream.id % 2, 0); + push.end(pushExpect); + response.end(); })); +}); - const url = `http://localhost:${port}`; - const client = h2.connect(url, common.mustCall(function() { +server.listen(0, common.mustCall(() => { + const port = server.address().port; + + const client = h2.connect(`http://localhost:${port}`, common.mustCall(() => { const headers = { ':path': '/', ':method': 'GET', - ':scheme': 'http', - ':authority': `localhost:${port}` }; - const requestStream = client.request(headers); + let remaining = 2; + function maybeClose() { + if (--remaining === 0) { + client.destroy(); + server.close(); + } + } - function onStream(pushStream, headers, flags) { + const req = client.request(headers); + + client.on('stream', common.mustCall((pushStream, headers) => { assert.strictEqual(headers[':path'], '/pushed'); assert.strictEqual(headers[':method'], 'GET'); assert.strictEqual(headers[':scheme'], 'http'); assert.strictEqual(headers[':authority'], `localhost:${port}`); - assert.strictEqual(flags, h2.constants.NGHTTP2_FLAG_END_HEADERS); - pushStream.on('data', common.mustCall(function(data) { - assert.strictEqual( - data.toString(), - 'This is a server-initiated response' - ); + let actual = ''; + pushStream.on('push', common.mustCall((headers) => { + assert.strictEqual(headers[':status'], 200); + assert(headers['date']); + })); + pushStream.setEncoding('utf8'); + pushStream.on('data', (chunk) => actual += chunk); + pushStream.on('end', common.mustCall(() => { + assert.strictEqual(actual, pushExpect); + maybeClose(); })); - } - - requestStream.session.on('stream', common.mustCall(onStream)); - - requestStream.on('response', common.mustCall(function(headers, flags) { - assert.strictEqual(headers[':status'], 200); - assert.ok(headers['date']); - assert.strictEqual(flags, h2.constants.NGHTTP2_FLAG_END_HEADERS); })); - requestStream.on('data', common.mustCall(function(data) { - assert.strictEqual( - data.toString(), - 'This is a client-initiated response' - ); + req.on('response', common.mustCall((headers) => { + assert.strictEqual(headers[':status'], 200); + assert(headers['date']); })); - requestStream.on('end', common.mustCall(function() { - client.destroy(); + let actual = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => actual += chunk); + req.on('end', common.mustCall(() => { + assert.strictEqual(actual, servExpect); + maybeClose(); })); - requestStream.end(); })); })); diff --git a/test/parallel/test-http2-compat-serverresponse-statuscode.js b/test/parallel/test-http2-compat-serverresponse-statuscode.js index 0bb472bf79..201a63c379 100644 --- a/test/parallel/test-http2-compat-serverresponse-statuscode.js +++ b/test/parallel/test-http2-compat-serverresponse-statuscode.js @@ -21,8 +21,7 @@ server.listen(0, common.mustCall(function() { }; const fakeStatusCodes = { tooLow: 99, - tooHigh: 1000, - backwardsCompatibility: 999 + tooHigh: 600 }; assert.strictEqual(response.statusCode, expectedDefaultStatusCode); @@ -32,18 +31,26 @@ server.listen(0, common.mustCall(function() { response.statusCode = realStatusCodes.multipleChoices; response.statusCode = realStatusCodes.badRequest; response.statusCode = realStatusCodes.internalServerError; - response.statusCode = fakeStatusCodes.backwardsCompatibility; }); assert.throws(function() { response.statusCode = realStatusCodes.continue; - }, RangeError); + }, common.expectsError({ + code: 'ERR_HTTP2_INFO_STATUS_NOT_ALLOWED', + type: RangeError + })); assert.throws(function() { response.statusCode = fakeStatusCodes.tooLow; - }, RangeError); + }, common.expectsError({ + code: 'ERR_HTTP2_STATUS_INVALID', + type: RangeError + })); assert.throws(function() { response.statusCode = fakeStatusCodes.tooHigh; - }, RangeError); + }, common.expectsError({ + code: 'ERR_HTTP2_STATUS_INVALID', + type: RangeError + })); response.on('finish', common.mustCall(function() { server.close(); diff --git a/test/parallel/test-http2-create-client-secure-session.js b/test/parallel/test-http2-create-client-secure-session.js index c777da9e19..9b1cf4a0c9 100644 --- a/test/parallel/test-http2-create-client-secure-session.js +++ b/test/parallel/test-http2-create-client-secure-session.js @@ -13,12 +13,13 @@ function loadKey(keyname) { path.join(common.fixturesDir, 'keys', keyname), 'binary'); } -function onStream(stream) { +function onStream(stream, headers) { + const socket = stream.session.socket; + assert(headers[':authority'].startsWith(socket.servername)); stream.respond({ 'content-type': 'text/html', ':status': 200 }); - const socket = stream.session.socket; stream.end(JSON.stringify({ servername: socket.servername, alpnProtocol: socket.alpnProtocol diff --git a/test/parallel/test-http2-date-header.js b/test/parallel/test-http2-date-header.js new file mode 100644 index 0000000000..d9a73b2ef6 --- /dev/null +++ b/test/parallel/test-http2-date-header.js @@ -0,0 +1,28 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); + +server.on('stream', common.mustCall((stream) => { + // Date header is defaulted + stream.respond(); + stream.end(); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request(); + req.on('response', common.mustCall((headers) => { + // The date header must be set to a non-invalid value + assert.notStrictEqual((new Date()).toString(), 'Invalid Date'); + })); + req.resume(); + req.on('end', common.mustCall(() => { + server.close(); + client.destroy(); + })); +})); diff --git a/test/parallel/test-http2-dont-override.js b/test/parallel/test-http2-dont-override.js new file mode 100644 index 0000000000..55b29580fb --- /dev/null +++ b/test/parallel/test-http2-dont-override.js @@ -0,0 +1,48 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const options = {}; + +const server = http2.createServer(options); + +// options are defaulted but the options are not modified +assert.deepStrictEqual(Object.keys(options), []); + +server.on('stream', common.mustCall((stream) => { + const headers = {}; + const options = {}; + stream.respond(headers, options); + + // The headers are defaulted but the original object is not modified + assert.deepStrictEqual(Object.keys(headers), []); + + // Options are defaulted but the original object is not modified + assert.deepStrictEqual(Object.keys(options), []); + + stream.end(); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + const headers = {}; + const options = {}; + + const req = client.request(headers, options); + + // The headers are defaulted but the original object is not modified + assert.deepStrictEqual(Object.keys(headers), []); + + // Options are defaulted but the original object is not modified + assert.deepStrictEqual(Object.keys(options), []); + + req.resume(); + req.on('end', common.mustCall(() => { + server.close(); + client.destroy(); + })); +})); diff --git a/test/parallel/test-http2-getpackedsettings.js b/test/parallel/test-http2-getpackedsettings.js new file mode 100644 index 0000000000..0c1a1bccee --- /dev/null +++ b/test/parallel/test-http2-getpackedsettings.js @@ -0,0 +1,131 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const check = Buffer.from([0x00, 0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x40, 0x00, 0x00, 0x04, 0x00, 0x00, + 0xff, 0xff, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]); +const val = http2.getPackedSettings(http2.getDefaultSettings()); +assert.deepStrictEqual(val, check); + +[ + ['headerTableSize', 0], + ['headerTableSize', 2 ** 32 - 1], + ['initialWindowSize', 0], + ['initialWindowSize', 2 ** 32 - 1], + ['maxFrameSize', 16384], + ['maxFrameSize', 2 ** 24 - 1], + ['maxConcurrentStreams', 0], + ['maxConcurrentStreams', 2 ** 31 - 1], + ['maxHeaderListSize', 0], + ['maxHeaderListSize', 2 ** 32 - 1] +].forEach((i) => { + assert.doesNotThrow(() => http2.getPackedSettings({ [i[0]]: i[1] })); +}); + +assert.doesNotThrow(() => http2.getPackedSettings({ enablePush: true })); +assert.doesNotThrow(() => http2.getPackedSettings({ enablePush: false })); + +[ + ['headerTableSize', -1], + ['headerTableSize', 2 ** 32], + ['initialWindowSize', -1], + ['initialWindowSize', 2 ** 32], + ['maxFrameSize', 16383], + ['maxFrameSize', 2 ** 24], + ['maxConcurrentStreams', -1], + ['maxConcurrentStreams', 2 ** 31], + ['maxHeaderListSize', -1], + ['maxHeaderListSize', 2 ** 32] +].forEach((i) => { + assert.throws(() => { + http2.getPackedSettings({ [i[0]]: i[1] }); + }, common.expectsError({ + code: 'ERR_HTTP2_INVALID_SETTING_VALUE', + type: RangeError, + message: `Invalid value for setting "${i[0]}": ${i[1]}` + })); +}); + +[ + 1, null, '', Infinity, new Date(), {}, NaN, [false] +].forEach((i) => { + assert.throws(() => { + http2.getPackedSettings({ enablePush: i }); + }, common.expectsError({ + code: 'ERR_HTTP2_INVALID_SETTING_VALUE', + type: TypeError, + message: `Invalid value for setting "enablePush": ${i}` + })); +}); + +{ + const check = Buffer.from([ + 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x03, 0x00, 0x00, + 0x00, 0xc8, 0x00, 0x05, 0x00, 0x00, 0x4e, 0x20, 0x00, 0x04, + 0x00, 0x00, 0x00, 0x64, 0x00, 0x06, 0x00, 0x00, 0x00, 0x64, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]); + + const packed = http2.getPackedSettings({ + headerTableSize: 100, + initialWindowSize: 100, + maxFrameSize: 20000, + maxConcurrentStreams: 200, + maxHeaderListSize: 100, + enablePush: true, + foo: 'ignored' + }); + assert.strictEqual(packed.length, 36); + assert.deepStrictEqual(packed, check); +} + +{ + const packed = Buffer.from([ + 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x03, 0x00, 0x00, + 0x00, 0xc8, 0x00, 0x05, 0x00, 0x00, 0x4e, 0x20, 0x00, 0x04, + 0x00, 0x00, 0x00, 0x64, 0x00, 0x06, 0x00, 0x00, 0x00, 0x64, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x01]); + + [1, true, '', [], {}, NaN].forEach((i) => { + assert.throws(() => { + http2.getUnpackedSettings(i); + }, common.expectsError({ + code: 'ERR_INVALID_ARG_TYPE', + type: TypeError, + message: 'The "buf" argument must be one of type Buffer or Uint8Array' + })); + }); + + assert.throws(() => { + http2.getUnpackedSettings(packed.slice(5)); + }, common.expectsError({ + code: 'ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH', + type: RangeError, + message: 'Packed settings length must be a multiple of six' + })); + + const settings = http2.getUnpackedSettings(packed); + + assert(settings); + assert.strictEqual(settings.headerTableSize, 100); + assert.strictEqual(settings.initialWindowSize, 100); + assert.strictEqual(settings.maxFrameSize, 20000); + assert.strictEqual(settings.maxConcurrentStreams, 200); + assert.strictEqual(settings.maxHeaderListSize, 100); + assert.strictEqual(settings.enablePush, true); +} + +{ + const packed = Buffer.from([0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF]); + + assert.throws(() => { + http2.getUnpackedSettings(packed, {validate: true}); + }, common.expectsError({ + code: 'ERR_HTTP2_INVALID_SETTING_VALUE', + type: RangeError, + message: 'Invalid value for setting "maxConcurrentStreams": 4294967295' + })); +} diff --git a/test/parallel/test-http2-misused-pseudoheaders.js b/test/parallel/test-http2-misused-pseudoheaders.js index 1e2f09b622..e356169d26 100644 --- a/test/parallel/test-http2-misused-pseudoheaders.js +++ b/test/parallel/test-http2-misused-pseudoheaders.js @@ -35,10 +35,9 @@ function onStream(stream, headers, flags) { trailers[':status'] = 'bar'; }); - stream.on('error', common.mustCall((err) => { - assert(err instanceof Error); - assert.strictEqual(err.code, 'ERR_HTTP2_INVALID_PSEUDOHEADER'); - }, 1)); + stream.on('error', common.expectsError({ + code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' + })); stream.end('hello world'); } diff --git a/test/parallel/test-http2-multi-content-length.js b/test/parallel/test-http2-multi-content-length.js new file mode 100644 index 0000000000..5dcd56990b --- /dev/null +++ b/test/parallel/test-http2-multi-content-length.js @@ -0,0 +1,58 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const http2 = require('http2'); + +const server = http2.createServer(); + +server.on('stream', common.mustCall((stream) => { + stream.respond(); + stream.end(); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + let remaining = 3; + function maybeClose() { + if (--remaining === 0) { + server.close(); + client.destroy(); + } + } + + // Request 1 will fail because there are two content-length header values + const req = client.request({ + ':method': 'POST', + 'content-length': 1, + 'Content-Length': 2 + }); + req.on('error', common.expectsError({ + code: 'ERR_HTTP2_HEADER_SINGLE_VALUE', + type: Error, + message: 'Header field "content-length" must have only a single value' + })); + req.on('error', common.mustCall(maybeClose)); + req.end('a'); + + // Request 2 will succeed + const req2 = client.request({ + ':method': 'POST', + 'content-length': 1 + }); + req2.resume(); + req2.on('end', common.mustCall(maybeClose)); + req2.end('a'); + + // Request 3 will fail because nghttp2 does not allow the content-length + // header to be set for non-payload bearing requests... + const req3 = client.request({ 'content-length': 1}); + req3.resume(); + req3.on('end', common.mustCall(maybeClose)); + req3.on('error', common.expectsError({ + code: 'ERR_HTTP2_STREAM_ERROR', + type: Error, + message: 'Stream closed with error code 1' + })); +})); diff --git a/test/parallel/test-http2-multiheaders.js b/test/parallel/test-http2-multiheaders.js new file mode 100644 index 0000000000..d7b8f56d51 --- /dev/null +++ b/test/parallel/test-http2-multiheaders.js @@ -0,0 +1,60 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); + +const src = Object.create(null); +src.accept = [ 'abc', 'def' ]; +src.Accept = 'ghijklmnop'; +src['www-authenticate'] = 'foo'; +src['WWW-Authenticate'] = 'bar'; +src['WWW-AUTHENTICATE'] = 'baz'; +src['proxy-authenticate'] = 'foo'; +src['Proxy-Authenticate'] = 'bar'; +src['PROXY-AUTHENTICATE'] = 'baz'; +src['x-foo'] = 'foo'; +src['X-Foo'] = 'bar'; +src['X-FOO'] = 'baz'; +src.constructor = 'foo'; +src.Constructor = 'bar'; +src.CONSTRUCTOR = 'baz'; +// eslint-disable-next-line no-proto +src['__proto__'] = 'foo'; +src['__PROTO__'] = 'bar'; +src['__Proto__'] = 'baz'; + +function checkHeaders(headers) { + assert.deepStrictEqual(headers['accept'], + [ 'abc', 'def', 'ghijklmnop' ]); + assert.deepStrictEqual(headers['www-authenticate'], + [ 'foo', 'bar', 'baz' ]); + assert.deepStrictEqual(headers['proxy-authenticate'], + [ 'foo', 'bar', 'baz' ]); + assert.deepStrictEqual(headers['x-foo'], [ 'foo', 'bar', 'baz' ]); + assert.deepStrictEqual(headers['constructor'], [ 'foo', 'bar', 'baz' ]); + // eslint-disable-next-line no-proto + assert.deepStrictEqual(headers['__proto__'], [ 'foo', 'bar', 'baz' ]); +} + +server.on('stream', common.mustCall((stream, headers) => { + assert.strictEqual(headers[':path'], '/'); + assert.strictEqual(headers[':scheme'], 'http'); + assert.strictEqual(headers[':method'], 'GET'); + checkHeaders(headers); + stream.respond(src); + stream.end(); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request(src); + req.on('response', common.mustCall(checkHeaders)); + req.on('streamClosed', common.mustCall(() => { + server.close(); + client.destroy(); + })); +})); diff --git a/test/parallel/test-http2-multiplex.js b/test/parallel/test-http2-multiplex.js new file mode 100644 index 0000000000..b6b81c73a6 --- /dev/null +++ b/test/parallel/test-http2-multiplex.js @@ -0,0 +1,59 @@ +// Flags: --expose-http2 +'use strict'; + +// Tests opening 100 concurrent simultaneous uploading streams over a single +// connection and makes sure that the data for each is appropriately echoed. + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); + +const count = 100; + +server.on('stream', common.mustCall((stream) => { + stream.respond(); + stream.pipe(stream); +}, count)); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + let remaining = count; + + function maybeClose() { + if (--remaining === 0) { + server.close(); + client.destroy(); + } + } + + function doRequest() { + const req = client.request({ ':method': 'POST '}); + + let data = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => data += chunk); + req.on('end', common.mustCall(() => { + assert.strictEqual(data, 'abcdefghij'); + maybeClose(); + })); + + let n = 0; + function writeChunk() { + if (n < 10) { + req.write(String.fromCharCode(97 + n)); + setTimeout(writeChunk, 10); + } else { + req.end(); + } + n++; + } + + writeChunk(); + } + + for (let n = 0; n < count; n++) + doRequest(); +})); diff --git a/test/parallel/test-http2-response-splitting.js b/test/parallel/test-http2-response-splitting.js new file mode 100644 index 0000000000..088c675389 --- /dev/null +++ b/test/parallel/test-http2-response-splitting.js @@ -0,0 +1,75 @@ +// Flags: --expose-http2 +'use strict'; + +// Response splitting is no longer an issue with HTTP/2. The underlying +// nghttp2 implementation automatically strips out the header values that +// contain invalid characters. + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); +const { URL } = require('url'); + +// Response splitting example, credit: Amit Klein, Safebreach +const str = '/welcome?lang=bar%c4%8d%c4%8aContent­Length:%200%c4%8d%c4%8a%c' + + '4%8d%c4%8aHTTP/1.1%20200%20OK%c4%8d%c4%8aContent­Length:%202' + + '0%c4%8d%c4%8aLast­Modified:%20Mon,%2027%20Oct%202003%2014:50:18' + + '%20GMT%c4%8d%c4%8aContent­Type:%20text/html%c4%8d%c4%8a%c4%8' + + 'd%c4%8a%3chtml%3eGotcha!%3c/html%3e'; + +// Response splitting example, credit: Сковорода Никита Андреевич (@ChALkeR) +const x = 'fooഊSet-Cookie: foo=barഊഊ'; +const y = 'foo⠊Set-Cookie: foo=bar'; + +let remaining = 3; + +function makeUrl(headers) { + return `${headers[':scheme']}://${headers[':authority']}${headers[':path']}`; +} + +const server = http2.createServer(); +server.on('stream', common.mustCall((stream, headers) => { + + const obj = Object.create(null); + switch (remaining--) { + case 3: + const url = new URL(makeUrl(headers)); + obj[':status'] = 302; + obj.Location = `/foo?lang=${url.searchParams.get('lang')}`; + break; + case 2: + obj.foo = x; + break; + case 1: + obj.foo = y; + break; + } + stream.respond(obj); + stream.end(); +}, 3)); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + function maybeClose() { + if (remaining === 0) { + server.close(); + client.destroy(); + } + } + + function doTest(path, key, expected) { + const req = client.request({ ':path': path }); + req.on('response', common.mustCall((headers) => { + assert.strictEqual(headers.foo, undefined); + assert.strictEqual(headers.location, undefined); + })); + req.resume(); + req.on('end', common.mustCall(maybeClose)); + } + + doTest(str, 'location', str); + doTest('/', 'foo', x); + doTest('/', 'foo', y); + +})); diff --git a/test/parallel/test-http2-server-push-stream.js b/test/parallel/test-http2-server-push-stream.js index b8ea7cf69c..c2f34ed517 100644 --- a/test/parallel/test-http2-server-push-stream.js +++ b/test/parallel/test-http2-server-push-stream.js @@ -6,27 +6,27 @@ const assert = require('assert'); const http2 = require('http2'); const server = http2.createServer(); -server.on('stream', common.mustCall((stream, headers, flags) => { +server.on('stream', common.mustCall((stream, headers) => { const port = server.address().port; if (headers[':path'] === '/') { stream.pushStream({ ':scheme': 'http', ':path': '/foobar', ':authority': `localhost:${port}`, - }, (stream, headers) => { - stream.respond({ + }, (push, headers) => { + push.respond({ 'content-type': 'text/html', ':status': 200, 'x-push-data': 'pushed by server', }); - stream.end('pushed by server data'); + push.end('pushed by server data'); + stream.end('test'); }); } stream.respond({ 'content-type': 'text/html', ':status': 200 }); - stream.end('test'); })); server.listen(0, common.mustCall(() => { @@ -35,11 +35,11 @@ server.listen(0, common.mustCall(() => { const client = http2.connect(`http://localhost:${port}`); const req = client.request(headers); - client.on('stream', common.mustCall((stream, headers, flags) => { + client.on('stream', common.mustCall((stream, headers) => { assert.strictEqual(headers[':scheme'], 'http'); assert.strictEqual(headers[':path'], '/foobar'); assert.strictEqual(headers[':authority'], `localhost:${port}`); - stream.on('push', common.mustCall((headers, flags) => { + stream.on('push', common.mustCall((headers) => { assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/html'); assert.strictEqual(headers['x-push-data'], 'pushed by server'); diff --git a/test/parallel/test-http2-single-headers.js b/test/parallel/test-http2-single-headers.js new file mode 100644 index 0000000000..49918acc47 --- /dev/null +++ b/test/parallel/test-http2-single-headers.js @@ -0,0 +1,59 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const http2 = require('http2'); + +const server = http2.createServer(); + +// Each of these headers must appear only once +const singles = [ + 'content-type', + 'user-agent', + 'referer', + 'authorization', + 'proxy-authorization', + 'if-modified-since', + 'if-unmodified-since', + 'from', + 'location', + 'max-forwards' +]; + +server.on('stream', common.mustNotCall()); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + let remaining = singles.length * 2; + function maybeClose() { + if (--remaining === 0) { + server.close(); + client.destroy(); + } + } + + singles.forEach((i) => { + const req = client.request({ + [i]: 'abc', + [i.toUpperCase()]: 'xyz' + }); + req.on('error', common.expectsError({ + code: 'ERR_HTTP2_HEADER_SINGLE_VALUE', + type: Error, + message: `Header field "${i}" must have only a single value` + })); + req.on('error', common.mustCall(maybeClose)); + + const req2 = client.request({ + [i]: ['abc', 'xyz'] + }); + req2.on('error', common.expectsError({ + code: 'ERR_HTTP2_HEADER_SINGLE_VALUE', + type: Error, + message: `Header field "${i}" must have only a single value` + })); + req2.on('error', common.mustCall(maybeClose)); + }); + +})); diff --git a/test/parallel/test-http2-status-code-invalid.js b/test/parallel/test-http2-status-code-invalid.js new file mode 100644 index 0000000000..cb8c9072f7 --- /dev/null +++ b/test/parallel/test-http2-status-code-invalid.js @@ -0,0 +1,40 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); + +function expectsError(code) { + return common.expectsError({ + code: 'ERR_HTTP2_STATUS_INVALID', + type: RangeError, + message: `Invalid status code: ${code}` + }); +} + +server.on('stream', common.mustCall((stream) => { + + // Anything lower than 100 and greater than 599 is rejected + [ 99, 700, 1000 ].forEach((i) => { + assert.throws(() => stream.respond({ ':status': i }), expectsError(i)); + }); + + stream.respond(); + stream.end(); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request(); + req.on('response', common.mustCall((headers) => { + assert.strictEqual(headers[':status'], 200); + })); + req.resume(); + req.on('end', common.mustCall(() => { + server.close(); + client.destroy(); + })); +})); diff --git a/test/parallel/test-http2-status-code.js b/test/parallel/test-http2-status-code.js new file mode 100644 index 0000000000..f094d981c3 --- /dev/null +++ b/test/parallel/test-http2-status-code.js @@ -0,0 +1,40 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const codes = [ 200, 202, 300, 400, 404, 451, 500 ]; +let test = 0; + +const server = http2.createServer(); + +server.on('stream', common.mustCall((stream) => { + const status = codes[test++]; + stream.respond({ ':status': status }, { endStream: true }); +}, 7)); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + + let remaining = codes.length; + function maybeClose() { + if (--remaining === 0) { + client.destroy(); + server.close(); + } + } + + function doTest(expected) { + const req = client.request(); + req.on('response', common.mustCall((headers) => { + assert.strictEqual(headers[':status'], expected); + })); + req.resume(); + req.on('end', common.mustCall(maybeClose)); + } + + for (let n = 0; n < codes.length; n++) + doTest(codes[n]); +})); diff --git a/test/parallel/test-http2-util-headers-list.js b/test/parallel/test-http2-util-headers-list.js index 99806a51c0..d19c78a2b3 100644 --- a/test/parallel/test-http2-util-headers-list.js +++ b/test/parallel/test-http2-util-headers-list.js @@ -52,7 +52,6 @@ const { HTTP2_HEADER_COOKIE, HTTP2_HEADER_EXPECT, HTTP2_HEADER_LINK, - HTTP2_HEADER_LOCATION, HTTP2_HEADER_PREFER, HTTP2_HEADER_PROXY_AUTHENTICATE, HTTP2_HEADER_REFRESH, @@ -189,11 +188,10 @@ const { HTTP2_HEADER_USER_AGENT ].forEach((name) => { const msg = `Header field "${name}" must have only a single value`; - assert.throws(() => mapToHeaders({[name]: [1, 2, 3]}), - common.expectsError({ - code: 'ERR_HTTP2_HEADER_SINGLE_VALUE', - message: msg - })); + common.expectsError({ + code: 'ERR_HTTP2_HEADER_SINGLE_VALUE', + message: msg + })(mapToHeaders({[name]: [1, 2, 3]})); }); [ @@ -209,7 +207,6 @@ const { HTTP2_HEADER_COOKIE, HTTP2_HEADER_EXPECT, HTTP2_HEADER_LINK, - HTTP2_HEADER_LOCATION, HTTP2_HEADER_PREFER, HTTP2_HEADER_PROXY_AUTHENTICATE, HTTP2_HEADER_REFRESH, @@ -220,7 +217,7 @@ const { HTTP2_HEADER_VIA, HTTP2_HEADER_WWW_AUTHENTICATE ].forEach((name) => { - assert.doesNotThrow(() => mapToHeaders({[name]: [1, 2, 3]}), name); + assert(!(mapToHeaders({[name]: [1, 2, 3]}) instanceof Error), name); }); const regex = @@ -242,11 +239,10 @@ const regex = 'Proxy-Connection', 'Keep-Alive' ].forEach((name) => { - assert.throws(() => mapToHeaders({[name]: 'abc'}), - common.expectsError({ - code: 'ERR_HTTP2_INVALID_CONNECTION_HEADERS', - message: regex - })); + common.expectsError({ + code: 'ERR_HTTP2_INVALID_CONNECTION_HEADERS', + message: regex + })(mapToHeaders({[name]: 'abc'})); }); -assert.doesNotThrow(() => mapToHeaders({ te: 'trailers' })); +assert(!(mapToHeaders({ te: 'trailers' }) instanceof Error)); diff --git a/test/parallel/test-http2-write-callbacks.js b/test/parallel/test-http2-write-callbacks.js new file mode 100644 index 0000000000..b371ebf681 --- /dev/null +++ b/test/parallel/test-http2-write-callbacks.js @@ -0,0 +1,36 @@ +// Flags: --expose-http2 +'use strict'; + +// Verifies that write callbacks are called + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const server = http2.createServer(); + +server.on('stream', common.mustCall((stream) => { + stream.write('abc', common.mustCall(() => { + stream.end('xyz'); + })); + let actual = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => actual += chunk); + stream.on('end', common.mustCall(() => assert.strictEqual(actual, 'abcxyz'))); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + const req = client.request({ ':method': 'POST' }); + req.write('abc', common.mustCall(() => { + req.end('xyz'); + })); + let actual = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => actual += chunk); + req.on('end', common.mustCall(() => assert.strictEqual(actual, 'abcxyz'))); + req.on('streamClosed', common.mustCall(() => { + client.destroy(); + server.close(); + })); +})); diff --git a/test/parallel/test-http2-zero-length-write.js b/test/parallel/test-http2-zero-length-write.js new file mode 100644 index 0000000000..5f4f0681d4 --- /dev/null +++ b/test/parallel/test-http2-zero-length-write.js @@ -0,0 +1,50 @@ +// Flags: --expose-http2 +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http2 = require('http2'); + +const { Readable } = require('stream'); + +function getSrc() { + const chunks = [ '', 'asdf', '', 'foo', '', 'bar', '' ]; + return new Readable({ + read() { + const chunk = chunks.shift(); + if (chunk !== undefined) + this.push(chunk); + else + this.push(null); + } + }); +} + +const expect = 'asdffoobar'; + +const server = http2.createServer(); +server.on('stream', common.mustCall((stream) => { + let actual = ''; + stream.respond(); + stream.resume(); + stream.setEncoding('utf8'); + stream.on('data', (chunk) => actual += chunk); + stream.on('end', common.mustCall(() => { + getSrc().pipe(stream); + assert.strictEqual(actual, expect); + })); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + let actual = ''; + const req = client.request({ ':method': 'POST' }); + req.on('response', common.mustCall()); + req.on('data', (chunk) => actual += chunk); + req.on('end', common.mustCall(() => { + assert.strictEqual(actual, expect); + server.close(); + client.destroy(); + })); + getSrc().pipe(req); +}));