From 02c86101e0c882e5cf70197c12b0a539231ef412 Mon Sep 17 00:00:00 2001 From: rickyes Date: Wed, 17 Jun 2020 21:24:09 +0800 Subject: [PATCH] http2: Abstracting stream and session-related logic --- lib/internal/http2/core.js | 2896 ++------------------------------- lib/internal/http2/session.js | 1455 +++++++++++++++++ lib/internal/http2/streams.js | 1363 ++++++++++++++++ lib/internal/http2/util.js | 118 +- node.gyp | 2 + src/node_native_module.cc | 2 + 6 files changed, 3037 insertions(+), 2799 deletions(-) create mode 100644 lib/internal/http2/session.js create mode 100644 lib/internal/http2/streams.js diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 0eedfaece2331d..8853171d13e2d5 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -1,2785 +1,117 @@ 'use strict'; -/* eslint-disable no-use-before-define */ - -const { - ArrayFrom, - ArrayIsArray, - Map, - MathMin, - ObjectAssign, - ObjectCreate, - ObjectKeys, - ObjectDefineProperty, - ObjectPrototypeHasOwnProperty, - Promise, - ReflectGetPrototypeOf, - Set, - Symbol, -} = primordials; - -const { - assertCrypto, - customInspectSymbol: kInspect, - promisify -} = require('internal/util'); - -assertCrypto(); - -const assert = require('assert'); -const EventEmitter = require('events'); -const fs = require('fs'); -const http = require('http'); -const net = require('net'); -const { Duplex } = require('stream'); -const tls = require('tls'); -const { URL } = require('url'); -const { setImmediate } = require('timers'); - -const { - kIncomingMessage, - _checkIsHttpToken: checkIsHttpToken -} = require('_http_common'); -const { kServerResponse } = require('_http_server'); -const JSStreamSocket = require('internal/js_stream_socket'); - -const { - defaultTriggerAsyncIdScope, - symbols: { - async_id_symbol, - owner_symbol, - }, -} = require('internal/async_hooks'); -const { - codes: { - ERR_HTTP2_ALTSVC_INVALID_ORIGIN, - ERR_HTTP2_ALTSVC_LENGTH, - ERR_HTTP2_CONNECT_AUTHORITY, - ERR_HTTP2_CONNECT_PATH, - ERR_HTTP2_CONNECT_SCHEME, - ERR_HTTP2_GOAWAY_SESSION, - ERR_HTTP2_HEADERS_AFTER_RESPOND, - ERR_HTTP2_HEADERS_SENT, - ERR_HTTP2_INVALID_INFO_STATUS, - ERR_HTTP2_INVALID_ORIGIN, - ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH, - ERR_HTTP2_INVALID_SESSION, - ERR_HTTP2_INVALID_SETTING_VALUE, - ERR_HTTP2_INVALID_STREAM, - ERR_HTTP2_MAX_PENDING_SETTINGS_ACK, - ERR_HTTP2_NESTED_PUSH, - ERR_HTTP2_NO_SOCKET_MANIPULATION, - ERR_HTTP2_ORIGIN_LENGTH, - ERR_HTTP2_OUT_OF_STREAMS, - ERR_HTTP2_PAYLOAD_FORBIDDEN, - ERR_HTTP2_PING_CANCEL, - ERR_HTTP2_PING_LENGTH, - ERR_HTTP2_PUSH_DISABLED, - ERR_HTTP2_SEND_FILE, - ERR_HTTP2_SEND_FILE_NOSEEK, - ERR_HTTP2_SESSION_ERROR, - ERR_HTTP2_SETTINGS_CANCEL, - ERR_HTTP2_SOCKET_BOUND, - ERR_HTTP2_SOCKET_UNBOUND, - ERR_HTTP2_STATUS_101, - ERR_HTTP2_STATUS_INVALID, - ERR_HTTP2_STREAM_CANCEL, - ERR_HTTP2_STREAM_ERROR, - ERR_HTTP2_STREAM_SELF_DEPENDENCY, - ERR_HTTP2_TRAILERS_ALREADY_SENT, - ERR_HTTP2_TRAILERS_NOT_READY, - ERR_HTTP2_UNSUPPORTED_PROTOCOL, - ERR_INVALID_ARG_TYPE, - ERR_INVALID_CALLBACK, - ERR_INVALID_CHAR, - ERR_INVALID_HTTP_TOKEN, - ERR_INVALID_OPT_VALUE, - ERR_OUT_OF_RANGE, - ERR_SOCKET_CLOSED - }, - hideStackFrames -} = require('internal/errors'); -const { validateInteger, - validateNumber, - validateString, - validateUint32, - isUint32, -} = require('internal/validators'); -const fsPromisesInternal = require('internal/fs/promises'); -const { utcDate } = require('internal/http'); -const { onServerStream, - Http2ServerRequest, - Http2ServerResponse, -} = require('internal/http2/compat'); - -const { - assertIsObject, - assertValidPseudoHeader, - assertValidPseudoHeaderResponse, - assertValidPseudoHeaderTrailer, - assertWithinRange, - getDefaultSettings, - getSessionState, - getSettings, - getStreamState, - isPayloadMeaningless, - kSensitiveHeaders, - kSocket, - kRequest, - kProxySocket, - mapToHeaders, - NghttpError, - sessionName, - toHeaderObject, - updateOptionsBuffer, - updateSettingsBuffer -} = require('internal/http2/util'); -const { - writeGeneric, - writevGeneric, - onStreamRead, - kAfterAsyncWrite, - kMaybeDestroy, - kUpdateTimer, - kHandle, - kSession, - setStreamTimeout -} = require('internal/stream_base_commons'); -const { kTimeout } = require('internal/timers'); -const { isArrayBufferView } = require('internal/util/types'); -const { format } = require('internal/util/inspect'); - -const { FileHandle } = internalBinding('fs'); -const binding = internalBinding('http2'); -const { - ShutdownWrap, - kReadBytesOrError, - streamBaseState -} = internalBinding('stream_wrap'); -const { UV_EOF } = internalBinding('uv'); - -const { StreamPipe } = internalBinding('stream_pipe'); -const { _connectionListener: httpConnectionListener } = http; -let debug = require('internal/util/debuglog').debuglog('http2', (fn) => { - debug = fn; -}); - -// TODO(addaleax): See if this can be made more efficient by figuring out -// whether debugging is enabled before we perform any further steps. Currently, -// this seems pretty fast, though. -function debugStream(id, sessionType, message, ...args) { - debug('Http2Stream %s [Http2Session %s]: ' + message, - id, sessionName(sessionType), ...args); -} - -function debugStreamObj(stream, message, ...args) { - debugStream(stream[kID], stream[kSession][kType], message, ...args); -} - -function debugSession(sessionType, message, ...args) { - debug('Http2Session %s: ' + message, sessionName(sessionType), ...args); -} - -function debugSessionObj(session, message, ...args) { - debugSession(session[kType], message, ...args); -} - -const kMaxFrameSize = (2 ** 24) - 1; -const kMaxInt = (2 ** 32) - 1; -const kMaxStreams = (2 ** 32) - 1; -const kMaxALTSVC = (2 ** 14) - 2; - -// eslint-disable-next-line no-control-regex -const kQuotedString = /^[\x09\x20-\x5b\x5d-\x7e\x80-\xff]*$/; - -const { constants, nameForErrorCode } = binding; - -const NETServer = net.Server; -const TLSServer = tls.Server; - -const kAlpnProtocol = Symbol('alpnProtocol'); -const kAuthority = Symbol('authority'); -const kEncrypted = Symbol('encrypted'); -const kID = Symbol('id'); -const kInit = Symbol('init'); -const kInfoHeaders = Symbol('sent-info-headers'); -const kLocalSettings = Symbol('local-settings'); -const kNativeFields = Symbol('kNativeFields'); -const kOptions = Symbol('options'); -const kOwner = owner_symbol; -const kOrigin = Symbol('origin'); -const kPendingRequestCalls = Symbol('kPendingRequestCalls'); -const kProceed = Symbol('proceed'); -const kProtocol = Symbol('protocol'); -const kRemoteSettings = Symbol('remote-settings'); -const kSelectPadding = Symbol('select-padding'); -const kSentHeaders = Symbol('sent-headers'); -const kSentTrailers = Symbol('sent-trailers'); -const kServer = Symbol('server'); -const kState = Symbol('state'); -const kType = Symbol('type'); -const kWriteGeneric = Symbol('write-generic'); - -const { - kBitfield, - kSessionPriorityListenerCount, - kSessionFrameErrorListenerCount, - kSessionMaxInvalidFrames, - kSessionMaxRejectedStreams, - kSessionUint8FieldCount, - kSessionHasRemoteSettingsListeners, - kSessionRemoteSettingsIsUpToDate, - kSessionHasPingListeners, - kSessionHasAltsvcListeners, -} = binding; - -const { - NGHTTP2_CANCEL, - NGHTTP2_REFUSED_STREAM, - NGHTTP2_DEFAULT_WEIGHT, - NGHTTP2_FLAG_END_STREAM, - NGHTTP2_HCAT_PUSH_RESPONSE, - NGHTTP2_HCAT_RESPONSE, - NGHTTP2_INTERNAL_ERROR, - NGHTTP2_NO_ERROR, - NGHTTP2_SESSION_CLIENT, - NGHTTP2_SESSION_SERVER, - NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE, - NGHTTP2_ERR_INVALID_ARGUMENT, - NGHTTP2_ERR_STREAM_CLOSED, - - HTTP2_HEADER_AUTHORITY, - HTTP2_HEADER_DATE, - HTTP2_HEADER_METHOD, - HTTP2_HEADER_PATH, - HTTP2_HEADER_PROTOCOL, - HTTP2_HEADER_SCHEME, - 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, - NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL, - - HTTP2_METHOD_GET, - HTTP2_METHOD_HEAD, - HTTP2_METHOD_CONNECT, - - HTTP_STATUS_CONTINUE, - HTTP_STATUS_RESET_CONTENT, - HTTP_STATUS_OK, - HTTP_STATUS_NO_CONTENT, - HTTP_STATUS_NOT_MODIFIED, - HTTP_STATUS_SWITCHING_PROTOCOLS, - HTTP_STATUS_MISDIRECTED_REQUEST, - - STREAM_OPTION_EMPTY_PAYLOAD, - STREAM_OPTION_GET_TRAILERS -} = constants; - -const STREAM_FLAGS_PENDING = 0x0; -const STREAM_FLAGS_READY = 0x1; -const STREAM_FLAGS_CLOSED = 0x2; -const STREAM_FLAGS_HEADERS_SENT = 0x4; -const STREAM_FLAGS_HEAD_REQUEST = 0x8; -const STREAM_FLAGS_ABORTED = 0x10; -const STREAM_FLAGS_HAS_TRAILERS = 0x20; - -const SESSION_FLAGS_PENDING = 0x0; -const SESSION_FLAGS_READY = 0x1; -const SESSION_FLAGS_CLOSED = 0x2; -const SESSION_FLAGS_DESTROYED = 0x4; - -// Top level to avoid creating a closure -function emit(self, ...args) { - self.emit(...args); -} - -// 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' -// event. If the stream is not new, emit the 'headers' event to pass -// the block of headers on. -function onSessionHeaders(handle, id, cat, flags, headers, sensitiveHeaders) { - const session = this[kOwner]; - if (session.destroyed) - return; - - const type = session[kType]; - session[kUpdateTimer](); - debugStream(id, type, 'headers received'); - const streams = session[kState].streams; - - const endOfStream = !!(flags & NGHTTP2_FLAG_END_STREAM); - let stream = streams.get(id); - - // Convert the array of header name value pairs into an object - const obj = toHeaderObject(headers, sensitiveHeaders); - - if (stream === undefined) { - if (session.closed) { - // We are not accepting any new streams at this point. This callback - // should not be invoked at this point in time, but just in case it is, - // refuse the stream using an RST_STREAM and destroy the handle. - handle.rstStream(NGHTTP2_REFUSED_STREAM); - handle.destroy(); - return; - } - const opts = { readable: !endOfStream }; - // session[kType] can be only one of two possible values - if (type === NGHTTP2_SESSION_SERVER) { - stream = new ServerHttp2Stream(session, handle, id, opts, obj); - if (obj[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD) { - // For head requests, there must not be a body... - // end the writable side immediately. - stream.end(); - stream[kState].flags |= STREAM_FLAGS_HEAD_REQUEST; - } - } else { - stream = new ClientHttp2Stream(session, handle, id, opts); - stream.end(); - } - if (endOfStream) - stream[kState].endAfterHeaders = true; - process.nextTick(emit, session, 'stream', stream, obj, flags, headers); - } else { - let event; - const status = obj[HTTP2_HEADER_STATUS]; - if (cat === NGHTTP2_HCAT_RESPONSE) { - if (!endOfStream && - status !== undefined && - status >= 100 && - status < 200) { - event = 'headers'; - } else { - event = 'response'; - } - } else if (cat === NGHTTP2_HCAT_PUSH_RESPONSE) { - event = 'push'; - // cat === NGHTTP2_HCAT_HEADERS: - } else if (!endOfStream && status !== undefined && status >= 200) { - event = 'response'; - } else { - event = endOfStream ? 'trailers' : 'headers'; - } - const session = stream.session; - if (status === HTTP_STATUS_MISDIRECTED_REQUEST) { - const originSet = session[kState].originSet = initOriginSet(session); - originSet.delete(stream[kOrigin]); - } - debugStream(id, type, "emitting stream '%s' event", event); - process.nextTick(emit, stream, event, obj, flags, headers); - } - if (endOfStream) { - stream.push(null); - } -} - -function tryClose(fd) { - // Try to close the file descriptor. If closing fails, assert because - // an error really should not happen at this point. - fs.close(fd, (err) => assert.ifError(err)); -} - -// Called when the Http2Stream has finished sending data and is ready for -// trailers to be sent. This will only be called if the { hasOptions: true } -// option is set. -function onStreamTrailers() { - const stream = this[kOwner]; - stream[kState].trailersReady = true; - if (stream.destroyed || stream.closed) - return; - if (!stream.emit('wantTrailers')) { - // There are no listeners, send empty trailing HEADERS frame and close. - stream.sendTrailers({}); - } -} - -// Submit an RST-STREAM frame to be sent to the remote peer. -// This will cause the Http2Stream to be closed. -function submitRstStream(code) { - if (this[kHandle] !== undefined) { - this[kHandle].rstStream(code); - } -} - -// Keep track of the number/presence of JS event listeners. Knowing that there -// are no listeners allows the C++ code to skip calling into JS for an event. -function sessionListenerAdded(name) { - switch (name) { - case 'ping': - this[kNativeFields][kBitfield] |= 1 << kSessionHasPingListeners; - break; - case 'altsvc': - this[kNativeFields][kBitfield] |= 1 << kSessionHasAltsvcListeners; - break; - case 'remoteSettings': - this[kNativeFields][kBitfield] |= 1 << kSessionHasRemoteSettingsListeners; - break; - case 'priority': - this[kNativeFields][kSessionPriorityListenerCount]++; - break; - case 'frameError': - this[kNativeFields][kSessionFrameErrorListenerCount]++; - break; - } -} - -function sessionListenerRemoved(name) { - switch (name) { - case 'ping': - if (this.listenerCount(name) > 0) return; - this[kNativeFields][kBitfield] &= ~(1 << kSessionHasPingListeners); - break; - case 'altsvc': - if (this.listenerCount(name) > 0) return; - this[kNativeFields][kBitfield] &= ~(1 << kSessionHasAltsvcListeners); - break; - case 'remoteSettings': - if (this.listenerCount(name) > 0) return; - this[kNativeFields][kBitfield] &= - ~(1 << kSessionHasRemoteSettingsListeners); - break; - case 'priority': - this[kNativeFields][kSessionPriorityListenerCount]--; - break; - case 'frameError': - this[kNativeFields][kSessionFrameErrorListenerCount]--; - break; - } -} - -// Also keep track of listeners for the Http2Stream instances, as some events -// are emitted on those objects. -function streamListenerAdded(name) { - const session = this[kSession]; - if (!session) return; - switch (name) { - case 'priority': - session[kNativeFields][kSessionPriorityListenerCount]++; - break; - case 'frameError': - session[kNativeFields][kSessionFrameErrorListenerCount]++; - break; - } -} - -function streamListenerRemoved(name) { - const session = this[kSession]; - if (!session) return; - switch (name) { - case 'priority': - session[kNativeFields][kSessionPriorityListenerCount]--; - break; - case 'frameError': - session[kNativeFields][kSessionFrameErrorListenerCount]--; - break; - } -} - -function onPing(payload) { - const session = this[kOwner]; - if (session.destroyed) - return; - session[kUpdateTimer](); - debugSessionObj(session, 'new ping received'); - session.emit('ping', payload); -} - -// Called when the stream is closed either by sending or receiving an -// RST_STREAM frame, or through a natural end-of-stream. -// If the writable and readable sides of the stream are still open at this -// point, close them. If there is an open fd for file send, close that also. -// At this point the underlying node::http2:Http2Stream handle is no -// longer usable so destroy it also. -function onStreamClose(code) { - const stream = this[kOwner]; - if (!stream || stream.destroyed) - return false; - - debugStreamObj( - stream, 'closed with code %d, closed %s, readable %s', - code, stream.closed, stream.readable - ); - - if (!stream.closed) - closeStream(stream, code, kNoRstStream); - - stream[kState].fd = -1; - // Defer destroy we actually emit end. - if (!stream.readable || code !== NGHTTP2_NO_ERROR) { - // If errored or ended, we can destroy immediately. - stream.destroy(); - } else { - // Wait for end to destroy. - stream.on('end', stream[kMaybeDestroy]); - // Push a null so the stream can end whenever the client consumes - // it completely. - stream.push(null); - - // If the user hasn't tried to consume the stream (and this is a server - // session) then just dump the incoming data so that the stream can - // be destroyed. - if (stream[kSession][kType] === NGHTTP2_SESSION_SERVER && - !stream[kState].didRead && - stream.readableFlowing === null) - stream.resume(); - else - stream.read(0); - } - return true; -} - -// Called when the remote peer settings have been updated. -// Resets the cached settings. -function onSettings() { - const session = this[kOwner]; - if (session.destroyed) - return; - session[kUpdateTimer](); - debugSessionObj(session, 'new settings received'); - session[kRemoteSettings] = undefined; - session.emit('remoteSettings', session.remoteSettings); -} - -// If the stream exists, an attempt will be made to emit an event -// on the stream object itself. Otherwise, forward it on to the -// session (which may, in turn, forward it on to the server) -function onPriority(id, parent, weight, exclusive) { - const session = this[kOwner]; - if (session.destroyed) - return; - debugStream(id, session[kType], - 'priority [parent: %d, weight: %d, exclusive: %s]', - parent, weight, exclusive); - const emitter = session[kState].streams.get(id) || session; - if (!emitter.destroyed) { - emitter[kUpdateTimer](); - emitter.emit('priority', id, parent, weight, exclusive); - } -} - -// Called by the native layer when an error has occurred sending a -// frame. This should be exceedingly rare. -function onFrameError(id, type, code) { - const session = this[kOwner]; - if (session.destroyed) - return; - debugSessionObj(session, 'error sending frame type %d on stream %d, code: %d', - type, id, code); - const emitter = session[kState].streams.get(id) || session; - emitter[kUpdateTimer](); - emitter.emit('frameError', type, code, id); -} - -function onAltSvc(stream, origin, alt) { - const session = this[kOwner]; - if (session.destroyed) - return; - debugSessionObj(session, 'altsvc received: stream: %d, origin: %s, alt: %s', - stream, origin, alt); - session[kUpdateTimer](); - session.emit('altsvc', alt, origin, stream); -} - -function initOriginSet(session) { - let originSet = session[kState].originSet; - if (originSet === undefined) { - const socket = session[kSocket]; - session[kState].originSet = originSet = new Set(); - if (socket.servername != null) { - let originString = `https://${socket.servername}`; - if (socket.remotePort != null) - originString += `:${socket.remotePort}`; - // We have to ensure that it is a properly serialized - // ASCII origin string. The socket.servername might not - // be properly ASCII encoded. - originSet.add((new URL(originString)).origin); - } - } - return originSet; -} - -function onOrigin(origins) { - const session = this[kOwner]; - if (session.destroyed) - return; - debugSessionObj(session, 'origin received: %j', origins); - session[kUpdateTimer](); - if (!session.encrypted || session.destroyed) - return undefined; - const originSet = initOriginSet(session); - for (let n = 0; n < origins.length; n++) - originSet.add(origins[n]); - session.emit('origin', origins); -} - -// Receiving a GOAWAY frame from the connected peer is a signal that no -// new streams should be created. If the code === NGHTTP2_NO_ERROR, we -// are going to send our close, but allow existing frames to close -// normally. If code !== NGHTTP2_NO_ERROR, we are going to send our own -// close using the same code then destroy the session with an error. -// The goaway event will be emitted on next tick. -function onGoawayData(code, lastStreamID, buf) { - const session = this[kOwner]; - if (session.destroyed) - return; - debugSessionObj(session, 'goaway %d received [last stream id: %d]', - code, lastStreamID); - - const state = session[kState]; - state.goawayCode = code; - state.goawayLastStreamID = lastStreamID; - - session.emit('goaway', code, lastStreamID, buf); - if (code === NGHTTP2_NO_ERROR) { - // If this is a no error goaway, begin shutting down. - // No new streams permitted, but existing streams may - // close naturally on their own. - session.close(); - } else { - // However, if the code is not NGHTTP_NO_ERROR, destroy the - // session immediately. We destroy with an error but send a - // goaway using NGHTTP2_NO_ERROR because there was no error - // condition on this side of the session that caused the - // shutdown. - session.destroy(new ERR_HTTP2_SESSION_ERROR(code), NGHTTP2_NO_ERROR); - } -} - -// When a ClientHttp2Session is first created, the socket may not yet be -// connected. If request() is called during this time, the actual request -// will be deferred until the socket is ready to go. -function requestOnConnect(headers, options) { - const session = this[kSession]; - - // At this point, the stream should have already been destroyed during - // the session.destroy() method. Do nothing else. - if (session === undefined || session.destroyed) - return; - - // If the session was closed while waiting for the connect, destroy - // the stream and do not continue with the request. - if (session.closed) { - const err = new ERR_HTTP2_GOAWAY_SESSION(); - this.destroy(err); - return; - } - - debugSessionObj(session, 'connected, initializing request'); - - let streamOptions = 0; - if (options.endStream) - streamOptions |= STREAM_OPTION_EMPTY_PAYLOAD; - - if (options.waitForTrailers) - streamOptions |= STREAM_OPTION_GET_TRAILERS; - - // `ret` will be either the reserved stream ID (if positive) - // or an error code (if negative) - const ret = session[kHandle].request(headers, - streamOptions, - options.parent | 0, - options.weight | 0, - !!options.exclusive); - - // In an error condition, one of three possible response codes will be - // possible: - // * NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE - Maximum stream ID is reached, this - // is fatal for the session - // * NGHTTP2_ERR_INVALID_ARGUMENT - Stream was made dependent on itself, this - // impacts on this stream. - // For the first two, emit the error on the session, - // For the third, emit the error on the stream, it will bubble up to the - // session if not handled. - if (typeof ret === 'number') { - let err; - switch (ret) { - case NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE: - err = new ERR_HTTP2_OUT_OF_STREAMS(); - this.destroy(err); - break; - case NGHTTP2_ERR_INVALID_ARGUMENT: - err = new ERR_HTTP2_STREAM_SELF_DEPENDENCY(); - this.destroy(err); - break; - default: - session.destroy(new NghttpError(ret)); - } - return; - } - this[kInit](ret.id(), ret); -} - -// Validates that priority options are correct, specifically: -// 1. options.weight must be a number -// 2. options.parent must be a positive number -// 3. options.exclusive must be a boolean -// 4. if specified, options.silent must be a boolean -// -// Also sets the default priority options if they are not set. -const setAndValidatePriorityOptions = hideStackFrames((options) => { - if (options.weight === undefined) { - options.weight = NGHTTP2_DEFAULT_WEIGHT; - } else if (typeof options.weight !== 'number') { - throw new ERR_INVALID_OPT_VALUE('weight', options.weight); - } - - if (options.parent === undefined) { - options.parent = 0; - } else if (typeof options.parent !== 'number' || options.parent < 0) { - throw new ERR_INVALID_OPT_VALUE('parent', options.parent); - } - - if (options.exclusive === undefined) { - options.exclusive = false; - } else if (typeof options.exclusive !== 'boolean') { - throw new ERR_INVALID_OPT_VALUE('exclusive', options.exclusive); - } - - if (options.silent === undefined) { - options.silent = false; - } else if (typeof options.silent !== 'boolean') { - throw new ERR_INVALID_OPT_VALUE('silent', options.silent); - } -}); - -// When an error occurs internally at the binding level, immediately -// destroy the session. -function onSessionInternalError(code) { - if (this[kOwner] !== undefined) - this[kOwner].destroy(new NghttpError(code)); -} - -function settingsCallback(cb, ack, duration) { - this[kState].pendingAck--; - this[kLocalSettings] = undefined; - if (ack) { - debugSessionObj(this, 'settings received'); - const settings = this.localSettings; - if (typeof cb === 'function') - cb(null, settings, duration); - this.emit('localSettings', settings); - } else { - debugSessionObj(this, 'settings canceled'); - if (typeof cb === 'function') - cb(new ERR_HTTP2_SETTINGS_CANCEL()); - } -} - -// Submits a SETTINGS frame to be sent to the remote peer. -function submitSettings(settings, callback) { - if (this.destroyed) - return; - debugSessionObj(this, 'submitting settings'); - this[kUpdateTimer](); - updateSettingsBuffer(settings); - if (!this[kHandle].settings(settingsCallback.bind(this, callback))) { - this.destroy(new ERR_HTTP2_MAX_PENDING_SETTINGS_ACK()); - } -} - -// Submits a PRIORITY frame to be sent to the remote peer -// Note: If the silent option is true, the change will be made -// locally with no PRIORITY frame sent. -function submitPriority(options) { - if (this.destroyed) - return; - this[kUpdateTimer](); - - // If the parent is the id, do nothing because a - // stream cannot be made to depend on itself. - if (options.parent === this[kID]) - return; - - this[kHandle].priority(options.parent | 0, - options.weight | 0, - !!options.exclusive, - !!options.silent); -} - -// Submit a GOAWAY frame to be sent to the remote peer. -// If the lastStreamID is set to <= 0, then the lastProcStreamID will -// be used. The opaqueData must either be a typed array or undefined -// (which will be checked elsewhere). -function submitGoaway(code, lastStreamID, opaqueData) { - if (this.destroyed) - return; - debugSessionObj(this, 'submitting goaway'); - this[kUpdateTimer](); - this[kHandle].goaway(code, lastStreamID, opaqueData); -} - -const proxySocketHandler = { - get(session, prop) { - switch (prop) { - case 'setTimeout': - case 'ref': - case 'unref': - return session[prop].bind(session); - case 'destroy': - case 'emit': - case 'end': - case 'pause': - case 'read': - case 'resume': - case 'write': - case 'setEncoding': - case 'setKeepAlive': - case 'setNoDelay': - throw new ERR_HTTP2_NO_SOCKET_MANIPULATION(); - default: - const socket = session[kSocket]; - if (socket === undefined) - throw new ERR_HTTP2_SOCKET_UNBOUND(); - const value = socket[prop]; - return typeof value === 'function' ? value.bind(socket) : value; - } - }, - getPrototypeOf(session) { - const socket = session[kSocket]; - if (socket === undefined) - throw new ERR_HTTP2_SOCKET_UNBOUND(); - return ReflectGetPrototypeOf(socket); - }, - set(session, prop, value) { - switch (prop) { - case 'setTimeout': - case 'ref': - case 'unref': - session[prop] = value; - return true; - case 'destroy': - case 'emit': - case 'end': - case 'pause': - case 'read': - case 'resume': - case 'write': - case 'setEncoding': - case 'setKeepAlive': - case 'setNoDelay': - throw new ERR_HTTP2_NO_SOCKET_MANIPULATION(); - default: - const socket = session[kSocket]; - if (socket === undefined) - throw new ERR_HTTP2_SOCKET_UNBOUND(); - socket[prop] = value; - return true; - } - } -}; - -// pingCallback() returns a function that is invoked when an HTTP2 PING -// frame acknowledgement is received. The ack is either true or false to -// indicate if the ping was successful or not. The duration indicates the -// number of milliseconds elapsed since the ping was sent and the ack -// received. The payload is a Buffer containing the 8 bytes of payload -// data received on the PING acknowledgement. -function pingCallback(cb) { - return function pingCallback(ack, duration, payload) { - if (ack) { - cb(null, duration, payload); - } else { - cb(new ERR_HTTP2_PING_CANCEL()); - } - }; -} - -// Validates the values in a settings object. Specifically: -// 1. headerTableSize must be a number in the range 0 <= n <= kMaxInt -// 2. initialWindowSize must be a number in the range 0 <= n <= kMaxInt -// 3. maxFrameSize must be a number in the range 16384 <= n <= kMaxFrameSize -// 4. maxConcurrentStreams must be a number in the range 0 <= n <= kMaxStreams -// 5. maxHeaderListSize must be a number in the range 0 <= n <= kMaxInt -// 6. enablePush must be a boolean -// 7. enableConnectProtocol must be a boolean -// All settings are optional and may be left undefined -const validateSettings = hideStackFrames((settings) => { - if (settings === undefined) return; - assertWithinRange('headerTableSize', - settings.headerTableSize, - 0, kMaxInt); - assertWithinRange('initialWindowSize', - settings.initialWindowSize, - 0, kMaxInt); - assertWithinRange('maxFrameSize', - settings.maxFrameSize, - 16384, kMaxFrameSize); - assertWithinRange('maxConcurrentStreams', - settings.maxConcurrentStreams, - 0, kMaxStreams); - assertWithinRange('maxHeaderListSize', - settings.maxHeaderListSize, - 0, kMaxInt); - if (settings.enablePush !== undefined && - typeof settings.enablePush !== 'boolean') { - throw new ERR_HTTP2_INVALID_SETTING_VALUE('enablePush', - settings.enablePush); - } - if (settings.enableConnectProtocol !== undefined && - typeof settings.enableConnectProtocol !== 'boolean') { - throw new ERR_HTTP2_INVALID_SETTING_VALUE('enableConnectProtocol', - settings.enableConnectProtocol); - } -}); - -// Creates the internal binding.Http2Session handle for an Http2Session -// instance. This occurs only after the socket connection has been -// established. Note: the binding.Http2Session will take over ownership -// of the socket. No other code should read from or write to the socket. -function setupHandle(socket, type, options) { - // If the session has been destroyed, go ahead and emit 'connect', - // but do nothing else. The various on('connect') handlers set by - // core will check for session.destroyed before progressing, this - // ensures that those at l`east get cleared out. - if (this.destroyed) { - process.nextTick(emit, this, 'connect', this, socket); - return; - } - - assert(socket._handle !== undefined, - 'Internal HTTP/2 Failure. The socket is not connected. Please ' + - 'report this as a bug in Node.js'); - - debugSession(type, 'setting up session handle'); - this[kState].flags |= SESSION_FLAGS_READY; - - updateOptionsBuffer(options); - const handle = new binding.Http2Session(type); - handle[kOwner] = this; - - if (typeof options.selectPadding === 'function') - this[kSelectPadding] = options.selectPadding; - handle.consume(socket._handle); - - this[kHandle] = handle; - if (this[kNativeFields]) - handle.fields.set(this[kNativeFields]); - else - this[kNativeFields] = handle.fields; - - if (socket.encrypted) { - this[kAlpnProtocol] = socket.alpnProtocol; - this[kEncrypted] = true; - } else { - // 'h2c' is the protocol identifier for HTTP/2 over plain-text. We use - // it here to identify any session that is not explicitly using an - // encrypted socket. - this[kAlpnProtocol] = 'h2c'; - this[kEncrypted] = false; - } - - if (isUint32(options.maxSessionInvalidFrames)) { - const uint32 = new Uint32Array( - this[kNativeFields].buffer, kSessionMaxInvalidFrames, 1); - uint32[0] = options.maxSessionInvalidFrames; - } - - if (isUint32(options.maxSessionRejectedStreams)) { - const uint32 = new Uint32Array( - this[kNativeFields].buffer, kSessionMaxRejectedStreams, 1); - uint32[0] = options.maxSessionRejectedStreams; - } - - const settings = typeof options.settings === 'object' ? - options.settings : {}; - - this.settings(settings); - - if (type === NGHTTP2_SESSION_SERVER && - ArrayIsArray(options.origins)) { - this.origin(...options.origins); - } - - process.nextTick(emit, this, 'connect', this, socket); -} - -// Emits a close event followed by an error event if err is truthy. Used -// by Http2Session.prototype.destroy() -function emitClose(self, error) { - if (error) - self.emit('error', error); - self.emit('close'); -} - -function cleanupSession(session) { - const socket = session[kSocket]; - const handle = session[kHandle]; - session[kProxySocket] = undefined; - session[kSocket] = undefined; - session[kHandle] = undefined; - session[kNativeFields] = new Uint8Array(kSessionUint8FieldCount); - if (handle) - handle.ondone = null; - if (socket) { - socket[kSession] = undefined; - socket[kServer] = undefined; - } -} - -function finishSessionClose(session, error) { - debugSessionObj(session, 'finishSessionClose'); - - const socket = session[kSocket]; - cleanupSession(session); - - if (socket && !socket.destroyed) { - // Always wait for writable side to finish. - socket.end((err) => { - debugSessionObj(session, 'finishSessionClose socket end', err); - // Due to the way the underlying stream is handled in Http2Session we - // won't get graceful Readable end from the other side even if it was sent - // as the stream is already considered closed and will neither be read - // from nor keep the event loop alive. - // Therefore destroy the socket immediately. - // Fixing this would require some heavy juggling of ReadStart/ReadStop - // mostly on Windows as on Unix it will be fine with just ReadStart - // after this 'ondone' callback. - socket.destroy(error); - emitClose(session, error); - }); - } else { - process.nextTick(emitClose, session, error); - } -} - -function closeSession(session, code, error) { - debugSessionObj(session, 'start closing/destroying'); - - const state = session[kState]; - state.flags |= SESSION_FLAGS_DESTROYED; - state.destroyCode = code; - - // Clear timeout and remove timeout listeners. - session.setTimeout(0); - session.removeAllListeners('timeout'); - - // Destroy any pending and open streams - if (state.pendingStreams.size > 0 || state.streams.size > 0) { - const cancel = new ERR_HTTP2_STREAM_CANCEL(error); - state.pendingStreams.forEach((stream) => stream.destroy(cancel)); - state.streams.forEach((stream) => stream.destroy(error)); - } - - // Disassociate from the socket and server. - const socket = session[kSocket]; - const handle = session[kHandle]; - - // Destroy the handle if it exists at this point. - if (handle !== undefined) { - handle.ondone = finishSessionClose.bind(null, session, error); - handle.destroy(code, socket.destroyed); - } else { - finishSessionClose(session, error); - } -} - -// 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. -// In that case, the Http2Session will wait for the socket to connect. Once -// the Http2Session is ready, it will emit its own 'connect' event. -// -// The Http2Session.goaway() method will send a GOAWAY frame, signalling -// to the connected peer that a shutdown is in progress. Sending a goaway -// frame has no other effect, however. -// -// Receiving a GOAWAY frame will cause the Http2Session to first emit a 'goaway' -// event notifying the user that a shutdown is in progress. If the goaway -// error code equals 0 (NGHTTP2_NO_ERROR), session.close() will be called, -// causing the Http2Session to send its own GOAWAY frame and switch itself -// into a graceful closing state. In this state, new inbound or outbound -// Http2Streams will be rejected. Existing *pending* streams (those created -// but without an assigned stream ID or handle) will be destroyed with a -// cancel error. Existing open streams will be permitted to complete on their -// own. Once all existing streams close, session.destroy() will be called -// automatically. -// -// Calling session.destroy() will tear down the Http2Session immediately, -// making it no longer usable. Pending and existing streams will be destroyed. -// The bound socket will be destroyed. Once all resources have been freed up, -// the 'close' event will be emitted. Note that pending streams will be -// destroyed using a specific "ERR_HTTP2_STREAM_CANCEL" error. Existing open -// streams will be destroyed using the same error passed to session.destroy() -// -// If destroy is called with an error, an 'error' event will be emitted -// immediately following the 'close' event. -// -// The socket and Http2Session lifecycles are tightly bound. Once one is -// destroyed, the other should also be destroyed. When the socket is destroyed -// with an error, session.destroy() will be called with that same error. -// Likewise, when session.destroy() is called with an error, the same error -// will be sent to the socket. -class Http2Session extends EventEmitter { - constructor(type, options, socket) { - super(); - - if (!socket._handle || !socket._handle.isStreamBase) { - socket = new JSStreamSocket(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 - // and cannot be attached again. - if (socket[kSession] !== undefined) - throw new ERR_HTTP2_SOCKET_BOUND(); - - socket[kSession] = this; - - this[kState] = { - destroyCode: NGHTTP2_NO_ERROR, - flags: SESSION_FLAGS_PENDING, - goawayCode: null, - goawayLastStreamID: null, - streams: new Map(), - pendingStreams: new Set(), - pendingAck: 0, - writeQueueSize: 0, - originSet: undefined - }; - - this[kEncrypted] = undefined; - this[kAlpnProtocol] = undefined; - this[kType] = type; - this[kProxySocket] = null; - this[kSocket] = socket; - this[kTimeout] = null; - this[kHandle] = undefined; - - // Do not use nagle's algorithm - if (typeof socket.setNoDelay === 'function') - socket.setNoDelay(); - - // Disable TLS renegotiation on the socket - if (typeof socket.disableRenegotiation === 'function') - socket.disableRenegotiation(); - - const setupFn = setupHandle.bind(this, socket, type, options); - if (socket.connecting || socket.secureConnecting) { - const connectEvent = - socket instanceof tls.TLSSocket ? 'secureConnect' : 'connect'; - socket.once(connectEvent, () => { - try { - setupFn(); - } catch (error) { - socket.destroy(error); - } - }); - } else { - setupFn(); - } - - if (!this[kNativeFields]) - this[kNativeFields] = new Uint8Array(kSessionUint8FieldCount); - this.on('newListener', sessionListenerAdded); - this.on('removeListener', sessionListenerRemoved); - - debugSession(type, 'created'); - } - - // Returns undefined if the socket is not yet connected, true if the - // socket is a TLSSocket, and false if it is not. - get encrypted() { - return this[kEncrypted]; - } - - // Returns undefined if the socket is not yet connected, `h2` if the - // socket is a TLSSocket and the alpnProtocol is `h2`, or `h2c` if the - // socket is not a TLSSocket. - get alpnProtocol() { - return this[kAlpnProtocol]; - } - - // TODO(jasnell): originSet is being added in preparation for ORIGIN frame - // support. At the current time, the ORIGIN frame specification is awaiting - // publication as an RFC and is awaiting implementation in nghttp2. Once - // added, an ORIGIN frame will add to the origins included in the origin - // set. 421 responses will remove origins from the set. - get originSet() { - if (!this.encrypted || this.destroyed) - return undefined; - return ArrayFrom(initOriginSet(this)); - } - - // True if the Http2Session is still waiting for the socket to connect - get connecting() { - return (this[kState].flags & SESSION_FLAGS_READY) === 0; - } - - // True if Http2Session.prototype.close() has been called - get closed() { - return !!(this[kState].flags & SESSION_FLAGS_CLOSED); - } - - // True if Http2Session.prototype.destroy() has been called - get destroyed() { - return !!(this[kState].flags & SESSION_FLAGS_DESTROYED); - } - - // Resets the timeout counter - [kUpdateTimer]() { - if (this.destroyed) - return; - if (this[kTimeout]) this[kTimeout].refresh(); - } - - // Sets the id of the next stream to be created by this Http2Session. - // The value must be a number in the range 0 <= n <= kMaxStreams. The - // value also needs to be larger than the current next stream ID. - setNextStreamID(id) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - validateNumber(id, 'id'); - if (id <= 0 || id > kMaxStreams) - throw new ERR_OUT_OF_RANGE('id', `> 0 and <= ${kMaxStreams}`, id); - this[kHandle].setNextStreamID(id); - } - - // If ping is called while we are still connecting, or after close() has - // been called, the ping callback will be invoked immediately will a ping - // cancelled error and a duration of 0.0. - ping(payload, callback) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - if (typeof payload === 'function') { - callback = payload; - payload = undefined; - } - if (payload && !isArrayBufferView(payload)) { - throw new ERR_INVALID_ARG_TYPE('payload', - ['Buffer', 'TypedArray', 'DataView'], - payload); - } - if (payload && payload.length !== 8) { - throw new ERR_HTTP2_PING_LENGTH(); - } - if (typeof callback !== 'function') - throw new ERR_INVALID_CALLBACK(callback); - - const cb = pingCallback(callback); - if (this.connecting || this.closed) { - process.nextTick(cb, false, 0.0, payload); - return; - } - - return this[kHandle].ping(payload, cb); - } - - [kInspect](depth, opts) { - if (typeof depth === 'number' && depth < 0) - return this; - - const obj = { - type: this[kType], - closed: this.closed, - destroyed: this.destroyed, - state: this.state, - localSettings: this.localSettings, - remoteSettings: this.remoteSettings - }; - return `Http2Session ${format(obj)}`; - } - - // The socket owned by this session - get socket() { - const proxySocket = this[kProxySocket]; - if (proxySocket === null) - return this[kProxySocket] = new Proxy(this, proxySocketHandler); - return proxySocket; - } - - // The session type - get type() { - return this[kType]; - } - - // If a GOAWAY frame has been received, gives the error code specified - get goawayCode() { - return this[kState].goawayCode || NGHTTP2_NO_ERROR; - } - - // If a GOAWAY frame has been received, gives the last stream ID reported - get goawayLastStreamID() { - return this[kState].goawayLastStreamID || 0; - } - - // True if the Http2Session is waiting for a settings acknowledgement - get pendingSettingsAck() { - return this[kState].pendingAck > 0; - } - - // Retrieves state information for the Http2Session - get state() { - return this.connecting || this.destroyed ? - {} : getSessionState(this[kHandle]); - } - - // The settings currently in effect for the local peer. These will - // be updated only when a settings acknowledgement has been received. - get localSettings() { - const settings = this[kLocalSettings]; - if (settings !== undefined) - return settings; - - if (this.destroyed || this.connecting) - return {}; - - return this[kLocalSettings] = getSettings(this[kHandle], false); // Local - } - - // The settings currently in effect for the remote peer. - get remoteSettings() { - if (this[kNativeFields][kBitfield] & - (1 << kSessionRemoteSettingsIsUpToDate)) { - const settings = this[kRemoteSettings]; - if (settings !== undefined) { - return settings; - } - } - - if (this.destroyed || this.connecting) - return {}; - - this[kNativeFields][kBitfield] |= (1 << kSessionRemoteSettingsIsUpToDate); - return this[kRemoteSettings] = getSettings(this[kHandle], true); // Remote - } - - // Submits a SETTINGS frame to be sent to the remote peer. - settings(settings, callback) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - assertIsObject(settings, 'settings'); - validateSettings(settings); - - if (callback && typeof callback !== 'function') - throw new ERR_INVALID_CALLBACK(callback); - debugSessionObj(this, 'sending settings'); - - this[kState].pendingAck++; - - const settingsFn = submitSettings.bind(this, { ...settings }, callback); - if (this.connecting) { - this.once('connect', settingsFn); - return; - } - settingsFn(); - } - - // Sumits a GOAWAY frame to be sent to the remote peer. Note that this - // is only a notification, and does not affect the usable state of the - // session with the notable exception that new incoming streams will - // be rejected automatically. - goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - if (opaqueData !== undefined && !isArrayBufferView(opaqueData)) { - throw new ERR_INVALID_ARG_TYPE('opaqueData', - ['Buffer', 'TypedArray', 'DataView'], - opaqueData); - } - validateNumber(code, 'code'); - validateNumber(lastStreamID, 'lastStreamID'); - - const goawayFn = submitGoaway.bind(this, code, lastStreamID, opaqueData); - if (this.connecting) { - this.once('connect', goawayFn); - return; - } - goawayFn(); - } - - // Destroy the Http2Session, making it no longer usable and cancelling - // any pending activity. - destroy(error = NGHTTP2_NO_ERROR, code) { - if (this.destroyed) - return; - - debugSessionObj(this, 'destroying'); - - if (typeof error === 'number') { - code = error; - error = - code !== NGHTTP2_NO_ERROR ? - new ERR_HTTP2_SESSION_ERROR(code) : undefined; - } - if (code === undefined && error != null) - code = NGHTTP2_INTERNAL_ERROR; - - closeSession(this, code, error); - } - - // Closing the session will: - // 1. Send a goaway frame - // 2. Mark the session as closed - // 3. Prevent new inbound or outbound streams from being opened - // 4. Optionally register a 'close' event handler - // 5. Will cause the session to automatically destroy after the - // last currently open Http2Stream closes. - // - // Close always assumes a good, non-error shutdown (NGHTTP_NO_ERROR) - // - // If the session has not connected yet, the closed flag will still be - // set but the goaway will not be sent until after the connect event - // is emitted. - close(callback) { - if (this.closed || this.destroyed) - return; - debugSessionObj(this, 'marking session closed'); - this[kState].flags |= SESSION_FLAGS_CLOSED; - if (typeof callback === 'function') - this.once('close', callback); - this.goaway(); - this[kMaybeDestroy](); - } - - [EventEmitter.captureRejectionSymbol](err, event, ...args) { - switch (event) { - case 'stream': - const [stream] = args; - stream.destroy(err); - break; - default: - this.destroy(err); - } - } - - // Destroy the session if: - // * error is not undefined/null - // * session is closed and there are no more pending or open streams - [kMaybeDestroy](error) { - if (error == null) { - const state = this[kState]; - // Do not destroy if we're not closed and there are pending/open streams - if (!this.closed || - state.streams.size > 0 || - state.pendingStreams.size > 0) { - return; - } - } - this.destroy(error); - } - - _onTimeout() { - callTimeout(this); - } - - ref() { - if (this[kSocket]) { - this[kSocket].ref(); - } - } - - unref() { - if (this[kSocket]) { - this[kSocket].unref(); - } - } -} - -// ServerHttp2Session instances should never have to wait for the socket -// to connect as they are always created after the socket has already been -// established. -class ServerHttp2Session extends Http2Session { - constructor(options, socket, server) { - super(NGHTTP2_SESSION_SERVER, options, socket); - this[kServer] = server; - // This is a bit inaccurate because it does not reflect changes to - // number of listeners made after the session was created. This should - // not be an issue in practice. Additionally, the 'priority' event on - // server instances (or any other object) is fully undocumented. - this[kNativeFields][kSessionPriorityListenerCount] = - server.listenerCount('priority'); - } - - get server() { - return this[kServer]; - } - - // Submits an altsvc frame to be sent to the client. `stream` is a - // numeric Stream ID. origin is a URL string that will be used to get - // the origin. alt is a string containing the altsvc details. No fancy - // API is provided for that. - altsvc(alt, originOrStream) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - let stream = 0; - let origin; - - if (typeof originOrStream === 'string') { - origin = (new URL(originOrStream)).origin; - if (origin === 'null') - throw new ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); - } else if (typeof originOrStream === 'number') { - if (originOrStream >>> 0 !== originOrStream || originOrStream === 0) { - throw new ERR_OUT_OF_RANGE('originOrStream', - `> 0 && < ${2 ** 32}`, originOrStream); - } - stream = originOrStream; - } else if (originOrStream !== undefined) { - // Allow origin to be passed a URL or object with origin property - if (originOrStream !== null && typeof originOrStream === 'object') - origin = originOrStream.origin; - // Note: if originOrStream is an object with an origin property other - // than a URL, then it is possible that origin will be malformed. - // We do not verify that here. Users who go that route need to - // ensure they are doing the right thing or the payload data will - // be invalid. - if (typeof origin !== 'string') { - throw new ERR_INVALID_ARG_TYPE('originOrStream', - ['string', 'number', 'URL', 'object'], - originOrStream); - } else if (origin === 'null' || origin.length === 0) { - throw new ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); - } - } - - validateString(alt, 'alt'); - if (!kQuotedString.test(alt)) - throw new ERR_INVALID_CHAR('alt'); - - // Max length permitted for ALTSVC - if ((alt.length + (origin !== undefined ? origin.length : 0)) > kMaxALTSVC) - throw new ERR_HTTP2_ALTSVC_LENGTH(); - - this[kHandle].altsvc(stream, origin || '', alt); - } - - // Submits an origin frame to be sent. - origin(...origins) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - if (origins.length === 0) - return; - - let arr = ''; - let len = 0; - const count = origins.length; - for (let i = 0; i < count; i++) { - let origin = origins[i]; - if (typeof origin === 'string') { - origin = (new URL(origin)).origin; - } else if (origin != null && typeof origin === 'object') { - origin = origin.origin; - } - validateString(origin, 'origin'); - if (origin === 'null') - throw new ERR_HTTP2_INVALID_ORIGIN(); - - arr += `${origin}\0`; - len += origin.length; - } - - if (len > kMaxALTSVC) - throw new ERR_HTTP2_ORIGIN_LENGTH(); - - this[kHandle].origin(arr, count); - } - -} - -// ClientHttp2Session instances have to wait for the socket to connect after -// they have been created. Various operations such as request() may be used, -// but the actual protocol communication will only occur after the socket -// has been connected. -class ClientHttp2Session extends Http2Session { - constructor(options, socket) { - super(NGHTTP2_SESSION_CLIENT, options, socket); - this[kPendingRequestCalls] = null; - } - - // Submits a new HTTP2 request to the connected peer. Returns the - // associated Http2Stream instance. - request(headers, options) { - debugSessionObj(this, 'initiating request'); - - if (this.destroyed) - throw new ERR_HTTP2_INVALID_SESSION(); - - if (this.closed) - throw new ERR_HTTP2_GOAWAY_SESSION(); - - this[kUpdateTimer](); - - if (headers !== null && headers !== undefined) { - for (const header of ObjectKeys(headers)) { - if (header[0] === ':') { - assertValidPseudoHeader(header); - } else if (header && !checkIsHttpToken(header)) - this.destroy(new ERR_INVALID_HTTP_TOKEN('Header name', header)); - } - } - - assertIsObject(headers, 'headers'); - assertIsObject(options, 'options'); - - headers = ObjectAssign(ObjectCreate(null), headers); - options = { ...options }; - - if (headers[HTTP2_HEADER_METHOD] === undefined) - headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_GET; - - const connect = headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_CONNECT; - - if (!connect || headers[HTTP2_HEADER_PROTOCOL] !== undefined) { - if (headers[HTTP2_HEADER_AUTHORITY] === undefined) - headers[HTTP2_HEADER_AUTHORITY] = this[kAuthority]; - if (headers[HTTP2_HEADER_SCHEME] === undefined) - headers[HTTP2_HEADER_SCHEME] = this[kProtocol].slice(0, -1); - if (headers[HTTP2_HEADER_PATH] === undefined) - headers[HTTP2_HEADER_PATH] = '/'; - } else { - if (headers[HTTP2_HEADER_AUTHORITY] === undefined) - throw new ERR_HTTP2_CONNECT_AUTHORITY(); - if (headers[HTTP2_HEADER_SCHEME] !== undefined) - throw new ERR_HTTP2_CONNECT_SCHEME(); - if (headers[HTTP2_HEADER_PATH] !== undefined) - throw new ERR_HTTP2_CONNECT_PATH(); - } - - setAndValidatePriorityOptions(options); - - if (options.endStream === undefined) { - // For some methods, we know that a payload is meaningless, so end the - // stream by default if the user has not specifically indicated a - // preference. - options.endStream = isPayloadMeaningless(headers[HTTP2_HEADER_METHOD]); - } else if (typeof options.endStream !== 'boolean') { - throw new ERR_INVALID_OPT_VALUE('endStream', options.endStream); - } - - const headersList = mapToHeaders(headers); - - const stream = new ClientHttp2Stream(this, undefined, undefined, {}); - stream[kSentHeaders] = headers; - stream[kOrigin] = `${headers[HTTP2_HEADER_SCHEME]}://` + - `${headers[HTTP2_HEADER_AUTHORITY]}`; - - // Close the writable side of the stream if options.endStream is set. - if (options.endStream) - stream.end(); - - if (options.waitForTrailers) - stream[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; - - const onConnect = requestOnConnect.bind(stream, headersList, options); - if (this.connecting) { - if (this[kPendingRequestCalls] !== null) { - this[kPendingRequestCalls].push(onConnect); - } else { - this[kPendingRequestCalls] = [onConnect]; - this.once('connect', () => { - this[kPendingRequestCalls].forEach((f) => f()); - this[kPendingRequestCalls] = null; - }); - } - } else { - onConnect(); - } - return stream; - } -} - -function trackWriteState(stream, bytes) { - const session = stream[kSession]; - stream[kState].writeQueueSize += bytes; - session[kState].writeQueueSize += bytes; - session[kHandle].chunksSentSinceLastWrite = 0; -} - -function streamOnResume() { - if (!this.destroyed) - this[kHandle].readStart(); -} - -function streamOnPause() { - if (!this.destroyed && !this.pending) - this[kHandle].readStop(); -} - -function afterShutdown(status) { - const stream = this.handle[kOwner]; - if (stream) { - stream.on('finish', () => { - stream[kMaybeDestroy](); - }); - } - // Currently this status value is unused - this.callback(); -} - -function finishSendTrailers(stream, headersList) { - // The stream might be destroyed and in that case - // there is nothing to do. - // This can happen because finishSendTrailers is - // scheduled via setImmediate. - if (stream.destroyed) { - return; - } - - stream[kState].flags &= ~STREAM_FLAGS_HAS_TRAILERS; - - const ret = stream[kHandle].trailers(headersList); - if (ret < 0) - stream.destroy(new NghttpError(ret)); - else - stream[kMaybeDestroy](); -} - -const kNoRstStream = 0; -const kSubmitRstStream = 1; -const kForceRstStream = 2; - -function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) { - const state = stream[kState]; - state.flags |= STREAM_FLAGS_CLOSED; - state.rstCode = code; - - // Clear timeout and remove timeout listeners - stream.setTimeout(0); - stream.removeAllListeners('timeout'); - - const { ending } = stream._writableState; - - if (!ending) { - // If the writable side of the Http2Stream is still open, emit the - // 'aborted' event and set the aborted flag. - if (!stream.aborted) { - state.flags |= STREAM_FLAGS_ABORTED; - stream.emit('aborted'); - } - - // Close the writable side. - stream.end(); - } - - if (rstStreamStatus !== kNoRstStream) { - const finishFn = finishCloseStream.bind(stream, code); - if (!ending || stream.writableFinished || code !== NGHTTP2_NO_ERROR || - rstStreamStatus === kForceRstStream) - finishFn(); - else - stream.once('finish', finishFn); - } -} - -function finishCloseStream(code) { - const rstStreamFn = submitRstStream.bind(this, code); - // If the handle has not yet been assigned, queue up the request to - // ensure that the RST_STREAM frame is sent after the stream ID has - // been determined. - if (this.pending) { - this.push(null); - this.once('ready', rstStreamFn); - return; - } - rstStreamFn(); -} - -// An Http2Stream is a Duplex stream that is backed by a -// node::http2::Http2Stream handle implementing StreamBase. -class Http2Stream extends Duplex { - constructor(session, options) { - options.allowHalfOpen = true; - options.decodeStrings = false; - options.autoDestroy = false; - super(options); - this[async_id_symbol] = -1; - - // Corking the stream automatically allows writes to happen - // but ensures that those are buffered until the handle has - // been assigned. - this.cork(); - this[kSession] = session; - session[kState].pendingStreams.add(this); - - // Allow our logic for determining whether any reads have happened to - // work in all situations. This is similar to what we do in _http_incoming. - this._readableState.readingMore = true; - - this[kTimeout] = null; - - this[kState] = { - didRead: false, - flags: STREAM_FLAGS_PENDING, - rstCode: NGHTTP2_NO_ERROR, - writeQueueSize: 0, - trailersReady: false, - endAfterHeaders: false - }; - - // Fields used by the compat API to avoid megamorphisms. - this[kRequest] = null; - this[kProxySocket] = null; - - this.on('pause', streamOnPause); - - this.on('newListener', streamListenerAdded); - this.on('removeListener', streamListenerRemoved); - } - - [kUpdateTimer]() { - if (this.destroyed) - return; - if (this[kTimeout]) - this[kTimeout].refresh(); - if (this[kSession]) - this[kSession][kUpdateTimer](); - } - - [kInit](id, handle) { - const state = this[kState]; - state.flags |= STREAM_FLAGS_READY; - - const session = this[kSession]; - session[kState].pendingStreams.delete(this); - session[kState].streams.set(id, this); - - this[kID] = id; - this[async_id_symbol] = handle.getAsyncId(); - handle[kOwner] = this; - this[kHandle] = handle; - handle.onread = onStreamRead; - this.uncork(); - this.emit('ready'); - } - - [kInspect](depth, opts) { - if (typeof depth === 'number' && depth < 0) - return this; - - const obj = { - id: this[kID] || '', - closed: this.closed, - destroyed: this.destroyed, - state: this.state, - readableState: this._readableState, - writableState: this._writableState - }; - return `Http2Stream ${format(obj)}`; - } - - get bufferSize() { - // `bufferSize` properties of `net.Socket` are `undefined` when - // their `_handle` are falsy. Here we avoid the behavior. - return this[kState].writeQueueSize + this.writableLength; - } - - get endAfterHeaders() { - return this[kState].endAfterHeaders; - } - - get sentHeaders() { - return this[kSentHeaders]; - } - - get sentTrailers() { - return this[kSentTrailers]; - } - - get sentInfoHeaders() { - return this[kInfoHeaders]; - } - - get pending() { - return this[kID] === undefined; - } - - // The id of the Http2Stream, will be undefined if the socket is not - // yet connected. - get id() { - return this[kID]; - } - - // The Http2Session that owns this Http2Stream. - get session() { - return this[kSession]; - } - - _onTimeout() { - callTimeout(this, kSession); - } - - // True if the HEADERS frame has been sent - get headersSent() { - return !!(this[kState].flags & STREAM_FLAGS_HEADERS_SENT); - } - - // True if the Http2Stream was aborted abnormally. - get aborted() { - return !!(this[kState].flags & STREAM_FLAGS_ABORTED); - } - - // True if dealing with a HEAD request - get headRequest() { - return !!(this[kState].flags & STREAM_FLAGS_HEAD_REQUEST); - } - - // The error code reported when this Http2Stream was closed. - get rstCode() { - return this[kState].rstCode; - } - - // State information for the Http2Stream - get state() { - const id = this[kID]; - if (this.destroyed || id === undefined) - return {}; - return getStreamState(this[kHandle], id); - } - - [kProceed]() { - assert.fail('Implementors MUST implement this. Please report this as a ' + - 'bug in Node.js'); - } - - [kAfterAsyncWrite]({ bytes }) { - this[kState].writeQueueSize -= bytes; - - if (this.session !== undefined) - this.session[kState].writeQueueSize -= bytes; - } - - [kWriteGeneric](writev, data, encoding, cb) { - // When the Http2Stream is first created, it is corked until the - // handle and the stream ID is assigned. However, if the user calls - // uncork() before that happens, the Duplex will attempt to pass - // writes through. Those need to be queued up here. - if (this.pending) { - this.once( - 'ready', - this[kWriteGeneric].bind(this, writev, data, encoding, cb) - ); - return; - } - - // If the stream has been destroyed, there's nothing else we can do - // because the handle has been destroyed. This should only be an - // issue if a write occurs before the 'ready' event in the case where - // the duplex is uncorked before the stream is ready to go. In that - // case, drop the data on the floor. An error should have already been - // emitted. - if (this.destroyed) - return; - - this[kUpdateTimer](); - if (!this.headersSent) - this[kProceed](); - - let req; - - // writeGeneric does not destroy on error and we cannot enable autoDestroy, - // so make sure to destroy on error. - const callback = (err) => { - if (err) { - this.destroy(err); - } - cb(err); - }; - - if (writev) - req = writevGeneric(this, data, callback); - else - req = writeGeneric(this, data, encoding, callback); - - trackWriteState(this, req.bytes); - } - - _write(data, encoding, cb) { - this[kWriteGeneric](false, data, encoding, cb); - } - - _writev(data, cb) { - this[kWriteGeneric](true, data, '', cb); - } - - _final(cb) { - const handle = this[kHandle]; - if (this.pending) { - this.once('ready', () => this._final(cb)); - } else if (handle !== undefined) { - debugStreamObj(this, '_final shutting down'); - const req = new ShutdownWrap(); - req.oncomplete = afterShutdown; - req.callback = cb; - req.handle = handle; - const err = handle.shutdown(req); - if (err === 1) // synchronous finish - return afterShutdown.call(req, 0); - } else { - cb(); - } - } - - _read(nread) { - if (this.destroyed) { - this.push(null); - return; - } - if (!this[kState].didRead) { - this._readableState.readingMore = false; - this[kState].didRead = true; - } - if (!this.pending) { - streamOnResume.call(this); - } else { - this.once('ready', streamOnResume); - } - } - - priority(options) { - if (this.destroyed) - throw new ERR_HTTP2_INVALID_STREAM(); - - assertIsObject(options, 'options'); - options = { ...options }; - setAndValidatePriorityOptions(options); - - const priorityFn = submitPriority.bind(this, options); - - // If the handle has not yet been assigned, queue up the priority - // frame to be sent as soon as the ready event is emitted. - if (this.pending) { - this.once('ready', priorityFn); - return; - } - priorityFn(); - } - - sendTrailers(headers) { - if (this.destroyed || this.closed) - throw new ERR_HTTP2_INVALID_STREAM(); - if (this[kSentTrailers]) - throw new ERR_HTTP2_TRAILERS_ALREADY_SENT(); - if (!this[kState].trailersReady) - throw new ERR_HTTP2_TRAILERS_NOT_READY(); - - assertIsObject(headers, 'headers'); - headers = ObjectAssign(ObjectCreate(null), headers); - - debugStreamObj(this, 'sending trailers'); - - this[kUpdateTimer](); - - const headersList = mapToHeaders(headers, assertValidPseudoHeaderTrailer); - this[kSentTrailers] = headers; - - // Send the trailers in setImmediate so we don't do it on nghttp2 stack. - setImmediate(finishSendTrailers, this, headersList); - } - - get closed() { - return !!(this[kState].flags & STREAM_FLAGS_CLOSED); - } - - // Close initiates closing the Http2Stream instance by sending an RST_STREAM - // frame to the connected peer. The readable and writable sides of the - // Http2Stream duplex are closed and the timeout timer is cleared. If - // a callback is passed, it is registered to listen for the 'close' event. - // - // If the handle and stream ID have not been assigned yet, the close - // will be queued up to wait for the ready event. As soon as the stream ID - // is determined, the close will proceed. - // - // Submitting the RST_STREAM frame to the underlying handle will cause - // the Http2Stream to be closed and ultimately destroyed. After calling - // close, it is still possible to queue up PRIORITY and RST_STREAM frames, - // but no DATA and HEADERS frames may be sent. - close(code = NGHTTP2_NO_ERROR, callback) { - validateInteger(code, 'code', 0, kMaxInt); - - if (callback !== undefined && typeof callback !== 'function') - throw new ERR_INVALID_CALLBACK(callback); - - if (this.closed) - return; - - if (callback !== undefined) - this.once('close', callback); - - closeStream(this, code); - } - - // Called by this.destroy(). - // * Will submit an RST stream to shutdown the stream if necessary. - // This will cause the internal resources to be released. - // * Then cleans up the resources on the js side - _destroy(err, callback) { - const session = this[kSession]; - const handle = this[kHandle]; - const id = this[kID]; - - debugStream(this[kID] || 'pending', session[kType], 'destroying stream'); - - const state = this[kState]; - const sessionCode = session[kState].goawayCode || - session[kState].destroyCode; - const code = err != null ? - sessionCode || NGHTTP2_INTERNAL_ERROR : - state.rstCode || sessionCode; - const hasHandle = handle !== undefined; - - if (!this.closed) - closeStream(this, code, hasHandle ? kForceRstStream : kNoRstStream); - this.push(null); - - if (hasHandle) { - handle.destroy(); - session[kState].streams.delete(id); - } else { - session[kState].pendingStreams.delete(this); - } - - // Adjust the write queue size for accounting - session[kState].writeQueueSize -= state.writeQueueSize; - state.writeQueueSize = 0; - - // RST code 8 not emitted as an error as its used by clients to signify - // abort and is already covered by aborted event, also allows more - // seamless compatibility with http1 - if (err == null && code !== NGHTTP2_NO_ERROR && code !== NGHTTP2_CANCEL) - err = new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code); - - this[kSession] = undefined; - this[kHandle] = undefined; - - // This notifies the session that this stream has been destroyed and - // gives the session the opportunity to clean itself up. The session - // will destroy if it has been closed and there are no other open or - // pending streams. - session[kMaybeDestroy](); - callback(err); - } - // The Http2Stream can be destroyed if it has closed and if the readable - // side has received the final chunk. - [kMaybeDestroy](code = NGHTTP2_NO_ERROR) { - if (code !== NGHTTP2_NO_ERROR) { - this.destroy(); - return; - } - - if (this.writableFinished) { - if (!this.readable && this.closed) { - this.destroy(); - return; - } - - // We've submitted a response from our server session, have not attempted - // to process any incoming data, and have no trailers. This means we can - // attempt to gracefully close the session. - const state = this[kState]; - if (this.headersSent && - this[kSession] && - this[kSession][kType] === NGHTTP2_SESSION_SERVER && - !(state.flags & STREAM_FLAGS_HAS_TRAILERS) && - !state.didRead && - this.readableFlowing === null) { - // By using setImmediate we allow pushStreams to make it through - // before the stream is officially closed. This prevents a bug - // in most browsers where those pushStreams would be rejected. - setImmediate(callStreamClose, this); - } - } - } -} - -function callTimeout(self, kSession) { - // If the session is destroyed, this should never actually be invoked, - // but just in case... - if (self.destroyed) - return; - // This checks whether a write is currently in progress and also whether - // that write is actually sending data across the write. The kHandle - // stored `chunksSentSinceLastWrite` is only updated when a timeout event - // happens, meaning that if a write is ongoing it should never equal the - // newly fetched, updated value. - if (self[kState].writeQueueSize > 0) { - const handle = kSession ? self[kSession][kHandle] : self[kHandle]; - const chunksSentSinceLastWrite = handle !== undefined ? - handle.chunksSentSinceLastWrite : null; - if (chunksSentSinceLastWrite !== null && - chunksSentSinceLastWrite !== handle.updateChunksSent()) { - self[kUpdateTimer](); - return; - } - } - - self.emit('timeout'); -} - -function callStreamClose(stream) { - stream.close(); -} - -function processHeaders(oldHeaders) { - assertIsObject(oldHeaders, 'headers'); - const headers = ObjectCreate(null); - - if (oldHeaders !== null && oldHeaders !== undefined) { - // This loop is here for performance reason. Do not change. - for (const key in oldHeaders) { - if (ObjectPrototypeHasOwnProperty(oldHeaders, key)) { - headers[key] = oldHeaders[key]; - } - } - headers[kSensitiveHeaders] = oldHeaders[kSensitiveHeaders]; - } - - const statusCode = - headers[HTTP2_HEADER_STATUS] = - headers[HTTP2_HEADER_STATUS] | 0 || HTTP_STATUS_OK; - - if (headers[HTTP2_HEADER_DATE] === null || - headers[HTTP2_HEADER_DATE] === undefined) - headers[HTTP2_HEADER_DATE] = utcDate(); - - // 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 compliance. - // 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 ERR_HTTP2_STATUS_INVALID(headers[HTTP2_HEADER_STATUS]); - - const neverIndex = headers[kSensitiveHeaders]; - if (neverIndex !== undefined && !ArrayIsArray(neverIndex)) - throw new ERR_INVALID_OPT_VALUE('headers[http2.neverIndex]', neverIndex); - - return headers; -} - - -function onFileUnpipe() { - const stream = this.sink[kOwner]; - if (stream.ownsFd) - this.source.close().catch(stream.destroy.bind(stream)); - else - this.source.releaseFD(); -} - -// This is only called once the pipe has returned back control, so -// it only has to handle errors and End-of-File. -function onPipedFileHandleRead() { - const err = streamBaseState[kReadBytesOrError]; - if (err < 0 && err !== UV_EOF) { - this.stream.close(NGHTTP2_INTERNAL_ERROR); - } -} - -function processRespondWithFD(self, fd, headers, offset = 0, length = -1, - streamOptions = 0) { - const state = self[kState]; - state.flags |= STREAM_FLAGS_HEADERS_SENT; - - let headersList; - try { - headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); - } catch (err) { - self.destroy(err); - return; - } - self[kSentHeaders] = headers; - - // Close the writable side of the stream, but only as far as the writable - // stream implementation is concerned. - self._final = null; - self.end(); - - const ret = self[kHandle].respond(headersList, streamOptions); - - if (ret < 0) { - self.destroy(new NghttpError(ret)); - return; - } - - defaultTriggerAsyncIdScope(self[async_id_symbol], startFilePipe, - self, fd, offset, length); -} - -function startFilePipe(self, fd, offset, length) { - const handle = new FileHandle(fd, offset, length); - handle.onread = onPipedFileHandleRead; - handle.stream = self; - - const pipe = new StreamPipe(handle, self[kHandle]); - pipe.onunpipe = onFileUnpipe; - pipe.start(); - - // Exact length of the file doesn't matter here, since the - // stream is closing anyway - just use 1 to signify that - // a write does exist - trackWriteState(self, 1); -} - -function doSendFD(session, options, fd, headers, streamOptions, err, stat) { - if (err) { - this.destroy(err); - return; - } - - // This can happen if the stream is destroyed or closed while we are waiting - // for the file descriptor to be opened or the stat call to be completed. - // In either case, we do not want to continue because the we are shutting - // down and should not attempt to send any data. - if (this.destroyed || this.closed) { - this.destroy(new ERR_HTTP2_INVALID_STREAM()); - return; - } - - const statOptions = { - offset: options.offset !== undefined ? options.offset : 0, - length: options.length !== undefined ? options.length : -1 - }; - - // options.statCheck is a user-provided function that can be used to - // verify stat values, override or set headers, or even cancel the - // response operation. If statCheck explicitly returns false, the - // response is canceled. The user code may also send a separate type - // of response so check again for the HEADERS_SENT flag - if ((typeof options.statCheck === 'function' && - options.statCheck.call(this, stat, headers, statOptions) === false) || - (this[kState].flags & STREAM_FLAGS_HEADERS_SENT)) { - return; - } - - processRespondWithFD(this, fd, headers, - statOptions.offset | 0, - statOptions.length | 0, - streamOptions); -} - -function doSendFileFD(session, options, fd, headers, streamOptions, err, stat) { - const onError = options.onError; - - if (err) { - tryClose(fd); - if (onError) - onError(err); - else - this.destroy(err); - return; - } - - if (!stat.isFile()) { - const isDirectory = stat.isDirectory(); - if (options.offset !== undefined || options.offset > 0 || - options.length !== undefined || options.length >= 0 || - isDirectory) { - const err = isDirectory ? - new ERR_HTTP2_SEND_FILE() : new ERR_HTTP2_SEND_FILE_NOSEEK(); - tryClose(fd); - if (onError) - onError(err); - else - this.destroy(err); - return; - } - - options.offset = -1; - options.length = -1; - } - - if (this.destroyed || this.closed) { - tryClose(fd); - this.destroy(new ERR_HTTP2_INVALID_STREAM()); - return; - } - - const statOptions = { - offset: options.offset !== undefined ? options.offset : 0, - length: options.length !== undefined ? options.length : -1 - }; - - // options.statCheck is a user-provided function that can be used to - // verify stat values, override or set headers, or even cancel the - // response operation. If statCheck explicitly returns false, the - // response is canceled. The user code may also send a separate type - // of response so check again for the HEADERS_SENT flag - if ((typeof options.statCheck === 'function' && - options.statCheck.call(this, stat, headers) === false) || - (this[kState].flags & STREAM_FLAGS_HEADERS_SENT)) { - tryClose(fd); - return; - } - - if (stat.isFile()) { - statOptions.length = - statOptions.length < 0 ? stat.size - (+statOptions.offset) : - MathMin(stat.size - (+statOptions.offset), - statOptions.length); - - headers[HTTP2_HEADER_CONTENT_LENGTH] = statOptions.length; - } - - processRespondWithFD(this, fd, headers, - options.offset | 0, - statOptions.length | 0, - streamOptions); -} - -function afterOpen(session, options, headers, streamOptions, err, fd) { - const state = this[kState]; - const onError = options.onError; - if (err) { - if (onError) - onError(err); - else - this.destroy(err); - return; - } - if (this.destroyed || this.closed) { - tryClose(fd); - return; - } - state.fd = fd; - - fs.fstat(fd, - doSendFileFD.bind(this, session, options, fd, - headers, streamOptions)); -} - -class ServerHttp2Stream extends Http2Stream { - constructor(session, handle, id, options, headers) { - super(session, options); - handle.owner = this; - this[kInit](id, handle); - this[kProtocol] = headers[HTTP2_HEADER_SCHEME]; - this[kAuthority] = headers[HTTP2_HEADER_AUTHORITY]; - } - - // True if the remote peer accepts push streams - get pushAllowed() { - return !this.destroyed && - !this.closed && - !this.session.closed && - !this.session.destroyed && - this[kSession].remoteSettings.enablePush; - } - - // Create a push stream, call the given callback with the created - // Http2Stream for the push stream. - pushStream(headers, options, callback) { - if (!this.pushAllowed) - throw new ERR_HTTP2_PUSH_DISABLED(); - if (this[kID] % 2 === 0) - throw new ERR_HTTP2_NESTED_PUSH(); - - const session = this[kSession]; - - debugStreamObj(this, 'initiating push stream'); - - this[kUpdateTimer](); - - if (typeof options === 'function') { - callback = options; - options = undefined; - } - - if (typeof callback !== 'function') - throw new ERR_INVALID_CALLBACK(callback); - - assertIsObject(options, 'options'); - options = { ...options }; - options.endStream = !!options.endStream; - - assertIsObject(headers, 'headers'); - headers = ObjectAssign(ObjectCreate(null), headers); - - if (headers[HTTP2_HEADER_METHOD] === undefined) - headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_GET; - if (headers[HTTP2_HEADER_AUTHORITY] === undefined) - headers[HTTP2_HEADER_AUTHORITY] = this[kAuthority]; - if (headers[HTTP2_HEADER_SCHEME] === undefined) - headers[HTTP2_HEADER_SCHEME] = this[kProtocol]; - if (headers[HTTP2_HEADER_PATH] === undefined) - headers[HTTP2_HEADER_PATH] = '/'; - - let headRequest = false; - if (headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD) - headRequest = options.endStream = true; - options.readable = false; - - const headersList = mapToHeaders(headers); - - const streamOptions = options.endStream ? STREAM_OPTION_EMPTY_PAYLOAD : 0; - - const ret = this[kHandle].pushPromise(headersList, streamOptions); - let err; - if (typeof ret === 'number') { - switch (ret) { - case NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE: - err = new ERR_HTTP2_OUT_OF_STREAMS(); - break; - case NGHTTP2_ERR_STREAM_CLOSED: - err = new ERR_HTTP2_INVALID_STREAM(); - break; - default: - err = new NghttpError(ret); - break; - } - process.nextTick(callback, err); - return; - } - - const id = ret.id(); - const stream = new ServerHttp2Stream(session, ret, id, options, headers); - stream[kSentHeaders] = headers; - - if (options.endStream) - stream.end(); - - if (headRequest) - stream[kState].flags |= STREAM_FLAGS_HEAD_REQUEST; - - process.nextTick(callback, null, stream, headers, 0); - } - - // Initiate a response on this Http2Stream - respond(headers, options) { - if (this.destroyed || this.closed) - throw new ERR_HTTP2_INVALID_STREAM(); - if (this.headersSent) - throw new ERR_HTTP2_HEADERS_SENT(); - - const state = this[kState]; - - assertIsObject(options, 'options'); - options = { ...options }; - - debugStreamObj(this, 'initiating response'); - this[kUpdateTimer](); - - options.endStream = !!options.endStream; - - let streamOptions = 0; - if (options.endStream) - streamOptions |= STREAM_OPTION_EMPTY_PAYLOAD; - - if (options.waitForTrailers) { - streamOptions |= STREAM_OPTION_GET_TRAILERS; - state.flags |= STREAM_FLAGS_HAS_TRAILERS; - } - - headers = processHeaders(headers); - const headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); - this[kSentHeaders] = headers; - - state.flags |= STREAM_FLAGS_HEADERS_SENT; - - // Close the writable side if the endStream option is set or status - // is one of known codes with no payload, or it's a head request - const statusCode = headers[HTTP2_HEADER_STATUS] | 0; - if (!!options.endStream || - statusCode === HTTP_STATUS_NO_CONTENT || - statusCode === HTTP_STATUS_RESET_CONTENT || - statusCode === HTTP_STATUS_NOT_MODIFIED || - this.headRequest === true) { - options.endStream = true; - this.end(); - } - - const ret = this[kHandle].respond(headersList, streamOptions); - if (ret < 0) - this.destroy(new NghttpError(ret)); - } - - // Initiate a response using an open FD. Note that there are fewer - // protections with this approach. For one, the fd is not validated by - // default. In respondWithFile, the file is checked to make sure it is a - // regular file, here the fd is passed directly. If the underlying - // mechanism is not able to read from the fd, then the stream will be - // reset with an error code. - respondWithFD(fd, headers, options) { - if (this.destroyed || this.closed) - throw new ERR_HTTP2_INVALID_STREAM(); - if (this.headersSent) - throw new ERR_HTTP2_HEADERS_SENT(); - - const session = this[kSession]; - - assertIsObject(options, 'options'); - options = { ...options }; - - if (options.offset !== undefined && typeof options.offset !== 'number') - throw new ERR_INVALID_OPT_VALUE('offset', options.offset); - - if (options.length !== undefined && typeof options.length !== 'number') - throw new ERR_INVALID_OPT_VALUE('length', options.length); - - if (options.statCheck !== undefined && - typeof options.statCheck !== 'function') { - throw new ERR_INVALID_OPT_VALUE('statCheck', options.statCheck); - } - - let streamOptions = 0; - if (options.waitForTrailers) { - streamOptions |= STREAM_OPTION_GET_TRAILERS; - this[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; - } - - if (fd instanceof fsPromisesInternal.FileHandle) - fd = fd.fd; - else if (typeof fd !== 'number') - throw new ERR_INVALID_ARG_TYPE('fd', ['number', 'FileHandle'], fd); - - debugStreamObj(this, 'initiating response from fd'); - this[kUpdateTimer](); - this.ownsFd = false; - - headers = processHeaders(headers); - const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; - // Payload/DATA frames are not permitted in these cases - if (statusCode === HTTP_STATUS_NO_CONTENT || - statusCode === HTTP_STATUS_RESET_CONTENT || - statusCode === HTTP_STATUS_NOT_MODIFIED || - this.headRequest) { - throw new ERR_HTTP2_PAYLOAD_FORBIDDEN(statusCode); - } - - if (options.statCheck !== undefined) { - fs.fstat(fd, - doSendFD.bind(this, session, options, fd, - headers, streamOptions)); - return; - } - - processRespondWithFD(this, fd, headers, - options.offset, - options.length, - streamOptions); - } - - // Initiate a file response on this Http2Stream. The path is passed to - // fs.open() to acquire the fd with mode 'r', then the fd is passed to - // fs.fstat(). Assuming fstat is successful, a check is made to ensure - // that the file is a regular file, then options.statCheck is called, - // giving the user an opportunity to verify the details and set additional - // headers. If statCheck returns false, the operation is aborted and no - // file details are sent. - respondWithFile(path, headers, options) { - if (this.destroyed || this.closed) - throw new ERR_HTTP2_INVALID_STREAM(); - if (this.headersSent) - throw new ERR_HTTP2_HEADERS_SENT(); - - assertIsObject(options, 'options'); - options = { ...options }; - - if (options.offset !== undefined && typeof options.offset !== 'number') - throw new ERR_INVALID_OPT_VALUE('offset', options.offset); - - if (options.length !== undefined && typeof options.length !== 'number') - throw new ERR_INVALID_OPT_VALUE('length', options.length); - - if (options.statCheck !== undefined && - typeof options.statCheck !== 'function') { - throw new ERR_INVALID_OPT_VALUE('statCheck', options.statCheck); - } - - let streamOptions = 0; - if (options.waitForTrailers) { - streamOptions |= STREAM_OPTION_GET_TRAILERS; - this[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; - } - - const session = this[kSession]; - debugStreamObj(this, 'initiating response from file'); - this[kUpdateTimer](); - this.ownsFd = true; - - headers = processHeaders(headers); - const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; - // Payload/DATA frames are not permitted in these cases - if (statusCode === HTTP_STATUS_NO_CONTENT || - statusCode === HTTP_STATUS_RESET_CONTENT || - statusCode === HTTP_STATUS_NOT_MODIFIED || - this.headRequest) { - throw new ERR_HTTP2_PAYLOAD_FORBIDDEN(statusCode); - } +const { + ObjectDefineProperty, + Promise, + Symbol, +} = primordials; - fs.open(path, 'r', - afterOpen.bind(this, session, options, headers, streamOptions)); - } +const { + assertCrypto, + promisify +} = require('internal/util'); - // Sends a block of informational headers. In theory, the HTTP/2 spec - // allows sending a HEADER block at any time during a streams lifecycle, - // but the HTTP request/response semantics defined in HTTP/2 places limits - // such that HEADERS may only be sent *before* or *after* DATA frames. - // If the block of headers being sent includes a status code, it MUST be - // a 1xx informational code and it MUST be sent before the request/response - // headers are sent, or an error will be thrown. - additionalHeaders(headers) { - if (this.destroyed || this.closed) - throw new ERR_HTTP2_INVALID_STREAM(); - if (this.headersSent) - throw new ERR_HTTP2_HEADERS_AFTER_RESPOND(); +assertCrypto(); - assertIsObject(headers, 'headers'); - headers = ObjectAssign(ObjectCreate(null), headers); +const EventEmitter = require('events'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { URL } = require('url'); - debugStreamObj(this, 'sending additional headers'); +const { + kIncomingMessage, +} = require('_http_common'); +const { kServerResponse } = require('_http_server'); - if (headers[HTTP2_HEADER_STATUS] != null) { - const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; - if (statusCode === HTTP_STATUS_SWITCHING_PROTOCOLS) - throw new ERR_HTTP2_STATUS_101(); - if (statusCode < 100 || statusCode >= 200) { - throw new ERR_HTTP2_INVALID_INFO_STATUS(headers[HTTP2_HEADER_STATUS]); - } - } +const { + codes: { + ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH, + ERR_HTTP2_UNSUPPORTED_PROTOCOL, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_CALLBACK, + ERR_SOCKET_CLOSED + }, +} = require('internal/errors'); +const { validateUint32 } = require('internal/validators'); +const { onServerStream, + Http2ServerRequest, + Http2ServerResponse, +} = require('internal/http2/compat'); +const { + kAuthority, + kNativeFields, + kProtocol, + onStreamClose, + onStreamTrailers, + Http2Stream, +} = require('internal/http2/streams'); +const { + constants: { + kServer, + }, + debugSessionObj, + onAltSvc, + onFrameError, + onGoawayData, + onOrigin, + onPing, + onPriority, + onSessionHeaders, + onSessionInternalError, + onSettings, + sessionOnError, + sessionOnPriority, + sessionOnStream, + sessionOnTimeout, + ClientHttp2Session, + Http2Session, + ServerHttp2Session, +} = require('internal/http2/session'); - this[kUpdateTimer](); +const { + assertIsObject, + getDefaultSettings, + kSensitiveHeaders, + kState, + updateSettingsBuffer, + validateSettings, +} = require('internal/http2/util'); +const { + kMaybeDestroy, + kSession, +} = require('internal/stream_base_commons'); +const { isArrayBufferView } = require('internal/util/types'); - const headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); - if (!this[kInfoHeaders]) - this[kInfoHeaders] = [headers]; - else - this[kInfoHeaders].push(headers); +const binding = internalBinding('http2'); - const ret = this[kHandle].info(headersList); - if (ret < 0) - this.destroy(new NghttpError(ret)); - } -} +const { _connectionListener: httpConnectionListener } = http; +let debug = require('internal/util/debuglog').debuglog('http2', (fn) => { + debug = fn; +}); -ServerHttp2Stream.prototype[kProceed] = ServerHttp2Stream.prototype.respond; +const { + constants, + kSessionPriorityListenerCount, +} = binding; -class ClientHttp2Stream extends Http2Stream { - constructor(session, handle, id, options) { - super(session, options); - this[kState].flags |= STREAM_FLAGS_HEADERS_SENT; - if (id !== undefined) - this[kInit](id, handle); - this.on('headers', handleHeaderContinue); - } -} +const NETServer = net.Server; +const TLSServer = tls.Server; -function handleHeaderContinue(headers) { - if (headers[HTTP2_HEADER_STATUS] === HTTP_STATUS_CONTINUE) - this.emit('continue'); -} +const kOptions = Symbol('options'); -const setTimeout = { - configurable: true, - enumerable: true, - writable: true, - value: setStreamTimeout -}; -ObjectDefineProperty(Http2Stream.prototype, 'setTimeout', setTimeout); -ObjectDefineProperty(Http2Session.prototype, 'setTimeout', setTimeout); +const { + NGHTTP2_CANCEL, + HTTP2_HEADER_STATUS, + 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, + NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL, +} = constants; // When the socket emits an error, destroy the associated Http2Session and // forward it the same error. @@ -2795,34 +127,6 @@ function socketOnError(error) { } } -// Handles the on('stream') event for a session and forwards -// it on to the server object. -function sessionOnStream(stream, headers, flags, rawHeaders) { - if (this[kServer] !== undefined) - this[kServer].emit('stream', stream, headers, flags, rawHeaders); -} - -function sessionOnPriority(stream, parent, weight, exclusive) { - if (this[kServer] !== undefined) - this[kServer].emit('priority', stream, parent, weight, exclusive); -} - -function sessionOnError(error) { - if (this[kServer] !== undefined) - this[kServer].emit('sessionError', error, this); -} - -// When the session times out on the server, try emitting a timeout event. -// If no handler is registered, destroy the session. -function sessionOnTimeout() { - // If destroyed or closed already, do nothing - if (this.destroyed || this.closed) - return; - const server = this[kServer]; - if (!server.emit('timeout', this)) - this.destroy(); // No error code, just things down. -} - function connectionListener(socket) { debug('Http2Session server: received a connection'); const options = this[kOptions] || {}; @@ -3178,5 +482,3 @@ module.exports = { Http2ServerRequest, Http2ServerResponse }; - -/* eslint-enable no-use-before-define */ diff --git a/lib/internal/http2/session.js b/lib/internal/http2/session.js new file mode 100644 index 00000000000000..4aa886f4ace3aa --- /dev/null +++ b/lib/internal/http2/session.js @@ -0,0 +1,1455 @@ +'use strict'; + +const { + ArrayFrom, + ArrayIsArray, + Map, + ObjectAssign, + ObjectCreate, + ObjectDefineProperty, + ObjectKeys, + ReflectGetPrototypeOf, + Set, + Symbol, +} = primordials; + +const { + customInspectSymbol: kInspect, +} = require('internal/util'); +const assert = require('assert'); +const EventEmitter = require('events'); +const tls = require('tls'); +const { URL } = require('url'); +const { + _checkIsHttpToken: checkIsHttpToken +} = require('_http_common'); +const JSStreamSocket = require('internal/js_stream_socket'); + +const { + symbols: { + owner_symbol, + }, +} = require('internal/async_hooks'); +const { + codes: { + ERR_HTTP2_ALTSVC_INVALID_ORIGIN, + ERR_HTTP2_ALTSVC_LENGTH, + ERR_HTTP2_CONNECT_AUTHORITY, + ERR_HTTP2_CONNECT_PATH, + ERR_HTTP2_CONNECT_SCHEME, + ERR_HTTP2_GOAWAY_SESSION, + ERR_HTTP2_INVALID_ORIGIN, + ERR_HTTP2_INVALID_SESSION, + ERR_HTTP2_MAX_PENDING_SETTINGS_ACK, + ERR_HTTP2_NO_SOCKET_MANIPULATION, + ERR_HTTP2_ORIGIN_LENGTH, + ERR_HTTP2_OUT_OF_STREAMS, + ERR_HTTP2_PING_CANCEL, + ERR_HTTP2_PING_LENGTH, + ERR_HTTP2_SESSION_ERROR, + ERR_HTTP2_SETTINGS_CANCEL, + ERR_HTTP2_SOCKET_BOUND, + ERR_HTTP2_SOCKET_UNBOUND, + ERR_HTTP2_STREAM_CANCEL, + ERR_HTTP2_STREAM_SELF_DEPENDENCY, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_CALLBACK, + ERR_INVALID_CHAR, + ERR_INVALID_HTTP_TOKEN, + ERR_INVALID_OPT_VALUE, + ERR_OUT_OF_RANGE, + }, +} = require('internal/errors'); +const { validateNumber, + validateString, + isUint32, +} = require('internal/validators'); +const { + assertIsObject, + assertValidPseudoHeader, + callTimeout, + getSessionState, + getSettings, + isPayloadMeaningless, + sessionName, + setAndValidatePriorityOptions, + toHeaderObject, + kMaxStreams, + kSocket, + kState, + kProxySocket, + mapToHeaders, + updateOptionsBuffer, + updateSettingsBuffer, + validateSettings, + NghttpError, +} = require('internal/http2/util'); +const { + debugStream, + kAuthority, + kInit, + kNativeFields, + kType, + kProtocol, + kSentHeaders, + ClientHttp2Stream, + ServerHttp2Stream, + STREAM_FLAGS_HAS_TRAILERS, + STREAM_FLAGS_HEAD_REQUEST, +} = require('internal/http2/streams'); +const { + kMaybeDestroy, + kUpdateTimer, + kHandle, + kSession, + setStreamTimeout, +} = require('internal/stream_base_commons'); +const { kTimeout } = require('internal/timers'); +const { format } = require('internal/util/inspect'); +const { isArrayBufferView } = require('internal/util/types'); + +const binding = internalBinding('http2'); + +let debug = require('internal/util/debuglog').debuglog('http2', (fn) => { + debug = fn; +}); + +function debugSession(sessionType, message, ...args) { + debug('Http2Session %s: ' + message, sessionName(sessionType), ...args); +} + +function debugSessionObj(session, message, ...args) { + debugSession(session[kType], message, ...args); +} + +const kMaxALTSVC = (2 ** 14) - 2; + +// eslint-disable-next-line no-control-regex +const kQuotedString = /^[\x09\x20-\x5b\x5d-\x7e\x80-\xff]*$/; + +const kAlpnProtocol = Symbol('alpnProtocol'); +const kEncrypted = Symbol('encrypted'); +const kLocalSettings = Symbol('local-settings'); +const kSelectPadding = Symbol('select-padding'); +const kPendingRequestCalls = Symbol('kPendingRequestCalls'); +const kOwner = owner_symbol; +const kOrigin = Symbol('origin'); +const kRemoteSettings = Symbol('remote-settings'); +const kServer = Symbol('server'); + +const { + constants, + kBitfield, + kSessionPriorityListenerCount, + kSessionFrameErrorListenerCount, + kSessionMaxInvalidFrames, + kSessionMaxRejectedStreams, + kSessionUint8FieldCount, + kSessionHasRemoteSettingsListeners, + kSessionRemoteSettingsIsUpToDate, + kSessionHasPingListeners, + kSessionHasAltsvcListeners, +} = binding; + +const { + NGHTTP2_REFUSED_STREAM, + NGHTTP2_FLAG_END_STREAM, + NGHTTP2_HCAT_PUSH_RESPONSE, + NGHTTP2_HCAT_RESPONSE, + NGHTTP2_INTERNAL_ERROR, + NGHTTP2_NO_ERROR, + NGHTTP2_SESSION_CLIENT, + NGHTTP2_SESSION_SERVER, + NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE, + NGHTTP2_ERR_INVALID_ARGUMENT, + HTTP2_HEADER_AUTHORITY, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_HEADER_PROTOCOL, + HTTP2_HEADER_SCHEME, + HTTP2_HEADER_STATUS, + + HTTP2_METHOD_GET, + HTTP2_METHOD_HEAD, + HTTP2_METHOD_CONNECT, + + HTTP_STATUS_MISDIRECTED_REQUEST, + + STREAM_OPTION_EMPTY_PAYLOAD, + STREAM_OPTION_GET_TRAILERS +} = constants; + +const SESSION_FLAGS_PENDING = 0x0; +const SESSION_FLAGS_READY = 0x1; +const SESSION_FLAGS_CLOSED = 0x2; +const SESSION_FLAGS_DESTROYED = 0x4; + +// Top level to avoid creating a closure +function emit(self, ...args) { + self.emit(...args); +} + +// 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. +// In that case, the Http2Session will wait for the socket to connect. Once +// the Http2Session is ready, it will emit its own 'connect' event. +// +// The Http2Session.goaway() method will send a GOAWAY frame, signalling +// to the connected peer that a shutdown is in progress. Sending a goaway +// frame has no other effect, however. +// +// Receiving a GOAWAY frame will cause the Http2Session to first emit a 'goaway' +// event notifying the user that a shutdown is in progress. If the goaway +// error code equals 0 (NGHTTP2_NO_ERROR), session.close() will be called, +// causing the Http2Session to send its own GOAWAY frame and switch itself +// into a graceful closing state. In this state, new inbound or outbound +// Http2Streams will be rejected. Existing *pending* streams (those created +// but without an assigned stream ID or handle) will be destroyed with a +// cancel error. Existing open streams will be permitted to complete on their +// own. Once all existing streams close, session.destroy() will be called +// automatically. +// +// Calling session.destroy() will tear down the Http2Session immediately, +// making it no longer usable. Pending and existing streams will be destroyed. +// The bound socket will be destroyed. Once all resources have been freed up, +// the 'close' event will be emitted. Note that pending streams will be +// destroyed using a specific "ERR_HTTP2_STREAM_CANCEL" error. Existing open +// streams will be destroyed using the same error passed to session.destroy() +// +// If destroy is called with an error, an 'error' event will be emitted +// immediately following the 'close' event. +// +// The socket and Http2Session lifecycles are tightly bound. Once one is +// destroyed, the other should also be destroyed. When the socket is destroyed +// with an error, session.destroy() will be called with that same error. +// Likewise, when session.destroy() is called with an error, the same error +// will be sent to the socket. +class Http2Session extends EventEmitter { + constructor(type, options, socket) { + super(); + + if (!socket._handle || !socket._handle.isStreamBase) { + socket = new JSStreamSocket(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 + // and cannot be attached again. + if (socket[kSession] !== undefined) + throw new ERR_HTTP2_SOCKET_BOUND(); + + socket[kSession] = this; + + this[kState] = { + destroyCode: NGHTTP2_NO_ERROR, + flags: SESSION_FLAGS_PENDING, + goawayCode: null, + goawayLastStreamID: null, + streams: new Map(), + pendingStreams: new Set(), + pendingAck: 0, + writeQueueSize: 0, + originSet: undefined + }; + + this[kEncrypted] = undefined; + this[kAlpnProtocol] = undefined; + this[kType] = type; + this[kProxySocket] = null; + this[kSocket] = socket; + this[kTimeout] = null; + this[kHandle] = undefined; + + // Do not use nagle's algorithm + if (typeof socket.setNoDelay === 'function') + socket.setNoDelay(); + + // Disable TLS renegotiation on the socket + if (typeof socket.disableRenegotiation === 'function') + socket.disableRenegotiation(); + + const setupFn = setupHandle.bind(this, socket, type, options); + if (socket.connecting || socket.secureConnecting) { + const connectEvent = + socket instanceof tls.TLSSocket ? 'secureConnect' : 'connect'; + socket.once(connectEvent, () => { + try { + setupFn(); + } catch (error) { + socket.destroy(error); + } + }); + } else { + setupFn(); + } + + if (!this[kNativeFields]) + this[kNativeFields] = new Uint8Array(kSessionUint8FieldCount); + this.on('newListener', sessionListenerAdded); + this.on('removeListener', sessionListenerRemoved); + + debugSession(type, 'created'); + } + + // Returns undefined if the socket is not yet connected, true if the + // socket is a TLSSocket, and false if it is not. + get encrypted() { + return this[kEncrypted]; + } + + // Returns undefined if the socket is not yet connected, `h2` if the + // socket is a TLSSocket and the alpnProtocol is `h2`, or `h2c` if the + // socket is not a TLSSocket. + get alpnProtocol() { + return this[kAlpnProtocol]; + } + + // TODO(jasnell): originSet is being added in preparation for ORIGIN frame + // support. At the current time, the ORIGIN frame specification is awaiting + // publication as an RFC and is awaiting implementation in nghttp2. Once + // added, an ORIGIN frame will add to the origins included in the origin + // set. 421 responses will remove origins from the set. + get originSet() { + if (!this.encrypted || this.destroyed) + return undefined; + return ArrayFrom(initOriginSet(this)); + } + + // True if the Http2Session is still waiting for the socket to connect + get connecting() { + return (this[kState].flags & SESSION_FLAGS_READY) === 0; + } + + // True if Http2Session.prototype.close() has been called + get closed() { + return !!(this[kState].flags & SESSION_FLAGS_CLOSED); + } + + // True if Http2Session.prototype.destroy() has been called + get destroyed() { + return !!(this[kState].flags & SESSION_FLAGS_DESTROYED); + } + + // Resets the timeout counter + [kUpdateTimer]() { + if (this.destroyed) + return; + if (this[kTimeout]) this[kTimeout].refresh(); + } + + // Sets the id of the next stream to be created by this Http2Session. + // The value must be a number in the range 0 <= n <= kMaxStreams. The + // value also needs to be larger than the current next stream ID. + setNextStreamID(id) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + validateNumber(id, 'id'); + if (id <= 0 || id > kMaxStreams) + throw new ERR_OUT_OF_RANGE('id', `> 0 and <= ${kMaxStreams}`, id); + this[kHandle].setNextStreamID(id); + } + + // If ping is called while we are still connecting, or after close() has + // been called, the ping callback will be invoked immediately will a ping + // cancelled error and a duration of 0.0. + ping(payload, callback) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + if (typeof payload === 'function') { + callback = payload; + payload = undefined; + } + if (payload && !isArrayBufferView(payload)) { + throw new ERR_INVALID_ARG_TYPE('payload', + ['Buffer', 'TypedArray', 'DataView'], + payload); + } + if (payload && payload.length !== 8) { + throw new ERR_HTTP2_PING_LENGTH(); + } + if (typeof callback !== 'function') + throw new ERR_INVALID_CALLBACK(callback); + + const cb = pingCallback(callback); + if (this.connecting || this.closed) { + process.nextTick(cb, false, 0.0, payload); + return; + } + + return this[kHandle].ping(payload, cb); + } + + [kInspect](depth, opts) { + if (typeof depth === 'number' && depth < 0) + return this; + + const obj = { + type: this[kType], + closed: this.closed, + destroyed: this.destroyed, + state: this.state, + localSettings: this.localSettings, + remoteSettings: this.remoteSettings + }; + return `Http2Session ${format(obj)}`; + } + + // The socket owned by this session + get socket() { + const proxySocket = this[kProxySocket]; + if (proxySocket === null) + return this[kProxySocket] = new Proxy(this, proxySocketHandler); + return proxySocket; + } + + // The session type + get type() { + return this[kType]; + } + + // If a GOAWAY frame has been received, gives the error code specified + get goawayCode() { + return this[kState].goawayCode || NGHTTP2_NO_ERROR; + } + + // If a GOAWAY frame has been received, gives the last stream ID reported + get goawayLastStreamID() { + return this[kState].goawayLastStreamID || 0; + } + + // True if the Http2Session is waiting for a settings acknowledgement + get pendingSettingsAck() { + return this[kState].pendingAck > 0; + } + + // Retrieves state information for the Http2Session + get state() { + return this.connecting || this.destroyed ? + {} : getSessionState(this[kHandle]); + } + + // The settings currently in effect for the local peer. These will + // be updated only when a settings acknowledgement has been received. + get localSettings() { + const settings = this[kLocalSettings]; + if (settings !== undefined) + return settings; + + if (this.destroyed || this.connecting) + return {}; + + return this[kLocalSettings] = getSettings(this[kHandle], false); // Local + } + + // The settings currently in effect for the remote peer. + get remoteSettings() { + if (this[kNativeFields][kBitfield] & + (1 << kSessionRemoteSettingsIsUpToDate)) { + const settings = this[kRemoteSettings]; + if (settings !== undefined) { + return settings; + } + } + + if (this.destroyed || this.connecting) + return {}; + + this[kNativeFields][kBitfield] |= (1 << kSessionRemoteSettingsIsUpToDate); + return this[kRemoteSettings] = getSettings(this[kHandle], true); // Remote + } + + // Submits a SETTINGS frame to be sent to the remote peer. + settings(settings, callback) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + assertIsObject(settings, 'settings'); + validateSettings(settings); + + if (callback && typeof callback !== 'function') + throw new ERR_INVALID_CALLBACK(callback); + debugSessionObj(this, 'sending settings'); + + this[kState].pendingAck++; + + const settingsFn = submitSettings.bind(this, { ...settings }, callback); + if (this.connecting) { + this.once('connect', settingsFn); + return; + } + settingsFn(); + } + + // Sumits a GOAWAY frame to be sent to the remote peer. Note that this + // is only a notification, and does not affect the usable state of the + // session with the notable exception that new incoming streams will + // be rejected automatically. + goaway(code = NGHTTP2_NO_ERROR, lastStreamID = 0, opaqueData) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + if (opaqueData !== undefined && !isArrayBufferView(opaqueData)) { + throw new ERR_INVALID_ARG_TYPE('opaqueData', + ['Buffer', 'TypedArray', 'DataView'], + opaqueData); + } + validateNumber(code, 'code'); + validateNumber(lastStreamID, 'lastStreamID'); + + const goawayFn = submitGoaway.bind(this, code, lastStreamID, opaqueData); + if (this.connecting) { + this.once('connect', goawayFn); + return; + } + goawayFn(); + } + + // Destroy the Http2Session, making it no longer usable and cancelling + // any pending activity. + destroy(error = NGHTTP2_NO_ERROR, code) { + if (this.destroyed) + return; + + debugSessionObj(this, 'destroying'); + + if (typeof error === 'number') { + code = error; + error = + code !== NGHTTP2_NO_ERROR ? + new ERR_HTTP2_SESSION_ERROR(code) : undefined; + } + if (code === undefined && error != null) + code = NGHTTP2_INTERNAL_ERROR; + + closeSession(this, code, error); + } + + // Closing the session will: + // 1. Send a goaway frame + // 2. Mark the session as closed + // 3. Prevent new inbound or outbound streams from being opened + // 4. Optionally register a 'close' event handler + // 5. Will cause the session to automatically destroy after the + // last currently open Http2Stream closes. + // + // Close always assumes a good, non-error shutdown (NGHTTP_NO_ERROR) + // + // If the session has not connected yet, the closed flag will still be + // set but the goaway will not be sent until after the connect event + // is emitted. + close(callback) { + if (this.closed || this.destroyed) + return; + debugSessionObj(this, 'marking session closed'); + this[kState].flags |= SESSION_FLAGS_CLOSED; + if (typeof callback === 'function') + this.once('close', callback); + this.goaway(); + this[kMaybeDestroy](); + } + + [EventEmitter.captureRejectionSymbol](err, event, ...args) { + switch (event) { + case 'stream': + const [stream] = args; + stream.destroy(err); + break; + default: + this.destroy(err); + } + } + + // Destroy the session if: + // * error is not undefined/null + // * session is closed and there are no more pending or open streams + [kMaybeDestroy](error) { + if (error == null) { + const state = this[kState]; + // Do not destroy if we're not closed and there are pending/open streams + if (!this.closed || + state.streams.size > 0 || + state.pendingStreams.size > 0) { + return; + } + } + this.destroy(error); + } + + _onTimeout() { + callTimeout(this); + } + + ref() { + if (this[kSocket]) { + this[kSocket].ref(); + } + } + + unref() { + if (this[kSocket]) { + this[kSocket].unref(); + } + } +} + +// ServerHttp2Session instances should never have to wait for the socket +// to connect as they are always created after the socket has already been +// established. +class ServerHttp2Session extends Http2Session { + constructor(options, socket, server) { + super(NGHTTP2_SESSION_SERVER, options, socket); + this[kServer] = server; + // This is a bit inaccurate because it does not reflect changes to + // number of listeners made after the session was created. This should + // not be an issue in practice. Additionally, the 'priority' event on + // server instances (or any other object) is fully undocumented. + this[kNativeFields][kSessionPriorityListenerCount] = + server.listenerCount('priority'); + } + + get server() { + return this[kServer]; + } + + // Submits an altsvc frame to be sent to the client. `stream` is a + // numeric Stream ID. origin is a URL string that will be used to get + // the origin. alt is a string containing the altsvc details. No fancy + // API is provided for that. + altsvc(alt, originOrStream) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + let stream = 0; + let origin; + + if (typeof originOrStream === 'string') { + origin = (new URL(originOrStream)).origin; + if (origin === 'null') + throw new ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); + } else if (typeof originOrStream === 'number') { + if (originOrStream >>> 0 !== originOrStream || originOrStream === 0) { + throw new ERR_OUT_OF_RANGE('originOrStream', + `> 0 && < ${2 ** 32}`, originOrStream); + } + stream = originOrStream; + } else if (originOrStream !== undefined) { + // Allow origin to be passed a URL or object with origin property + if (originOrStream !== null && typeof originOrStream === 'object') + origin = originOrStream.origin; + // Note: if originOrStream is an object with an origin property other + // than a URL, then it is possible that origin will be malformed. + // We do not verify that here. Users who go that route need to + // ensure they are doing the right thing or the payload data will + // be invalid. + if (typeof origin !== 'string') { + throw new ERR_INVALID_ARG_TYPE('originOrStream', + ['string', 'number', 'URL', 'object'], + originOrStream); + } else if (origin === 'null' || origin.length === 0) { + throw new ERR_HTTP2_ALTSVC_INVALID_ORIGIN(); + } + } + + validateString(alt, 'alt'); + if (!kQuotedString.test(alt)) + throw new ERR_INVALID_CHAR('alt'); + + // Max length permitted for ALTSVC + if ((alt.length + (origin !== undefined ? origin.length : 0)) > kMaxALTSVC) + throw new ERR_HTTP2_ALTSVC_LENGTH(); + + this[kHandle].altsvc(stream, origin || '', alt); + } + + // Submits an origin frame to be sent. + origin(...origins) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + if (origins.length === 0) + return; + + let arr = ''; + let len = 0; + const count = origins.length; + for (let i = 0; i < count; i++) { + let origin = origins[i]; + if (typeof origin === 'string') { + origin = (new URL(origin)).origin; + } else if (origin != null && typeof origin === 'object') { + origin = origin.origin; + } + validateString(origin, 'origin'); + if (origin === 'null') + throw new ERR_HTTP2_INVALID_ORIGIN(); + + arr += `${origin}\0`; + len += origin.length; + } + + if (len > kMaxALTSVC) + throw new ERR_HTTP2_ORIGIN_LENGTH(); + + this[kHandle].origin(arr, count); + } + +} + +// ClientHttp2Session instances have to wait for the socket to connect after +// they have been created. Various operations such as request() may be used, +// but the actual protocol communication will only occur after the socket +// has been connected. +class ClientHttp2Session extends Http2Session { + constructor(options, socket) { + super(NGHTTP2_SESSION_CLIENT, options, socket); + this[kPendingRequestCalls] = null; + } + + // Submits a new HTTP2 request to the connected peer. Returns the + // associated Http2Stream instance. + request(headers, options) { + debugSessionObj(this, 'initiating request'); + + if (this.destroyed) + throw new ERR_HTTP2_INVALID_SESSION(); + + if (this.closed) + throw new ERR_HTTP2_GOAWAY_SESSION(); + + this[kUpdateTimer](); + + if (headers !== null && headers !== undefined) { + for (const header of ObjectKeys(headers)) { + if (header[0] === ':') { + assertValidPseudoHeader(header); + } else if (header && !checkIsHttpToken(header)) + this.destroy(new ERR_INVALID_HTTP_TOKEN('Header name', header)); + } + } + + assertIsObject(headers, 'headers'); + assertIsObject(options, 'options'); + + headers = ObjectAssign(ObjectCreate(null), headers); + options = { ...options }; + + if (headers[HTTP2_HEADER_METHOD] === undefined) + headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_GET; + + const connect = headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_CONNECT; + + if (!connect || headers[HTTP2_HEADER_PROTOCOL] !== undefined) { + if (headers[HTTP2_HEADER_AUTHORITY] === undefined) + headers[HTTP2_HEADER_AUTHORITY] = this[kAuthority]; + if (headers[HTTP2_HEADER_SCHEME] === undefined) + headers[HTTP2_HEADER_SCHEME] = this[kProtocol].slice(0, -1); + if (headers[HTTP2_HEADER_PATH] === undefined) + headers[HTTP2_HEADER_PATH] = '/'; + } else { + if (headers[HTTP2_HEADER_AUTHORITY] === undefined) + throw new ERR_HTTP2_CONNECT_AUTHORITY(); + if (headers[HTTP2_HEADER_SCHEME] !== undefined) + throw new ERR_HTTP2_CONNECT_SCHEME(); + if (headers[HTTP2_HEADER_PATH] !== undefined) + throw new ERR_HTTP2_CONNECT_PATH(); + } + + setAndValidatePriorityOptions(options); + + if (options.endStream === undefined) { + // For some methods, we know that a payload is meaningless, so end the + // stream by default if the user has not specifically indicated a + // preference. + options.endStream = isPayloadMeaningless(headers[HTTP2_HEADER_METHOD]); + } else if (typeof options.endStream !== 'boolean') { + throw new ERR_INVALID_OPT_VALUE('endStream', options.endStream); + } + + const headersList = mapToHeaders(headers); + + const stream = new ClientHttp2Stream(this, undefined, undefined, {}); + stream[kSentHeaders] = headers; + stream[kOrigin] = `${headers[HTTP2_HEADER_SCHEME]}://` + + `${headers[HTTP2_HEADER_AUTHORITY]}`; + + // Close the writable side of the stream if options.endStream is set. + if (options.endStream) + stream.end(); + + if (options.waitForTrailers) + stream[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; + + const onConnect = requestOnConnect.bind(stream, headersList, options); + if (this.connecting) { + if (this[kPendingRequestCalls] !== null) { + this[kPendingRequestCalls].push(onConnect); + } else { + this[kPendingRequestCalls] = [onConnect]; + this.once('connect', () => { + this[kPendingRequestCalls].forEach((f) => f()); + this[kPendingRequestCalls] = null; + }); + } + } else { + onConnect(); + } + return stream; + } +} + +ObjectDefineProperty(Http2Session.prototype, 'setTimeout', { + configurable: true, + enumerable: true, + writable: true, + value: setStreamTimeout +}); + +// 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' +// event. If the stream is not new, emit the 'headers' event to pass +// the block of headers on. +function onSessionHeaders(handle, id, cat, flags, headers, sensitiveHeaders) { + const session = this[kOwner]; + if (session.destroyed) + return; + + const type = session[kType]; + session[kUpdateTimer](); + debugStream(id, type, 'headers received'); + const streams = session[kState].streams; + + const endOfStream = !!(flags & NGHTTP2_FLAG_END_STREAM); + let stream = streams.get(id); + + // Convert the array of header name value pairs into an object + const obj = toHeaderObject(headers, sensitiveHeaders); + + if (stream === undefined) { + if (session.closed) { + // We are not accepting any new streams at this point. This callback + // should not be invoked at this point in time, but just in case it is, + // refuse the stream using an RST_STREAM and destroy the handle. + handle.rstStream(NGHTTP2_REFUSED_STREAM); + handle.destroy(); + return; + } + const opts = { readable: !endOfStream }; + // session[kType] can be only one of two possible values + if (type === NGHTTP2_SESSION_SERVER) { + stream = new ServerHttp2Stream(session, handle, id, opts, obj); + if (obj[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD) { + // For head requests, there must not be a body... + // end the writable side immediately. + stream.end(); + stream[kState].flags |= STREAM_FLAGS_HEAD_REQUEST; + } + } else { + stream = new ClientHttp2Stream(session, handle, id, opts); + stream.end(); + } + if (endOfStream) + stream[kState].endAfterHeaders = true; + process.nextTick(emit, session, 'stream', stream, obj, flags, headers); + } else { + let event; + const status = obj[HTTP2_HEADER_STATUS]; + if (cat === NGHTTP2_HCAT_RESPONSE) { + if (!endOfStream && + status !== undefined && + status >= 100 && + status < 200) { + event = 'headers'; + } else { + event = 'response'; + } + } else if (cat === NGHTTP2_HCAT_PUSH_RESPONSE) { + event = 'push'; + // cat === NGHTTP2_HCAT_HEADERS: + } else if (!endOfStream && status !== undefined && status >= 200) { + event = 'response'; + } else { + event = endOfStream ? 'trailers' : 'headers'; + } + const session = stream.session; + if (status === HTTP_STATUS_MISDIRECTED_REQUEST) { + const originSet = session[kState].originSet = initOriginSet(session); + originSet.delete(stream[kOrigin]); + } + debugStream(id, type, "emitting stream '%s' event", event); + process.nextTick(emit, stream, event, obj, flags, headers); + } + if (endOfStream) { + stream.push(null); + } +} + +function initOriginSet(session) { + let originSet = session[kState].originSet; + if (originSet === undefined) { + const socket = session[kSocket]; + session[kState].originSet = originSet = new Set(); + if (socket.servername != null) { + let originString = `https://${socket.servername}`; + if (socket.remotePort != null) + originString += `:${socket.remotePort}`; + // We have to ensure that it is a properly serialized + // ASCII origin string. The socket.servername might not + // be properly ASCII encoded. + originSet.add((new URL(originString)).origin); + } + } + return originSet; +} + +// Submit a GOAWAY frame to be sent to the remote peer. +// If the lastStreamID is set to <= 0, then the lastProcStreamID will +// be used. The opaqueData must either be a typed array or undefined +// (which will be checked elsewhere). +function submitGoaway(code, lastStreamID, opaqueData) { + if (this.destroyed) + return; + debugSessionObj(this, 'submitting goaway'); + this[kUpdateTimer](); + this[kHandle].goaway(code, lastStreamID, opaqueData); +} + +const proxySocketHandler = { + get(session, prop) { + switch (prop) { + case 'setTimeout': + case 'ref': + case 'unref': + return session[prop].bind(session); + case 'destroy': + case 'emit': + case 'end': + case 'pause': + case 'read': + case 'resume': + case 'write': + case 'setEncoding': + case 'setKeepAlive': + case 'setNoDelay': + throw new ERR_HTTP2_NO_SOCKET_MANIPULATION(); + default: + const socket = session[kSocket]; + if (socket === undefined) + throw new ERR_HTTP2_SOCKET_UNBOUND(); + const value = socket[prop]; + return typeof value === 'function' ? value.bind(socket) : value; + } + }, + getPrototypeOf(session) { + const socket = session[kSocket]; + if (socket === undefined) + throw new ERR_HTTP2_SOCKET_UNBOUND(); + return ReflectGetPrototypeOf(socket); + }, + set(session, prop, value) { + switch (prop) { + case 'setTimeout': + case 'ref': + case 'unref': + session[prop] = value; + return true; + case 'destroy': + case 'emit': + case 'end': + case 'pause': + case 'read': + case 'resume': + case 'write': + case 'setEncoding': + case 'setKeepAlive': + case 'setNoDelay': + throw new ERR_HTTP2_NO_SOCKET_MANIPULATION(); + default: + const socket = session[kSocket]; + if (socket === undefined) + throw new ERR_HTTP2_SOCKET_UNBOUND(); + socket[prop] = value; + return true; + } + } +}; + +// Emits a close event followed by an error event if err is truthy. Used +// by Http2Session.prototype.destroy() +function emitClose(self, error) { + if (error) + self.emit('error', error); + self.emit('close'); +} + +function cleanupSession(session) { + const socket = session[kSocket]; + const handle = session[kHandle]; + session[kProxySocket] = undefined; + session[kSocket] = undefined; + session[kHandle] = undefined; + session[kNativeFields] = new Uint8Array(kSessionUint8FieldCount); + if (handle) + handle.ondone = null; + if (socket) { + socket[kSession] = undefined; + socket[kServer] = undefined; + } +} + +function finishSessionClose(session, error) { + debugSessionObj(session, 'finishSessionClose'); + + const socket = session[kSocket]; + cleanupSession(session); + + if (socket && !socket.destroyed) { + // Always wait for writable side to finish. + socket.end((err) => { + debugSessionObj(session, 'finishSessionClose socket end', err); + // Due to the way the underlying stream is handled in Http2Session we + // won't get graceful Readable end from the other side even if it was sent + // as the stream is already considered closed and will neither be read + // from nor keep the event loop alive. + // Therefore destroy the socket immediately. + // Fixing this would require some heavy juggling of ReadStart/ReadStop + // mostly on Windows as on Unix it will be fine with just ReadStart + // after this 'ondone' callback. + socket.destroy(error); + emitClose(session, error); + }); + } else { + process.nextTick(emitClose, session, error); + } +} + +function closeSession(session, code, error) { + debugSessionObj(session, 'start closing/destroying'); + + const state = session[kState]; + state.flags |= SESSION_FLAGS_DESTROYED; + state.destroyCode = code; + + // Clear timeout and remove timeout listeners. + session.setTimeout(0); + session.removeAllListeners('timeout'); + + // Destroy any pending and open streams + if (state.pendingStreams.size > 0 || state.streams.size > 0) { + const cancel = new ERR_HTTP2_STREAM_CANCEL(error); + state.pendingStreams.forEach((stream) => stream.destroy(cancel)); + state.streams.forEach((stream) => stream.destroy(error)); + } + + // Disassociate from the socket and server. + const socket = session[kSocket]; + const handle = session[kHandle]; + + // Destroy the handle if it exists at this point. + if (handle !== undefined) { + handle.ondone = finishSessionClose.bind(null, session, error); + handle.destroy(code, socket.destroyed); + } else { + finishSessionClose(session, error); + } +} + +// Keep track of the number/presence of JS event listeners. Knowing that there +// are no listeners allows the C++ code to skip calling into JS for an event. +function sessionListenerAdded(name) { + switch (name) { + case 'ping': + this[kNativeFields][kBitfield] |= 1 << kSessionHasPingListeners; + break; + case 'altsvc': + this[kNativeFields][kBitfield] |= 1 << kSessionHasAltsvcListeners; + break; + case 'remoteSettings': + this[kNativeFields][kBitfield] |= 1 << kSessionHasRemoteSettingsListeners; + break; + case 'priority': + this[kNativeFields][kSessionPriorityListenerCount]++; + break; + case 'frameError': + this[kNativeFields][kSessionFrameErrorListenerCount]++; + break; + } +} + +function sessionListenerRemoved(name) { + switch (name) { + case 'ping': + if (this.listenerCount(name) > 0) return; + this[kNativeFields][kBitfield] &= ~(1 << kSessionHasPingListeners); + break; + case 'altsvc': + if (this.listenerCount(name) > 0) return; + this[kNativeFields][kBitfield] &= ~(1 << kSessionHasAltsvcListeners); + break; + case 'remoteSettings': + if (this.listenerCount(name) > 0) return; + this[kNativeFields][kBitfield] &= + ~(1 << kSessionHasRemoteSettingsListeners); + break; + case 'priority': + this[kNativeFields][kSessionPriorityListenerCount]--; + break; + case 'frameError': + this[kNativeFields][kSessionFrameErrorListenerCount]--; + break; + } +} + +// When a ClientHttp2Session is first created, the socket may not yet be +// connected. If request() is called during this time, the actual request +// will be deferred until the socket is ready to go. +function requestOnConnect(headers, options) { + const session = this[kSession]; + + // At this point, the stream should have already been destroyed during + // the session.destroy() method. Do nothing else. + if (session === undefined || session.destroyed) + return; + + // If the session was closed while waiting for the connect, destroy + // the stream and do not continue with the request. + if (session.closed) { + const err = new ERR_HTTP2_GOAWAY_SESSION(); + this.destroy(err); + return; + } + + debugSessionObj(session, 'connected, initializing request'); + + let streamOptions = 0; + if (options.endStream) + streamOptions |= STREAM_OPTION_EMPTY_PAYLOAD; + + if (options.waitForTrailers) + streamOptions |= STREAM_OPTION_GET_TRAILERS; + + // `ret` will be either the reserved stream ID (if positive) + // or an error code (if negative) + const ret = session[kHandle].request(headers, + streamOptions, + options.parent | 0, + options.weight | 0, + !!options.exclusive); + + // In an error condition, one of three possible response codes will be + // possible: + // * NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE - Maximum stream ID is reached, this + // is fatal for the session + // * NGHTTP2_ERR_INVALID_ARGUMENT - Stream was made dependent on itself, this + // impacts on this stream. + // For the first two, emit the error on the session, + // For the third, emit the error on the stream, it will bubble up to the + // session if not handled. + if (typeof ret === 'number') { + let err; + switch (ret) { + case NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE: + err = new ERR_HTTP2_OUT_OF_STREAMS(); + this.destroy(err); + break; + case NGHTTP2_ERR_INVALID_ARGUMENT: + err = new ERR_HTTP2_STREAM_SELF_DEPENDENCY(); + this.destroy(err); + break; + default: + session.destroy(new NghttpError(ret)); + } + return; + } + this[kInit](ret.id(), ret); +} + +function settingsCallback(cb, ack, duration) { + this[kState].pendingAck--; + this[kLocalSettings] = undefined; + if (ack) { + debugSessionObj(this, 'settings received'); + const settings = this.localSettings; + if (typeof cb === 'function') + cb(null, settings, duration); + this.emit('localSettings', settings); + } else { + debugSessionObj(this, 'settings canceled'); + if (typeof cb === 'function') + cb(new ERR_HTTP2_SETTINGS_CANCEL()); + } +} + +// Submits a SETTINGS frame to be sent to the remote peer. +function submitSettings(settings, callback) { + if (this.destroyed) + return; + debugSessionObj(this, 'submitting settings'); + this[kUpdateTimer](); + updateSettingsBuffer(settings); + if (!this[kHandle].settings(settingsCallback.bind(this, callback))) { + this.destroy(new ERR_HTTP2_MAX_PENDING_SETTINGS_ACK()); + } +} + +// pingCallback() returns a function that is invoked when an HTTP2 PING +// frame acknowledgement is received. The ack is either true or false to +// indicate if the ping was successful or not. The duration indicates the +// number of milliseconds elapsed since the ping was sent and the ack +// received. The payload is a Buffer containing the 8 bytes of payload +// data received on the PING acknowledgement. +function pingCallback(cb) { + return function pingCallback(ack, duration, payload) { + if (ack) { + cb(null, duration, payload); + } else { + cb(new ERR_HTTP2_PING_CANCEL()); + } + }; +} + +// Creates the internal binding.Http2Session handle for an Http2Session +// instance. This occurs only after the socket connection has been +// established. Note: the binding.Http2Session will take over ownership +// of the socket. No other code should read from or write to the socket. +function setupHandle(socket, type, options) { + // If the session has been destroyed, go ahead and emit 'connect', + // but do nothing else. The various on('connect') handlers set by + // core will check for session.destroyed before progressing, this + // ensures that those at l`east get cleared out. + if (this.destroyed) { + process.nextTick(emit, this, 'connect', this, socket); + return; + } + + assert(socket._handle !== undefined, + 'Internal HTTP/2 Failure. The socket is not connected. Please ' + + 'report this as a bug in Node.js'); + + debugSession(type, 'setting up session handle'); + this[kState].flags |= SESSION_FLAGS_READY; + + updateOptionsBuffer(options); + const handle = new binding.Http2Session(type); + handle[kOwner] = this; + + if (typeof options.selectPadding === 'function') + this[kSelectPadding] = options.selectPadding; + handle.consume(socket._handle); + + this[kHandle] = handle; + if (this[kNativeFields]) + handle.fields.set(this[kNativeFields]); + else + this[kNativeFields] = handle.fields; + + if (socket.encrypted) { + this[kAlpnProtocol] = socket.alpnProtocol; + this[kEncrypted] = true; + } else { + // 'h2c' is the protocol identifier for HTTP/2 over plain-text. We use + // it here to identify any session that is not explicitly using an + // encrypted socket. + this[kAlpnProtocol] = 'h2c'; + this[kEncrypted] = false; + } + + if (isUint32(options.maxSessionInvalidFrames)) { + const uint32 = new Uint32Array( + this[kNativeFields].buffer, kSessionMaxInvalidFrames, 1); + uint32[0] = options.maxSessionInvalidFrames; + } + + if (isUint32(options.maxSessionRejectedStreams)) { + const uint32 = new Uint32Array( + this[kNativeFields].buffer, kSessionMaxRejectedStreams, 1); + uint32[0] = options.maxSessionRejectedStreams; + } + + const settings = typeof options.settings === 'object' ? + options.settings : {}; + + this.settings(settings); + + if (type === NGHTTP2_SESSION_SERVER && + ArrayIsArray(options.origins)) { + this.origin(...options.origins); + } + + process.nextTick(emit, this, 'connect', this, socket); +} + +// Handles the on('stream') event for a session and forwards +// it on to the server object. +function sessionOnStream(stream, headers, flags, rawHeaders) { + if (this[kServer] !== undefined) + this[kServer].emit('stream', stream, headers, flags, rawHeaders); +} + +function sessionOnPriority(stream, parent, weight, exclusive) { + if (this[kServer] !== undefined) + this[kServer].emit('priority', stream, parent, weight, exclusive); +} + +function sessionOnError(error) { + if (this[kServer] !== undefined) + this[kServer].emit('sessionError', error, this); +} + +// When the session times out on the server, try emitting a timeout event. +// If no handler is registered, destroy the session. +function sessionOnTimeout() { + // If destroyed or closed already, do nothing + if (this.destroyed || this.closed) + return; + const server = this[kServer]; + if (!server.emit('timeout', this)) + this.destroy(); // No error code, just things down. +} + +// When an error occurs internally at the binding level, immediately +// destroy the session. +function onSessionInternalError(code) { + if (this[kOwner] !== undefined) + this[kOwner].destroy(new NghttpError(code)); +} + +function onPing(payload) { + const session = this[kOwner]; + if (session.destroyed) + return; + session[kUpdateTimer](); + debugSessionObj(session, 'new ping received'); + session.emit('ping', payload); +} + +// Called when the remote peer settings have been updated. +// Resets the cached settings. +function onSettings() { + const session = this[kOwner]; + if (session.destroyed) + return; + session[kUpdateTimer](); + debugSessionObj(session, 'new settings received'); + session[kRemoteSettings] = undefined; + session.emit('remoteSettings', session.remoteSettings); +} + +// If the stream exists, an attempt will be made to emit an event +// on the stream object itself. Otherwise, forward it on to the +// session (which may, in turn, forward it on to the server) +function onPriority(id, parent, weight, exclusive) { + const session = this[kOwner]; + if (session.destroyed) + return; + debugStream(id, session[kType], + 'priority [parent: %d, weight: %d, exclusive: %s]', + parent, weight, exclusive); + const emitter = session[kState].streams.get(id) || session; + if (!emitter.destroyed) { + emitter[kUpdateTimer](); + emitter.emit('priority', id, parent, weight, exclusive); + } +} + +// Called by the native layer when an error has occurred sending a +// frame. This should be exceedingly rare. +function onFrameError(id, type, code) { + const session = this[kOwner]; + if (session.destroyed) + return; + debugSessionObj(session, 'error sending frame type %d on stream %d, code: %d', + type, id, code); + const emitter = session[kState].streams.get(id) || session; + emitter[kUpdateTimer](); + emitter.emit('frameError', type, code, id); +} + +function onAltSvc(stream, origin, alt) { + const session = this[kOwner]; + if (session.destroyed) + return; + debugSessionObj(session, 'altsvc received: stream: %d, origin: %s, alt: %s', + stream, origin, alt); + session[kUpdateTimer](); + session.emit('altsvc', alt, origin, stream); +} + +function onOrigin(origins) { + const session = this[kOwner]; + if (session.destroyed) + return; + debugSessionObj(session, 'origin received: %j', origins); + session[kUpdateTimer](); + if (!session.encrypted || session.destroyed) + return undefined; + const originSet = initOriginSet(session); + for (let n = 0; n < origins.length; n++) + originSet.add(origins[n]); + session.emit('origin', origins); +} + +// Receiving a GOAWAY frame from the connected peer is a signal that no +// new streams should be created. If the code === NGHTTP2_NO_ERROR, we +// are going to send our close, but allow existing frames to close +// normally. If code !== NGHTTP2_NO_ERROR, we are going to send our own +// close using the same code then destroy the session with an error. +// The goaway event will be emitted on next tick. +function onGoawayData(code, lastStreamID, buf) { + const session = this[kOwner]; + if (session.destroyed) + return; + debugSessionObj(session, 'goaway %d received [last stream id: %d]', + code, lastStreamID); + + const state = session[kState]; + state.goawayCode = code; + state.goawayLastStreamID = lastStreamID; + + session.emit('goaway', code, lastStreamID, buf); + if (code === NGHTTP2_NO_ERROR) { + // If this is a no error goaway, begin shutting down. + // No new streams permitted, but existing streams may + // close naturally on their own. + session.close(); + } else { + // However, if the code is not NGHTTP_NO_ERROR, destroy the + // session immediately. We destroy with an error but send a + // goaway using NGHTTP2_NO_ERROR because there was no error + // condition on this side of the session that caused the + // shutdown. + session.destroy(new ERR_HTTP2_SESSION_ERROR(code), NGHTTP2_NO_ERROR); + } +} + +module.exports = { + constants: { + kInit, + kNativeFields, + kOrigin, + kOwner, + kRemoteSettings, + kServer, + }, + debugSessionObj, + onAltSvc, + onFrameError, + onGoawayData, + onOrigin, + onPing, + onPriority, + onSessionHeaders, + onSessionInternalError, + onSettings, + sessionOnError, + sessionOnPriority, + sessionOnStream, + sessionOnTimeout, + ClientHttp2Session, + Http2Session, + ServerHttp2Session, +}; diff --git a/lib/internal/http2/streams.js b/lib/internal/http2/streams.js new file mode 100644 index 00000000000000..ea6f93c0413674 --- /dev/null +++ b/lib/internal/http2/streams.js @@ -0,0 +1,1363 @@ +'use strict'; + +const { + ArrayIsArray, + MathMin, + ObjectAssign, + ObjectCreate, + ObjectDefineProperty, + ObjectPrototypeHasOwnProperty, + Symbol, +} = primordials; + +const { + customInspectSymbol: kInspect, +} = require('internal/util'); + +const assert = require('assert'); +const fs = require('fs'); +const { Duplex } = require('stream'); +const { setImmediate } = require('timers'); + +const { + assertIsObject, + assertValidPseudoHeaderResponse, + assertValidPseudoHeaderTrailer, + callTimeout, + getStreamState, + kMaxInt, + kSensitiveHeaders, + kState, + kRequest, + kProxySocket, + mapToHeaders, + NghttpError, + sessionName, + setAndValidatePriorityOptions, +} = require('internal/http2/util'); +const { + defaultTriggerAsyncIdScope, + symbols: { + async_id_symbol, + owner_symbol, + }, +} = require('internal/async_hooks'); +const { + writeGeneric, + writevGeneric, + onStreamRead, + kAfterAsyncWrite, + kMaybeDestroy, + kUpdateTimer, + kHandle, + kSession, + setStreamTimeout +} = require('internal/stream_base_commons'); +const { kTimeout } = require('internal/timers'); +const { format } = require('internal/util/inspect'); +const { + codes: { + ERR_HTTP2_HEADERS_AFTER_RESPOND, + ERR_HTTP2_HEADERS_SENT, + ERR_HTTP2_INVALID_INFO_STATUS, + ERR_HTTP2_INVALID_STREAM, + ERR_HTTP2_NESTED_PUSH, + ERR_HTTP2_OUT_OF_STREAMS, + ERR_HTTP2_PAYLOAD_FORBIDDEN, + ERR_HTTP2_PUSH_DISABLED, + ERR_HTTP2_SEND_FILE, + ERR_HTTP2_SEND_FILE_NOSEEK, + ERR_HTTP2_STATUS_101, + ERR_HTTP2_STATUS_INVALID, + ERR_HTTP2_STREAM_ERROR, + ERR_HTTP2_TRAILERS_ALREADY_SENT, + ERR_HTTP2_TRAILERS_NOT_READY, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_CALLBACK, + ERR_INVALID_OPT_VALUE, + }, +} = require('internal/errors'); +const { validateInteger } = require('internal/validators'); +const fsPromisesInternal = require('internal/fs/promises'); +const { utcDate } = require('internal/http'); +let debug = require('internal/util/debuglog').debuglog('http2', (fn) => { + debug = fn; +}); + +const binding = internalBinding('http2'); +const { FileHandle } = internalBinding('fs'); +const { + ShutdownWrap, + kReadBytesOrError, + streamBaseState +} = internalBinding('stream_wrap'); +const { UV_EOF } = internalBinding('uv'); +const { StreamPipe } = internalBinding('stream_pipe'); + +const kID = Symbol('id'); +const kInit = Symbol('init'); +const kInfoHeaders = Symbol('sent-info-headers'); +const kOwner = owner_symbol; +const kProceed = Symbol('proceed'); +const kProtocol = Symbol('protocol'); +const kAuthority = Symbol('authority'); +const kNativeFields = Symbol('kNativeFields'); +const kSentHeaders = Symbol('sent-headers'); +const kSentTrailers = Symbol('sent-trailers'); +const kWriteGeneric = Symbol('write-generic'); +const kType = Symbol('type'); + +const { + constants, + nameForErrorCode, + kSessionFrameErrorListenerCount, + kSessionPriorityListenerCount, +} = binding; + +const { + NGHTTP2_CANCEL, + NGHTTP2_INTERNAL_ERROR, + NGHTTP2_NO_ERROR, + NGHTTP2_SESSION_SERVER, + NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE, + NGHTTP2_ERR_STREAM_CLOSED, + + HTTP2_HEADER_AUTHORITY, + HTTP2_HEADER_DATE, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_HEADER_SCHEME, + HTTP2_HEADER_STATUS, + HTTP2_HEADER_CONTENT_LENGTH, + + HTTP2_METHOD_GET, + HTTP2_METHOD_HEAD, + + HTTP_STATUS_CONTINUE, + HTTP_STATUS_RESET_CONTENT, + HTTP_STATUS_OK, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_NOT_MODIFIED, + HTTP_STATUS_SWITCHING_PROTOCOLS, + + STREAM_OPTION_EMPTY_PAYLOAD, + STREAM_OPTION_GET_TRAILERS +} = constants; + +const STREAM_FLAGS_PENDING = 0x0; +const STREAM_FLAGS_READY = 0x1; +const STREAM_FLAGS_CLOSED = 0x2; +const STREAM_FLAGS_HEADERS_SENT = 0x4; +const STREAM_FLAGS_HEAD_REQUEST = 0x8; +const STREAM_FLAGS_ABORTED = 0x10; +const STREAM_FLAGS_HAS_TRAILERS = 0x20; + +const kNoRstStream = 0; +const kSubmitRstStream = 1; +const kForceRstStream = 2; + +// TODO(addaleax): See if this can be made more efficient by figuring out +// whether debugging is enabled before we perform any further steps. Currently, +// this seems pretty fast, though. +function debugStream(id, sessionType, message, ...args) { + debug('Http2Stream %s [Http2Session %s]: ' + message, + id, sessionName(sessionType), ...args); +} + +function debugStreamObj(stream, message, ...args) { + debugStream(stream[kID], stream[kSession][kType], message, ...args); +} + +// An Http2Stream is a Duplex stream that is backed by a +// node::http2::Http2Stream handle implementing StreamBase. +class Http2Stream extends Duplex { + constructor(session, options) { + options.allowHalfOpen = true; + options.decodeStrings = false; + options.autoDestroy = false; + super(options); + this[async_id_symbol] = -1; + + // Corking the stream automatically allows writes to happen + // but ensures that those are buffered until the handle has + // been assigned. + this.cork(); + this[kSession] = session; + session[kState].pendingStreams.add(this); + + // Allow our logic for determining whether any reads have happened to + // work in all situations. This is similar to what we do in _http_incoming. + this._readableState.readingMore = true; + + this[kTimeout] = null; + + this[kState] = { + didRead: false, + flags: STREAM_FLAGS_PENDING, + rstCode: NGHTTP2_NO_ERROR, + writeQueueSize: 0, + trailersReady: false, + endAfterHeaders: false + }; + + // Fields used by the compat API to avoid megamorphisms. + this[kRequest] = null; + this[kProxySocket] = null; + + this.on('pause', streamOnPause); + + this.on('newListener', streamListenerAdded); + this.on('removeListener', streamListenerRemoved); + } + + [kUpdateTimer]() { + if (this.destroyed) + return; + if (this[kTimeout]) + this[kTimeout].refresh(); + if (this[kSession]) + this[kSession][kUpdateTimer](); + } + + [kInit](id, handle) { + const state = this[kState]; + state.flags |= STREAM_FLAGS_READY; + + const session = this[kSession]; + session[kState].pendingStreams.delete(this); + session[kState].streams.set(id, this); + + this[kID] = id; + this[async_id_symbol] = handle.getAsyncId(); + handle[kOwner] = this; + this[kHandle] = handle; + handle.onread = onStreamRead; + this.uncork(); + this.emit('ready'); + } + + [kInspect](depth, opts) { + if (typeof depth === 'number' && depth < 0) + return this; + + const obj = { + id: this[kID] || '', + closed: this.closed, + destroyed: this.destroyed, + state: this.state, + readableState: this._readableState, + writableState: this._writableState + }; + return `Http2Stream ${format(obj)}`; + } + + get bufferSize() { + // `bufferSize` properties of `net.Socket` are `undefined` when + // their `_handle` are falsy. Here we avoid the behavior. + return this[kState].writeQueueSize + this.writableLength; + } + + get endAfterHeaders() { + return this[kState].endAfterHeaders; + } + + get sentHeaders() { + return this[kSentHeaders]; + } + + get sentTrailers() { + return this[kSentTrailers]; + } + + get sentInfoHeaders() { + return this[kInfoHeaders]; + } + + get pending() { + return this[kID] === undefined; + } + + // The id of the Http2Stream, will be undefined if the socket is not + // yet connected. + get id() { + return this[kID]; + } + + // The Http2Session that owns this Http2Stream. + get session() { + return this[kSession]; + } + + _onTimeout() { + callTimeout(this, kSession); + } + + // True if the HEADERS frame has been sent + get headersSent() { + return !!(this[kState].flags & STREAM_FLAGS_HEADERS_SENT); + } + + // True if the Http2Stream was aborted abnormally. + get aborted() { + return !!(this[kState].flags & STREAM_FLAGS_ABORTED); + } + + // True if dealing with a HEAD request + get headRequest() { + return !!(this[kState].flags & STREAM_FLAGS_HEAD_REQUEST); + } + + // The error code reported when this Http2Stream was closed. + get rstCode() { + return this[kState].rstCode; + } + + // State information for the Http2Stream + get state() { + const id = this[kID]; + if (this.destroyed || id === undefined) + return {}; + return getStreamState(this[kHandle], id); + } + + [kProceed]() { + assert.fail('Implementors MUST implement this. Please report this as a ' + + 'bug in Node.js'); + } + + [kAfterAsyncWrite]({ bytes }) { + this[kState].writeQueueSize -= bytes; + + if (this.session !== undefined) + this.session[kState].writeQueueSize -= bytes; + } + + [kWriteGeneric](writev, data, encoding, cb) { + // When the Http2Stream is first created, it is corked until the + // handle and the stream ID is assigned. However, if the user calls + // uncork() before that happens, the Duplex will attempt to pass + // writes through. Those need to be queued up here. + if (this.pending) { + this.once( + 'ready', + this[kWriteGeneric].bind(this, writev, data, encoding, cb) + ); + return; + } + + // If the stream has been destroyed, there's nothing else we can do + // because the handle has been destroyed. This should only be an + // issue if a write occurs before the 'ready' event in the case where + // the duplex is uncorked before the stream is ready to go. In that + // case, drop the data on the floor. An error should have already been + // emitted. + if (this.destroyed) + return; + + this[kUpdateTimer](); + if (!this.headersSent) + this[kProceed](); + + let req; + + // writeGeneric does not destroy on error and we cannot enable autoDestroy, + // so make sure to destroy on error. + const callback = (err) => { + if (err) { + this.destroy(err); + } + cb(err); + }; + + if (writev) + req = writevGeneric(this, data, callback); + else + req = writeGeneric(this, data, encoding, callback); + + trackWriteState(this, req.bytes); + } + + _write(data, encoding, cb) { + this[kWriteGeneric](false, data, encoding, cb); + } + + _writev(data, cb) { + this[kWriteGeneric](true, data, '', cb); + } + + _final(cb) { + const handle = this[kHandle]; + if (this.pending) { + this.once('ready', () => this._final(cb)); + } else if (handle !== undefined) { + debugStreamObj(this, '_final shutting down'); + const req = new ShutdownWrap(); + req.oncomplete = afterShutdown; + req.callback = cb; + req.handle = handle; + const err = handle.shutdown(req); + if (err === 1) // synchronous finish + return afterShutdown.call(req, 0); + } else { + cb(); + } + } + + _read(nread) { + if (this.destroyed) { + this.push(null); + return; + } + if (!this[kState].didRead) { + this._readableState.readingMore = false; + this[kState].didRead = true; + } + if (!this.pending) { + streamOnResume.call(this); + } else { + this.once('ready', streamOnResume); + } + } + + priority(options) { + if (this.destroyed) + throw new ERR_HTTP2_INVALID_STREAM(); + + assertIsObject(options, 'options'); + options = { ...options }; + setAndValidatePriorityOptions(options); + + const priorityFn = submitPriority.bind(this, options); + + // If the handle has not yet been assigned, queue up the priority + // frame to be sent as soon as the ready event is emitted. + if (this.pending) { + this.once('ready', priorityFn); + return; + } + priorityFn(); + } + + sendTrailers(headers) { + if (this.destroyed || this.closed) + throw new ERR_HTTP2_INVALID_STREAM(); + if (this[kSentTrailers]) + throw new ERR_HTTP2_TRAILERS_ALREADY_SENT(); + if (!this[kState].trailersReady) + throw new ERR_HTTP2_TRAILERS_NOT_READY(); + + assertIsObject(headers, 'headers'); + headers = ObjectAssign(ObjectCreate(null), headers); + + debugStreamObj(this, 'sending trailers'); + + this[kUpdateTimer](); + + const headersList = mapToHeaders(headers, assertValidPseudoHeaderTrailer); + this[kSentTrailers] = headers; + + // Send the trailers in setImmediate so we don't do it on nghttp2 stack. + setImmediate(finishSendTrailers, this, headersList); + } + + get closed() { + return !!(this[kState].flags & STREAM_FLAGS_CLOSED); + } + + // Close initiates closing the Http2Stream instance by sending an RST_STREAM + // frame to the connected peer. The readable and writable sides of the + // Http2Stream duplex are closed and the timeout timer is cleared. If + // a callback is passed, it is registered to listen for the 'close' event. + // + // If the handle and stream ID have not been assigned yet, the close + // will be queued up to wait for the ready event. As soon as the stream ID + // is determined, the close will proceed. + // + // Submitting the RST_STREAM frame to the underlying handle will cause + // the Http2Stream to be closed and ultimately destroyed. After calling + // close, it is still possible to queue up PRIORITY and RST_STREAM frames, + // but no DATA and HEADERS frames may be sent. + close(code = NGHTTP2_NO_ERROR, callback) { + validateInteger(code, 'code', 0, kMaxInt); + + if (callback !== undefined && typeof callback !== 'function') + throw new ERR_INVALID_CALLBACK(callback); + + if (this.closed) + return; + + if (callback !== undefined) + this.once('close', callback); + + closeStream(this, code); + } + + // Called by this.destroy(). + // * Will submit an RST stream to shutdown the stream if necessary. + // This will cause the internal resources to be released. + // * Then cleans up the resources on the js side + _destroy(err, callback) { + const session = this[kSession]; + const handle = this[kHandle]; + const id = this[kID]; + + debugStream(this[kID] || 'pending', session[kType], 'destroying stream'); + + const state = this[kState]; + const sessionCode = session[kState].goawayCode || + session[kState].destroyCode; + const code = err != null ? + sessionCode || NGHTTP2_INTERNAL_ERROR : + state.rstCode || sessionCode; + const hasHandle = handle !== undefined; + + if (!this.closed) + closeStream(this, code, hasHandle ? kForceRstStream : kNoRstStream); + this.push(null); + + if (hasHandle) { + handle.destroy(); + session[kState].streams.delete(id); + } else { + session[kState].pendingStreams.delete(this); + } + + // Adjust the write queue size for accounting + session[kState].writeQueueSize -= state.writeQueueSize; + state.writeQueueSize = 0; + + // RST code 8 not emitted as an error as its used by clients to signify + // abort and is already covered by aborted event, also allows more + // seamless compatibility with http1 + if (err == null && code !== NGHTTP2_NO_ERROR && code !== NGHTTP2_CANCEL) + err = new ERR_HTTP2_STREAM_ERROR(nameForErrorCode[code] || code); + + this[kSession] = undefined; + this[kHandle] = undefined; + + // This notifies the session that this stream has been destroyed and + // gives the session the opportunity to clean itself up. The session + // will destroy if it has been closed and there are no other open or + // pending streams. + session[kMaybeDestroy](); + callback(err); + } + // The Http2Stream can be destroyed if it has closed and if the readable + // side has received the final chunk. + [kMaybeDestroy](code = NGHTTP2_NO_ERROR) { + if (code !== NGHTTP2_NO_ERROR) { + this.destroy(); + return; + } + + if (this.writableFinished) { + if (!this.readable && this.closed) { + this.destroy(); + return; + } + + // We've submitted a response from our server session, have not attempted + // to process any incoming data, and have no trailers. This means we can + // attempt to gracefully close the session. + const state = this[kState]; + if (this.headersSent && + this[kSession] && + this[kSession][kType] === NGHTTP2_SESSION_SERVER && + !(state.flags & STREAM_FLAGS_HAS_TRAILERS) && + !state.didRead && + this.readableFlowing === null) { + // By using setImmediate we allow pushStreams to make it through + // before the stream is officially closed. This prevents a bug + // in most browsers where those pushStreams would be rejected. + setImmediate(callStreamClose, this); + } + } + } +} + +class ServerHttp2Stream extends Http2Stream { + constructor(session, handle, id, options, headers) { + super(session, options); + handle.owner = this; + this[kInit](id, handle); + this[kProtocol] = headers[HTTP2_HEADER_SCHEME]; + this[kAuthority] = headers[HTTP2_HEADER_AUTHORITY]; + } + + // True if the remote peer accepts push streams + get pushAllowed() { + return !this.destroyed && + !this.closed && + !this.session.closed && + !this.session.destroyed && + this[kSession].remoteSettings.enablePush; + } + + // Create a push stream, call the given callback with the created + // Http2Stream for the push stream. + pushStream(headers, options, callback) { + if (!this.pushAllowed) + throw new ERR_HTTP2_PUSH_DISABLED(); + if (this[kID] % 2 === 0) + throw new ERR_HTTP2_NESTED_PUSH(); + + const session = this[kSession]; + + debugStreamObj(this, 'initiating push stream'); + + this[kUpdateTimer](); + + if (typeof options === 'function') { + callback = options; + options = undefined; + } + + if (typeof callback !== 'function') + throw new ERR_INVALID_CALLBACK(callback); + + assertIsObject(options, 'options'); + options = { ...options }; + options.endStream = !!options.endStream; + + assertIsObject(headers, 'headers'); + headers = ObjectAssign(ObjectCreate(null), headers); + + if (headers[HTTP2_HEADER_METHOD] === undefined) + headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_GET; + if (headers[HTTP2_HEADER_AUTHORITY] === undefined) + headers[HTTP2_HEADER_AUTHORITY] = this[kAuthority]; + if (headers[HTTP2_HEADER_SCHEME] === undefined) + headers[HTTP2_HEADER_SCHEME] = this[kProtocol]; + if (headers[HTTP2_HEADER_PATH] === undefined) + headers[HTTP2_HEADER_PATH] = '/'; + + let headRequest = false; + if (headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_HEAD) + headRequest = options.endStream = true; + options.readable = false; + + const headersList = mapToHeaders(headers); + + const streamOptions = options.endStream ? STREAM_OPTION_EMPTY_PAYLOAD : 0; + + const ret = this[kHandle].pushPromise(headersList, streamOptions); + let err; + if (typeof ret === 'number') { + switch (ret) { + case NGHTTP2_ERR_STREAM_ID_NOT_AVAILABLE: + err = new ERR_HTTP2_OUT_OF_STREAMS(); + break; + case NGHTTP2_ERR_STREAM_CLOSED: + err = new ERR_HTTP2_INVALID_STREAM(); + break; + default: + err = new NghttpError(ret); + break; + } + process.nextTick(callback, err); + return; + } + + const id = ret.id(); + const stream = new ServerHttp2Stream(session, ret, id, options, headers); + stream[kSentHeaders] = headers; + + if (options.endStream) + stream.end(); + + if (headRequest) + stream[kState].flags |= STREAM_FLAGS_HEAD_REQUEST; + + process.nextTick(callback, null, stream, headers, 0); + } + + // Initiate a response on this Http2Stream + respond(headers, options) { + if (this.destroyed || this.closed) + throw new ERR_HTTP2_INVALID_STREAM(); + if (this.headersSent) + throw new ERR_HTTP2_HEADERS_SENT(); + + const state = this[kState]; + + assertIsObject(options, 'options'); + options = { ...options }; + + debugStreamObj(this, 'initiating response'); + this[kUpdateTimer](); + + options.endStream = !!options.endStream; + + let streamOptions = 0; + if (options.endStream) + streamOptions |= STREAM_OPTION_EMPTY_PAYLOAD; + + if (options.waitForTrailers) { + streamOptions |= STREAM_OPTION_GET_TRAILERS; + state.flags |= STREAM_FLAGS_HAS_TRAILERS; + } + + headers = processHeaders(headers); + const headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); + this[kSentHeaders] = headers; + + state.flags |= STREAM_FLAGS_HEADERS_SENT; + + // Close the writable side if the endStream option is set or status + // is one of known codes with no payload, or it's a head request + const statusCode = headers[HTTP2_HEADER_STATUS] | 0; + if (!!options.endStream || + statusCode === HTTP_STATUS_NO_CONTENT || + statusCode === HTTP_STATUS_RESET_CONTENT || + statusCode === HTTP_STATUS_NOT_MODIFIED || + this.headRequest === true) { + options.endStream = true; + this.end(); + } + + const ret = this[kHandle].respond(headersList, streamOptions); + if (ret < 0) + this.destroy(new NghttpError(ret)); + } + + // Initiate a response using an open FD. Note that there are fewer + // protections with this approach. For one, the fd is not validated by + // default. In respondWithFile, the file is checked to make sure it is a + // regular file, here the fd is passed directly. If the underlying + // mechanism is not able to read from the fd, then the stream will be + // reset with an error code. + respondWithFD(fd, headers, options) { + if (this.destroyed || this.closed) + throw new ERR_HTTP2_INVALID_STREAM(); + if (this.headersSent) + throw new ERR_HTTP2_HEADERS_SENT(); + + const session = this[kSession]; + + assertIsObject(options, 'options'); + options = { ...options }; + + if (options.offset !== undefined && typeof options.offset !== 'number') + throw new ERR_INVALID_OPT_VALUE('offset', options.offset); + + if (options.length !== undefined && typeof options.length !== 'number') + throw new ERR_INVALID_OPT_VALUE('length', options.length); + + if (options.statCheck !== undefined && + typeof options.statCheck !== 'function') { + throw new ERR_INVALID_OPT_VALUE('statCheck', options.statCheck); + } + + let streamOptions = 0; + if (options.waitForTrailers) { + streamOptions |= STREAM_OPTION_GET_TRAILERS; + this[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; + } + + if (fd instanceof fsPromisesInternal.FileHandle) + fd = fd.fd; + else if (typeof fd !== 'number') + throw new ERR_INVALID_ARG_TYPE('fd', ['number', 'FileHandle'], fd); + + debugStreamObj(this, 'initiating response from fd'); + this[kUpdateTimer](); + this.ownsFd = false; + + headers = processHeaders(headers); + const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; + // Payload/DATA frames are not permitted in these cases + if (statusCode === HTTP_STATUS_NO_CONTENT || + statusCode === HTTP_STATUS_RESET_CONTENT || + statusCode === HTTP_STATUS_NOT_MODIFIED || + this.headRequest) { + throw new ERR_HTTP2_PAYLOAD_FORBIDDEN(statusCode); + } + + if (options.statCheck !== undefined) { + fs.fstat(fd, + doSendFD.bind(this, session, options, fd, + headers, streamOptions)); + return; + } + + processRespondWithFD(this, fd, headers, + options.offset, + options.length, + streamOptions); + } + + // Initiate a file response on this Http2Stream. The path is passed to + // fs.open() to acquire the fd with mode 'r', then the fd is passed to + // fs.fstat(). Assuming fstat is successful, a check is made to ensure + // that the file is a regular file, then options.statCheck is called, + // giving the user an opportunity to verify the details and set additional + // headers. If statCheck returns false, the operation is aborted and no + // file details are sent. + respondWithFile(path, headers, options) { + if (this.destroyed || this.closed) + throw new ERR_HTTP2_INVALID_STREAM(); + if (this.headersSent) + throw new ERR_HTTP2_HEADERS_SENT(); + + assertIsObject(options, 'options'); + options = { ...options }; + + if (options.offset !== undefined && typeof options.offset !== 'number') + throw new ERR_INVALID_OPT_VALUE('offset', options.offset); + + if (options.length !== undefined && typeof options.length !== 'number') + throw new ERR_INVALID_OPT_VALUE('length', options.length); + + if (options.statCheck !== undefined && + typeof options.statCheck !== 'function') { + throw new ERR_INVALID_OPT_VALUE('statCheck', options.statCheck); + } + + let streamOptions = 0; + if (options.waitForTrailers) { + streamOptions |= STREAM_OPTION_GET_TRAILERS; + this[kState].flags |= STREAM_FLAGS_HAS_TRAILERS; + } + + const session = this[kSession]; + debugStreamObj(this, 'initiating response from file'); + this[kUpdateTimer](); + this.ownsFd = true; + + headers = processHeaders(headers); + const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; + // Payload/DATA frames are not permitted in these cases + if (statusCode === HTTP_STATUS_NO_CONTENT || + statusCode === HTTP_STATUS_RESET_CONTENT || + statusCode === HTTP_STATUS_NOT_MODIFIED || + this.headRequest) { + throw new ERR_HTTP2_PAYLOAD_FORBIDDEN(statusCode); + } + + fs.open(path, 'r', + afterOpen.bind(this, session, options, headers, streamOptions)); + } + + // Sends a block of informational headers. In theory, the HTTP/2 spec + // allows sending a HEADER block at any time during a streams lifecycle, + // but the HTTP request/response semantics defined in HTTP/2 places limits + // such that HEADERS may only be sent *before* or *after* DATA frames. + // If the block of headers being sent includes a status code, it MUST be + // a 1xx informational code and it MUST be sent before the request/response + // headers are sent, or an error will be thrown. + additionalHeaders(headers) { + if (this.destroyed || this.closed) + throw new ERR_HTTP2_INVALID_STREAM(); + if (this.headersSent) + throw new ERR_HTTP2_HEADERS_AFTER_RESPOND(); + + assertIsObject(headers, 'headers'); + headers = ObjectAssign(ObjectCreate(null), headers); + + debugStreamObj(this, 'sending additional headers'); + + if (headers[HTTP2_HEADER_STATUS] != null) { + const statusCode = headers[HTTP2_HEADER_STATUS] |= 0; + if (statusCode === HTTP_STATUS_SWITCHING_PROTOCOLS) + throw new ERR_HTTP2_STATUS_101(); + if (statusCode < 100 || statusCode >= 200) { + throw new ERR_HTTP2_INVALID_INFO_STATUS(headers[HTTP2_HEADER_STATUS]); + } + } + + this[kUpdateTimer](); + + const headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); + if (!this[kInfoHeaders]) + this[kInfoHeaders] = [headers]; + else + this[kInfoHeaders].push(headers); + + const ret = this[kHandle].info(headersList); + if (ret < 0) + this.destroy(new NghttpError(ret)); + } +} + +ServerHttp2Stream.prototype[kProceed] = ServerHttp2Stream.prototype.respond; + +class ClientHttp2Stream extends Http2Stream { + constructor(session, handle, id, options) { + super(session, options); + this[kState].flags |= STREAM_FLAGS_HEADERS_SENT; + if (id !== undefined) + this[kInit](id, handle); + this.on('headers', handleHeaderContinue); + } +} + +ObjectDefineProperty(Http2Stream.prototype, 'setTimeout', { + configurable: true, + enumerable: true, + writable: true, + value: setStreamTimeout +}); + +// Also keep track of listeners for the Http2Stream instances, as some events +// are emitted on those objects. +function streamListenerAdded(name) { + const session = this[kSession]; + if (!session) return; + switch (name) { + case 'priority': + session[kNativeFields][kSessionPriorityListenerCount]++; + break; + case 'frameError': + session[kNativeFields][kSessionFrameErrorListenerCount]++; + break; + } +} + +function streamListenerRemoved(name) { + const session = this[kSession]; + if (!session) return; + switch (name) { + case 'priority': + session[kNativeFields][kSessionPriorityListenerCount]--; + break; + case 'frameError': + session[kNativeFields][kSessionFrameErrorListenerCount]--; + break; + } +} + +// Submits a PRIORITY frame to be sent to the remote peer +// Note: If the silent option is true, the change will be made +// locally with no PRIORITY frame sent. +function submitPriority(options) { + if (this.destroyed) + return; + this[kUpdateTimer](); + + // If the parent is the id, do nothing because a + // stream cannot be made to depend on itself. + if (options.parent === this[kID]) + return; + + this[kHandle].priority(options.parent | 0, + options.weight | 0, + !!options.exclusive, + !!options.silent); +} + +function streamOnResume() { + if (!this.destroyed) + this[kHandle].readStart(); +} + +function streamOnPause() { + if (!this.destroyed && !this.pending) + this[kHandle].readStop(); +} + +function afterShutdown(status) { + const stream = this.handle[kOwner]; + if (stream) { + stream.on('finish', () => { + stream[kMaybeDestroy](); + }); + } + // Currently this status value is unused + this.callback(); +} + +function doSendFD(session, options, fd, headers, streamOptions, err, stat) { + if (err) { + this.destroy(err); + return; + } + + // This can happen if the stream is destroyed or closed while we are waiting + // for the file descriptor to be opened or the stat call to be completed. + // In either case, we do not want to continue because the we are shutting + // down and should not attempt to send any data. + if (this.destroyed || this.closed) { + this.destroy(new ERR_HTTP2_INVALID_STREAM()); + return; + } + + const statOptions = { + offset: options.offset !== undefined ? options.offset : 0, + length: options.length !== undefined ? options.length : -1 + }; + + // options.statCheck is a user-provided function that can be used to + // verify stat values, override or set headers, or even cancel the + // response operation. If statCheck explicitly returns false, the + // response is canceled. The user code may also send a separate type + // of response so check again for the HEADERS_SENT flag + if ((typeof options.statCheck === 'function' && + options.statCheck.call(this, stat, headers, statOptions) === false) || + (this[kState].flags & STREAM_FLAGS_HEADERS_SENT)) { + return; + } + + processRespondWithFD(this, fd, headers, + statOptions.offset | 0, + statOptions.length | 0, + streamOptions); +} + +function doSendFileFD(session, options, fd, headers, streamOptions, err, stat) { + const onError = options.onError; + + if (err) { + tryClose(fd); + if (onError) + onError(err); + else + this.destroy(err); + return; + } + + if (!stat.isFile()) { + const isDirectory = stat.isDirectory(); + if (options.offset !== undefined || options.offset > 0 || + options.length !== undefined || options.length >= 0 || + isDirectory) { + const err = isDirectory ? + new ERR_HTTP2_SEND_FILE() : new ERR_HTTP2_SEND_FILE_NOSEEK(); + tryClose(fd); + if (onError) + onError(err); + else + this.destroy(err); + return; + } + + options.offset = -1; + options.length = -1; + } + + if (this.destroyed || this.closed) { + tryClose(fd); + this.destroy(new ERR_HTTP2_INVALID_STREAM()); + return; + } + + const statOptions = { + offset: options.offset !== undefined ? options.offset : 0, + length: options.length !== undefined ? options.length : -1 + }; + + // options.statCheck is a user-provided function that can be used to + // verify stat values, override or set headers, or even cancel the + // response operation. If statCheck explicitly returns false, the + // response is canceled. The user code may also send a separate type + // of response so check again for the HEADERS_SENT flag + if ((typeof options.statCheck === 'function' && + options.statCheck.call(this, stat, headers) === false) || + (this[kState].flags & STREAM_FLAGS_HEADERS_SENT)) { + tryClose(fd); + return; + } + + if (stat.isFile()) { + statOptions.length = + statOptions.length < 0 ? stat.size - (+statOptions.offset) : + MathMin(stat.size - (+statOptions.offset), + statOptions.length); + + headers[HTTP2_HEADER_CONTENT_LENGTH] = statOptions.length; + } + + processRespondWithFD(this, fd, headers, + options.offset | 0, + statOptions.length | 0, + streamOptions); +} + +function afterOpen(session, options, headers, streamOptions, err, fd) { + const state = this[kState]; + const onError = options.onError; + if (err) { + if (onError) + onError(err); + else + this.destroy(err); + return; + } + if (this.destroyed || this.closed) { + tryClose(fd); + return; + } + state.fd = fd; + + fs.fstat(fd, + doSendFileFD.bind(this, session, options, fd, + headers, streamOptions)); +} + +function handleHeaderContinue(headers) { + if (headers[HTTP2_HEADER_STATUS] === HTTP_STATUS_CONTINUE) + this.emit('continue'); +} + +function onFileUnpipe() { + const stream = this.sink[kOwner]; + if (stream.ownsFd) + this.source.close().catch(stream.destroy.bind(stream)); + else + this.source.releaseFD(); +} + +// This is only called once the pipe has returned back control, so +// it only has to handle errors and End-of-File. +function onPipedFileHandleRead() { + const err = streamBaseState[kReadBytesOrError]; + if (err < 0 && err !== UV_EOF) { + this.stream.close(NGHTTP2_INTERNAL_ERROR); + } +} + +function processRespondWithFD(self, fd, headers, offset = 0, length = -1, + streamOptions = 0) { + const state = self[kState]; + state.flags |= STREAM_FLAGS_HEADERS_SENT; + + let headersList; + try { + headersList = mapToHeaders(headers, assertValidPseudoHeaderResponse); + } catch (err) { + self.destroy(err); + return; + } + self[kSentHeaders] = headers; + + // Close the writable side of the stream, but only as far as the writable + // stream implementation is concerned. + self._final = null; + self.end(); + + const ret = self[kHandle].respond(headersList, streamOptions); + + if (ret < 0) { + self.destroy(new NghttpError(ret)); + return; + } + + defaultTriggerAsyncIdScope(self[async_id_symbol], startFilePipe, + self, fd, offset, length); +} + +function startFilePipe(self, fd, offset, length) { + const handle = new FileHandle(fd, offset, length); + handle.onread = onPipedFileHandleRead; + handle.stream = self; + + const pipe = new StreamPipe(handle, self[kHandle]); + pipe.onunpipe = onFileUnpipe; + pipe.start(); + + // Exact length of the file doesn't matter here, since the + // stream is closing anyway - just use 1 to signify that + // a write does exist + trackWriteState(self, 1); +} + +function tryClose(fd) { + // Try to close the file descriptor. If closing fails, assert because + // an error really should not happen at this point. + fs.close(fd, (err) => assert.ifError(err)); +} + +function trackWriteState(stream, bytes) { + const session = stream[kSession]; + stream[kState].writeQueueSize += bytes; + session[kState].writeQueueSize += bytes; + session[kHandle].chunksSentSinceLastWrite = 0; +} + +function finishSendTrailers(stream, headersList) { + // The stream might be destroyed and in that case + // there is nothing to do. + // This can happen because finishSendTrailers is + // scheduled via setImmediate. + if (stream.destroyed) { + return; + } + + stream[kState].flags &= ~STREAM_FLAGS_HAS_TRAILERS; + + const ret = stream[kHandle].trailers(headersList); + if (ret < 0) + stream.destroy(new NghttpError(ret)); + else + stream[kMaybeDestroy](); +} + +function callStreamClose(stream) { + stream.close(); +} + +function processHeaders(oldHeaders) { + assertIsObject(oldHeaders, 'headers'); + const headers = ObjectCreate(null); + + if (oldHeaders !== null && oldHeaders !== undefined) { + // This loop is here for performance reason. Do not change. + for (const key in oldHeaders) { + if (ObjectPrototypeHasOwnProperty(oldHeaders, key)) { + headers[key] = oldHeaders[key]; + } + } + headers[kSensitiveHeaders] = oldHeaders[kSensitiveHeaders]; + } + + const statusCode = + headers[HTTP2_HEADER_STATUS] = + headers[HTTP2_HEADER_STATUS] | 0 || HTTP_STATUS_OK; + + if (headers[HTTP2_HEADER_DATE] === null || + headers[HTTP2_HEADER_DATE] === undefined) + headers[HTTP2_HEADER_DATE] = utcDate(); + + // 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 compliance. + // 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 ERR_HTTP2_STATUS_INVALID(headers[HTTP2_HEADER_STATUS]); + + const neverIndex = headers[kSensitiveHeaders]; + if (neverIndex !== undefined && !ArrayIsArray(neverIndex)) + throw new ERR_INVALID_OPT_VALUE('headers[http2.neverIndex]', neverIndex); + + return headers; +} + +function closeStream(stream, code, rstStreamStatus = kSubmitRstStream) { + const state = stream[kState]; + state.flags |= STREAM_FLAGS_CLOSED; + state.rstCode = code; + + // Clear timeout and remove timeout listeners + stream.setTimeout(0); + stream.removeAllListeners('timeout'); + + const { ending } = stream._writableState; + + if (!ending) { + // If the writable side of the Http2Stream is still open, emit the + // 'aborted' event and set the aborted flag. + if (!stream.aborted) { + state.flags |= STREAM_FLAGS_ABORTED; + stream.emit('aborted'); + } + + // Close the writable side. + stream.end(); + } + + if (rstStreamStatus !== kNoRstStream) { + const finishFn = finishCloseStream.bind(stream, code); + if (!ending || stream.writableFinished || code !== NGHTTP2_NO_ERROR || + rstStreamStatus === kForceRstStream) + finishFn(); + else + stream.once('finish', finishFn); + } +} + +function finishCloseStream(code) { + const rstStreamFn = submitRstStream.bind(this, code); + // If the handle has not yet been assigned, queue up the request to + // ensure that the RST_STREAM frame is sent after the stream ID has + // been determined. + if (this.pending) { + this.push(null); + this.once('ready', rstStreamFn); + return; + } + rstStreamFn(); +} + +// Submit an RST-STREAM frame to be sent to the remote peer. +// This will cause the Http2Stream to be closed. +function submitRstStream(code) { + if (this[kHandle] !== undefined) { + this[kHandle].rstStream(code); + } +} + +// Called when the Http2Stream has finished sending data and is ready for +// trailers to be sent. This will only be called if the { hasOptions: true } +// option is set. +function onStreamTrailers() { + const stream = this[kOwner]; + stream[kState].trailersReady = true; + if (stream.destroyed || stream.closed) + return; + if (!stream.emit('wantTrailers')) { + // There are no listeners, send empty trailing HEADERS frame and close. + stream.sendTrailers({}); + } +} + +// Called when the stream is closed either by sending or receiving an +// RST_STREAM frame, or through a natural end-of-stream. +// If the writable and readable sides of the stream are still open at this +// point, close them. If there is an open fd for file send, close that also. +// At this point the underlying node::http2:Http2Stream handle is no +// longer usable so destroy it also. +function onStreamClose(code) { + const stream = this[kOwner]; + if (!stream || stream.destroyed) + return false; + + debugStreamObj( + stream, 'closed with code %d, closed %s, readable %s', + code, stream.closed, stream.readable + ); + + if (!stream.closed) + closeStream(stream, code, kNoRstStream); + + stream[kState].fd = -1; + // Defer destroy we actually emit end. + if (!stream.readable || code !== NGHTTP2_NO_ERROR) { + // If errored or ended, we can destroy immediately. + stream.destroy(); + } else { + // Wait for end to destroy. + stream.on('end', stream[kMaybeDestroy]); + // Push a null so the stream can end whenever the client consumes + // it completely. + stream.push(null); + + // If the user hasn't tried to consume the stream (and this is a server + // session) then just dump the incoming data so that the stream can + // be destroyed. + if (stream[kSession][kType] === NGHTTP2_SESSION_SERVER && + !stream[kState].didRead && + stream.readableFlowing === null) + stream.resume(); + else + stream.read(0); + } + return true; +} + +module.exports = { + debugStream, + kAuthority, + kInit, + kNativeFields, + kType, + kProtocol, + kSentHeaders, + onStreamClose, + onStreamTrailers, + ClientHttp2Stream, + Http2Stream, + ServerHttp2Stream, + STREAM_FLAGS_HAS_TRAILERS, + STREAM_FLAGS_HEAD_REQUEST, +}; diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 2f87043f677427..91cd5038056510 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -12,6 +12,11 @@ const { Symbol, } = primordials; +const { + kUpdateTimer, + kHandle, +} = require('internal/stream_base_commons'); + const binding = internalBinding('http2'); const { codes: { @@ -20,7 +25,8 @@ const { ERR_HTTP2_INVALID_PSEUDOHEADER, ERR_HTTP2_INVALID_SETTING_VALUE, ERR_INVALID_ARG_TYPE, - ERR_INVALID_HTTP_TOKEN + ERR_INVALID_HTTP_TOKEN, + ERR_INVALID_OPT_VALUE, }, addCodeToName, hideStackFrames @@ -28,12 +34,14 @@ const { const kSensitiveHeaders = Symbol('nodejs.http2.sensitiveHeaders'); const kSocket = Symbol('socket'); +const kState = Symbol('state'); const kProxySocket = Symbol('proxySocket'); const kRequest = Symbol('request'); const { NGHTTP2_NV_FLAG_NONE, NGHTTP2_NV_FLAG_NO_INDEX, + NGHTTP2_DEFAULT_WEIGHT, NGHTTP2_SESSION_CLIENT, NGHTTP2_SESSION_SERVER, @@ -211,6 +219,10 @@ const IDX_OPTIONS_MAX_SESSION_MEMORY = 8; const IDX_OPTIONS_MAX_SETTINGS = 9; const IDX_OPTIONS_FLAGS = 10; +const kMaxFrameSize = (2 ** 24) - 1; +const kMaxInt = (2 ** 32) - 1; +const kMaxStreams = (2 ** 32) - 1; + function updateOptionsBuffer(options) { let flags = 0; if (typeof options.maxDeflateDynamicTableSize === 'number') { @@ -614,25 +626,127 @@ function sessionName(type) { } } +// Validates the values in a settings object. Specifically: +// 1. headerTableSize must be a number in the range 0 <= n <= kMaxInt +// 2. initialWindowSize must be a number in the range 0 <= n <= kMaxInt +// 3. maxFrameSize must be a number in the range 16384 <= n <= kMaxFrameSize +// 4. maxConcurrentStreams must be a number in the range 0 <= n <= kMaxStreams +// 5. maxHeaderListSize must be a number in the range 0 <= n <= kMaxInt +// 6. enablePush must be a boolean +// 7. enableConnectProtocol must be a boolean +// All settings are optional and may be left undefined +const validateSettings = hideStackFrames((settings) => { + if (settings === undefined) return; + assertWithinRange('headerTableSize', + settings.headerTableSize, + 0, kMaxInt); + assertWithinRange('initialWindowSize', + settings.initialWindowSize, + 0, kMaxInt); + assertWithinRange('maxFrameSize', + settings.maxFrameSize, + 16384, kMaxFrameSize); + assertWithinRange('maxConcurrentStreams', + settings.maxConcurrentStreams, + 0, kMaxStreams); + assertWithinRange('maxHeaderListSize', + settings.maxHeaderListSize, + 0, kMaxInt); + if (settings.enablePush !== undefined && + typeof settings.enablePush !== 'boolean') { + throw new ERR_HTTP2_INVALID_SETTING_VALUE('enablePush', + settings.enablePush); + } + if (settings.enableConnectProtocol !== undefined && + typeof settings.enableConnectProtocol !== 'boolean') { + throw new ERR_HTTP2_INVALID_SETTING_VALUE('enableConnectProtocol', + settings.enableConnectProtocol); + } +}); + +// Validates that priority options are correct, specifically: +// 1. options.weight must be a number +// 2. options.parent must be a positive number +// 3. options.exclusive must be a boolean +// 4. if specified, options.silent must be a boolean +// +// Also sets the default priority options if they are not set. +const setAndValidatePriorityOptions = hideStackFrames((options) => { + if (options.weight === undefined) { + options.weight = NGHTTP2_DEFAULT_WEIGHT; + } else if (typeof options.weight !== 'number') { + throw new ERR_INVALID_OPT_VALUE('weight', options.weight); + } + + if (options.parent === undefined) { + options.parent = 0; + } else if (typeof options.parent !== 'number' || options.parent < 0) { + throw new ERR_INVALID_OPT_VALUE('parent', options.parent); + } + + if (options.exclusive === undefined) { + options.exclusive = false; + } else if (typeof options.exclusive !== 'boolean') { + throw new ERR_INVALID_OPT_VALUE('exclusive', options.exclusive); + } + + if (options.silent === undefined) { + options.silent = false; + } else if (typeof options.silent !== 'boolean') { + throw new ERR_INVALID_OPT_VALUE('silent', options.silent); + } +}); + +function callTimeout(self, kSession) { + // If the session is destroyed, this should never actually be invoked, + // but just in case... + if (self.destroyed) + return; + // This checks whether a write is currently in progress and also whether + // that write is actually sending data across the write. The kHandle + // stored `chunksSentSinceLastWrite` is only updated when a timeout event + // happens, meaning that if a write is ongoing it should never equal the + // newly fetched, updated value. + if (self[kState].writeQueueSize > 0) { + const handle = kSession ? self[kSession][kHandle] : self[kHandle]; + const chunksSentSinceLastWrite = handle !== undefined ? + handle.chunksSentSinceLastWrite : null; + if (chunksSentSinceLastWrite !== null && + chunksSentSinceLastWrite !== handle.updateChunksSent()) { + self[kUpdateTimer](); + return; + } + } + + self.emit('timeout'); +} + module.exports = { assertIsObject, assertValidPseudoHeader, assertValidPseudoHeaderResponse, assertValidPseudoHeaderTrailer, assertWithinRange, + callTimeout, getDefaultSettings, getSessionState, getSettings, getStreamState, isPayloadMeaningless, kSensitiveHeaders, + kMaxFrameSize, + kMaxInt, + kMaxStreams, kSocket, + kState, kProxySocket, kRequest, mapToHeaders, NghttpError, sessionName, + setAndValidatePriorityOptions, toHeaderObject, updateOptionsBuffer, - updateSettingsBuffer + updateSettingsBuffer, + validateSettings, }; diff --git a/node.gyp b/node.gyp index 497c06c6bc0a6c..875df793c7242c 100644 --- a/node.gyp +++ b/node.gyp @@ -219,6 +219,8 @@ 'lib/internal/util/types.js', 'lib/internal/http2/core.js', 'lib/internal/http2/compat.js', + 'lib/internal/http2/session.js', + 'lib/internal/http2/streams.js', 'lib/internal/http2/util.js', 'lib/internal/v8_prof_polyfill.js', 'lib/internal/v8_prof_processor.js', diff --git a/src/node_native_module.cc b/src/node_native_module.cc index 17abd057eaa06d..e4d7236f3e8cd2 100644 --- a/src/node_native_module.cc +++ b/src/node_native_module.cc @@ -97,6 +97,8 @@ void NativeModuleLoader::InitializeModuleCategories() { "_tls_wrap", "internal/http2/core", "internal/http2/compat", + "internal/http2/session", + "internal/http2/streams", "internal/policy/manifest", "internal/process/policy", "internal/streams/lazy_transform",