diff --git a/webchannel/README.md b/webchannel/README.md new file mode 100644 index 0000000..3167a66 --- /dev/null +++ b/webchannel/README.md @@ -0,0 +1,17 @@ +# WebChannel + +WebChannel is a bidirectional communication protocol designed to provide reliable, full-duplex communication channels over HTTP. + +This directory is the multi-language home for WebChannel client implementations. + +## Directory Structure + +- [js/](js/) - JavaScript client implementation (based on Closure Library). + - [imported_src/](js/imported_src/) - Imported source files from Closure Library. +- [objc/](objc/) - (Planned) Objective-C client implementation. + +## JavaScript Client (`webchannel/js`) + +The JavaScript implementation of the WebChannel client is based on the Google Closure Library. + +The raw imported source files are located in `js/imported_src/`. diff --git a/webchannel/js/imported_src/channel.js b/webchannel/js/imported_src/channel.js new file mode 100644 index 0000000..2876ff2 --- /dev/null +++ b/webchannel/js/imported_src/channel.js @@ -0,0 +1,186 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A core interface for WebChannelBase. + * + */ +goog.module('goog.labs.net.webChannel.Channel'); +goog.module.declareLegacyNamespace(); + +const ChannelRequest = goog.requireType('goog.labs.net.webChannel.ChannelRequest'); +const ConnectionState = goog.requireType('goog.labs.net.webChannel.ConnectionState'); +const Uri = goog.requireType('goog.Uri'); +const XhrIo = goog.requireType('goog.net.XhrIo'); + +/** + * Core interface for WebChannelBase. + * + * @interface + */ +function Channel() {} + +/** + * Determines whether to use a secondary domain when the server gives us + * a host prefix. This allows us to work around browser per-domain + * connection limits. + * + * If you need to use secondary domains on different browsers and IE10, + * you have two choices: + * 1) If you only care about browsers that support CORS + * (https://developer.mozilla.org/en-US/docs/HTTP_access_control), you + * can use {@link #setSupportsCrossDomainXhrs} and set the appropriate + * CORS response headers on the server. + * 2) Or, override this method in a subclass, and make sure that those + * browsers use some messaging mechanism that works cross-domain (e.g + * iframes and window.postMessage). + * + * @return {boolean} Whether to use secondary domains. + * @see http://code.google.com/p/closure-library/issues/detail?id=339 + */ +Channel.prototype.shouldUseSecondaryDomains = goog.abstractMethod; + +/** + * Called when creating an XhrIo object. Override in a subclass if + * you need to customize the behavior, for example to enable the creation of + * XHR's capable of calling a secondary domain. Will also allow calling + * a secondary domain if withCredentials (CORS) is enabled. + * @param {?string} hostPrefix The host prefix, if we need an XhrIo object + * capable of calling a secondary domain. + * @param {boolean=} isStreaming Whether or not fetch/streams are enabled for + * the underlying HTTP request. + * @return {!XhrIo} A new XhrIo object. + */ +Channel.prototype.createXhrIo = goog.abstractMethod; + +/** + * Callback from ChannelRequest that indicates a request has completed. + * @param {!ChannelRequest} request + * The request object. + */ +Channel.prototype.onRequestComplete = goog.abstractMethod; + +/** + * Returns whether the channel is closed + * @return {boolean} true if the channel is closed. + */ +Channel.prototype.isClosed = goog.abstractMethod; + +/** + * Callback from ChannelRequest for when new data is received + * @param {ChannelRequest} request + * The request object. + * @param {string} responseText The text of the response. + */ +Channel.prototype.onRequestData = goog.abstractMethod; + +/** + * Callback from ChannelRequest for when the first byte of response body has + * been received. This is needed for detecting buffering proxies. + * @param {!ChannelRequest} request + * The request object. + * @param {string} responseText The text of the response. + */ +Channel.prototype.onFirstByteReceived = goog.abstractMethod; + +/** + * Gets whether this channel is currently active. This is used to determine the + * length of time to wait before retrying. This call delegates to the handler. + * @return {boolean} Whether the channel is currently active. + */ +Channel.prototype.isActive = goog.abstractMethod; + +/** + * Not needed for testchannel. + * + * Gets the Uri used for the connection that sends data to the server. + * @param {string} path The path on the host. + * @return {Uri} The forward channel URI. + */ +Channel.prototype.getForwardChannelUri = goog.abstractMethod; + +/** + * Not needed for testchannel. + * + * Gets the Uri used for the connection that receives data from the server. + * @param {?string} hostPrefix The host prefix. + * @param {string} path The path on the host. + * @return {Uri} The back channel URI. + */ +Channel.prototype.getBackChannelUri = goog.abstractMethod; + +/** + * Not needed for testchannel. + * + * Allows the handler to override a host prefix provided by the server. Will + * be called whenever the channel has received such a prefix and is considering + * its use. + * @param {?string} serverHostPrefix The host prefix provided by the server. + * @return {?string} The host prefix the client should use. + */ +Channel.prototype.correctHostPrefix = goog.abstractMethod; + +/** + * Not needed for testchannel. + * + * Creates a data Uri applying logic for secondary hostprefix, port + * overrides, and versioning. + * @param {?string} hostPrefix The host prefix. + * @param {string} path The path on the host (may be absolute or relative). + * @param {number=} opt_overridePort Optional override port. + * @return {Uri} The data URI. + */ +Channel.prototype.createDataUri = goog.abstractMethod; + +/** + * Not needed for testchannel. + * Gets the result of previous connectivity tests. + * + * @return {!ConnectionState} The connectivity state. + */ +Channel.prototype.getConnectionState = goog.abstractMethod; + +/** + * Sets the parameter name for the http session id. + * + * @param {?string} httpSessionIdParam The parameter name for http session id + */ +Channel.prototype.setHttpSessionIdParam = goog.abstractMethod; + +/** + * Gets the parameter name for the http session id. + * + * @return {?string} The parameter name for the http session id. + */ +Channel.prototype.getHttpSessionIdParam = goog.abstractMethod; + +/** + * Sets the http session id. + * + * @param {string} httpSessionId The http session id + */ +Channel.prototype.setHttpSessionId = goog.abstractMethod; + +/** + * Gets the http session id. + * + * @return {?string} The http session id if there is one in effect. + */ +Channel.prototype.getHttpSessionId = goog.abstractMethod; + +/** + * Whether or not this channel uses WHATWG Fetch/streams. + * + * @return {boolean} true if use Fetch streams. + */ +Channel.prototype.usesFetchStreams = goog.abstractMethod; + +// MOE:begin_strip +// Ensure ES2021 inputs. go/transpile-js +null?.(6_6); +// MOE:end_strip + +exports = Channel; diff --git a/webchannel/js/imported_src/channelrequest.js b/webchannel/js/imported_src/channelrequest.js new file mode 100644 index 0000000..8dc5e14 --- /dev/null +++ b/webchannel/js/imported_src/channelrequest.js @@ -0,0 +1,1257 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Definition of the ChannelRequest class. The request + * object encapsulates the logic for making a single request, either for the + * forward channel, back channel, or test channel, to the server. It contains + * the logic for the two types of transports we use: + * XMLHTTP and Image request. It provides timeout detection. More transports + * to be added in future, such as Fetch, WebSocket. + * + */ + +goog.module('goog.labs.net.webChannel.ChannelRequest'); +goog.module.declareLegacyNamespace(); + +const Channel = goog.require('goog.labs.net.webChannel.Channel'); +const ErrorCode = goog.require('goog.net.ErrorCode'); +const EventHandler = goog.require('goog.events.EventHandler'); +const EventType = goog.require('goog.net.EventType'); +const GoogEvent = goog.requireType('goog.events.Event'); +const Throttle = goog.require('goog.async.Throttle'); +const Uri = goog.requireType('goog.Uri'); +const WebChannel = goog.require('goog.net.WebChannel'); +const WebChannelDebug = goog.require('goog.labs.net.webChannel.WebChannelDebug'); +const Wire = goog.requireType('goog.labs.net.webChannel.Wire'); +const XhrIo = goog.requireType('goog.net.XhrIo'); +const XmlHttp = goog.require('goog.net.XmlHttp'); +const asserts = goog.require('goog.asserts'); +const dispose = goog.require('goog.dispose'); +/** @suppress {extraRequire} unused, try to remove this. */ +const environment = goog.require('goog.labs.net.webChannel.environment'); +const googObject = goog.require('goog.object'); +const googString = goog.require('goog.string'); +const requestStats = goog.require('goog.labs.net.webChannel.requestStats'); + +/** + * A new ChannelRequest is created for each request to the server. + * + * @param {Channel} channel + * The channel that owns this request. + * @param {WebChannelDebug} channelDebug A + * WebChannelDebug to use for logging. + * @param {string=} opt_sessionId The session id for the channel. + * @param {string|number=} opt_requestId The request id for this request. + * @param {number=} opt_retryId The retry id for this request. + * @constructor + * @struct + * @final + */ +function ChannelRequest( + channel, channelDebug, opt_sessionId, opt_requestId, opt_retryId) { + /** + * The channel object that owns the request. + * @private {Channel} + */ + this.channel_ = channel; + + /** + * The channel debug to use for logging + * @private {WebChannelDebug} + */ + this.channelDebug_ = channelDebug; + + /** + * The Session ID for the channel. + * @private {string|undefined} + */ + this.sid_ = opt_sessionId; + + /** + * The RID (request ID) for the request. + * @private {string|number|undefined} + */ + this.rid_ = opt_requestId; + + /** + * The attempt number of the current request. + * @private {number} + */ + this.retryId_ = opt_retryId || 1; + + /** + * An object to keep track of the channel request event listeners. + * @private {!EventHandler< + * !ChannelRequest>} + */ + this.eventHandler_ = new EventHandler(this); + + /** + * The timeout in ms before failing the request. + * @private {number} + */ + this.timeout_ = ChannelRequest.TIMEOUT_MS_; + + /** + * Extra HTTP headers to add to all the requests sent to the server. + * @private {?Object} + */ + this.extraHeaders_ = null; + + /** + * Whether the request was successful. This is only set to true after the + * request successfully completes. + * @private {boolean} + */ + this.successful_ = false; + + /** + * The TimerID of the timer used to detect if the request has timed-out. + * @type {?number} + * @private + */ + this.watchDogTimerId_ = null; + + /** + * The time in the future when the request will timeout. + * @private {?number} + */ + this.watchDogTimeoutTime_ = null; + + /** + * The time the request started. + * @private {?number} + */ + this.requestStartTime_ = null; + + /** + * The type of request (XMLHTTP, IMG) + * @private {?number} + */ + this.type_ = null; + + /** + * The base Uri for the request. The includes all the parameters except the + * one that indicates the retry number. + * @private {?Uri} + */ + this.baseUri_ = null; + + /** + * The request Uri that was actually used for the most recent request attempt. + * @private {?Uri} + */ + this.requestUri_ = null; + + /** + * The post data, if the request is a post. + * @private {?string} + */ + this.postData_ = null; + + /** + * An array of pending messages that we have either received a non-successful + * response for, or no response at all, and which therefore may or may not + * have been received by the server. + * @private {!Array} + */ + this.pendingMessages_ = []; + + /** + * The XhrLte request if the request is using XMLHTTP + * @private {?XhrIo} + */ + this.xmlHttp_ = null; + + /** + * The position of where the next unprocessed chunk starts in the response + * text. + * @private {number} + */ + this.xmlHttpChunkStart_ = 0; + + /** + * The verb (Get or Post) for the request. + * @private {?string} + */ + this.verb_ = null; + + /** + * The last error if the request failed. + * @private {?ChannelRequest.Error} + */ + this.lastError_ = null; + + /** + * The response headers received along with the non-200 status. + * + * @private {!Object|undefined} + */ + this.errorResponseHeaders_ = undefined; + + /** + * The last status code received. + * @private {number} + */ + this.lastStatusCode_ = -1; + + /** + * Whether the request has been cancelled due to a call to cancel. + * @private {boolean} + */ + this.cancelled_ = false; + + /** + * A throttle time in ms for readystatechange events for the backchannel. + * Useful for throttling when ready state is INTERACTIVE (partial data). + * If set to zero no throttle is used. + * + * See WebChannelBase.prototype.readyStateChangeThrottleMs_ + * + * @private {number} + */ + this.readyStateChangeThrottleMs_ = 0; + + /** + * The throttle for readystatechange events for the current request, or null + * if there is none. + * @private {?Throttle} + */ + this.readyStateChangeThrottle_ = null; + + /** + * Whether to the result is expected to be encoded for chunking and thus + * requires decoding. + * @private {boolean} + */ + this.decodeChunks_ = false; + + /** + * Whether to decode x-http-initial-response. + * @private {boolean} + */ + this.decodeInitialResponse_ = false; + + /** + * Whether x-http-initial-response has been decoded (dispatched). + * @private {boolean} + */ + this.initialResponseDecoded_ = false; + + /** + * Whether the first byte of response body has arrived, for a successful + * response. + * @private {boolean} + */ + this.firstByteReceived_ = false; + + /** + * The current state of fetch responses if webchannel is using WHATWG + * fetch/streams. + * @private {!goog.labs.net.webChannel.FetchResponseState} + */ + this.fetchResponseState_ = new goog.labs.net.webChannel.FetchResponseState(); +} + +/** + * A collection of fetch/stream properties. + * @struct + * @constructor + */ +goog.labs.net.webChannel.FetchResponseState = function() { + /** + * The TextDecoder for decoding Uint8Array responses from fetch request. + * @type {?goog.global.TextDecoder} + */ + this.textDecoder = null; + + /** + * The unconsumed response text from the fetch requests. + * @type {string} + */ + this.responseBuffer = ''; + + /** + * Whether or not the response body has arrived. + * @type {boolean} + */ + this.responseArrivedForFetch = false; +}; + +/** + * Default timeout in MS for a request. The server must return data within this + * time limit for the request to not timeout. + * @private {number} + */ +ChannelRequest.TIMEOUT_MS_ = 45 * 1000; + +/** + * Enum for channel requests type + * @enum {number} + * @private + */ +ChannelRequest.Type_ = { + /** + * XMLHTTP requests. + */ + XML_HTTP: 1, + + /** + * IMG requests. + */ + CLOSE_REQUEST: 2 +}; + +/** + * Enum type for identifying an error. + * @enum {number} + */ +ChannelRequest.Error = { + /** + * Errors due to a non-200 status code. + */ + STATUS: 0, + + /** + * Errors due to no data being returned. + */ + NO_DATA: 1, + + /** + * Errors due to a timeout. + */ + TIMEOUT: 2, + + /** + * Errors due to the server returning an unknown. + */ + UNKNOWN_SESSION_ID: 3, + + /** + * Errors due to bad data being received. + */ + BAD_DATA: 4, + + /** + * Errors due to the handler throwing an exception. + */ + HANDLER_EXCEPTION: 5, + + /** + * The browser declared itself offline during the request. + */ + BROWSER_OFFLINE: 6 +}; + +/** + * Returns a useful error string for debugging based on the specified error + * code. + * @param {?ChannelRequest.Error} errorCode The error code. + * @param {number} statusCode The HTTP status code. + * @return {string} The error string for the given code combination. + */ +ChannelRequest.errorStringFromCode = function(errorCode, statusCode) { + switch (errorCode) { + case ChannelRequest.Error.STATUS: + return 'Non-200 return code (' + statusCode + ')'; + case ChannelRequest.Error.NO_DATA: + return 'XMLHTTP failure (no data)'; + case ChannelRequest.Error.TIMEOUT: + return 'HttpConnection timeout'; + default: + return 'Unknown error'; + } +}; + +/** + * Sentinel value used to indicate an invalid chunk in a multi-chunk response. + * @private {!Object} + */ +ChannelRequest.INVALID_CHUNK_ = {}; + +/** + * Sentinel value used to indicate an incomplete chunk in a multi-chunk + * response. + * @private {!Object} + */ +ChannelRequest.INCOMPLETE_CHUNK_ = {}; + +/** + * Returns whether XHR streaming is supported on this browser. + * + * @return {boolean} Whether XHR streaming is supported. + * @see http://code.google.com/p/closure-library/issues/detail?id=346 + */ +ChannelRequest.supportsXhrStreaming = function() { + // TODO: Remove this function. + return true; +}; + +/** + * Sets extra HTTP headers to add to all the requests sent to the server. + * + * @param {Object} extraHeaders The HTTP headers. + */ +ChannelRequest.prototype.setExtraHeaders = function(extraHeaders) { + this.extraHeaders_ = extraHeaders; +}; + +/** + * Overrides the default HTTP method. + * + * @param {string} verb The HTTP method + */ +ChannelRequest.prototype.setVerb = function(verb) { + this.verb_ = verb; +}; + +/** + * Sets the timeout for a request + * + * @param {number} timeout The timeout in MS for when we fail the request. + */ +ChannelRequest.prototype.setTimeout = function(timeout) { + this.timeout_ = timeout; +}; + +/** + * Sets the throttle for handling onreadystatechange events for the request. + * + * @param {number} throttle The throttle in ms. A value of zero indicates + * no throttle. + */ +ChannelRequest.prototype.setReadyStateChangeThrottle = function(throttle) { + this.readyStateChangeThrottleMs_ = throttle; +}; + +/** + * Sets the pending messages that this request is handling. + * + * @param {!Array} pendingMessages + * The pending messages for this request. + */ +ChannelRequest.prototype.setPendingMessages = function(pendingMessages) { + this.pendingMessages_ = pendingMessages; +}; + +/** + * Gets the pending messages that this request is handling, in case of a retry. + * + * @return {!Array} The pending + * messages for this request. + */ +ChannelRequest.prototype.getPendingMessages = function() { + return this.pendingMessages_; +}; + +/** + * Uses XMLHTTP to send an HTTP POST to the server. + * + * @param {Uri} uri The uri of the request. + * @param {?string} postData The data for the post body. + * @param {boolean} decodeChunks Whether to the result is expected to be + * encoded for chunking and thus requires decoding. + */ +ChannelRequest.prototype.xmlHttpPost = function(uri, postData, decodeChunks) { + this.type_ = ChannelRequest.Type_.XML_HTTP; + this.baseUri_ = uri.clone().makeUnique(); + this.postData_ = postData; + this.decodeChunks_ = decodeChunks; + this.sendXmlHttp_(null /* hostPrefix */); +}; + +/** + * Uses XMLHTTP to send an HTTP GET to the server. + * + * @param {Uri} uri The uri of the request. + * @param {boolean} decodeChunks Whether to the result is expected to be + * encoded for chunking and thus requires decoding. + * @param {?string} hostPrefix The host prefix, if we might be using a + * secondary domain. Note that it should also be in the URL, adding this + * won't cause it to be added to the URL. + */ +ChannelRequest.prototype.xmlHttpGet = function(uri, decodeChunks, hostPrefix) { + this.type_ = ChannelRequest.Type_.XML_HTTP; + this.baseUri_ = uri.clone().makeUnique(); + this.postData_ = null; + this.decodeChunks_ = decodeChunks; + + this.sendXmlHttp_(hostPrefix); +}; + +/** + * Sends a request via XMLHTTP according to the current state of the request + * object. + * + * @param {?string} hostPrefix The host prefix, if we might be using a secondary + * domain. + * @private + */ +ChannelRequest.prototype.sendXmlHttp_ = function(hostPrefix) { + this.requestStartTime_ = Date.now(); + this.ensureWatchDogTimer_(); + + // clone the base URI to create the request URI. The request uri has the + // attempt number as a parameter which helps in debugging. + this.requestUri_ = this.baseUri_.clone(); + this.requestUri_.setParameterValues('t', this.retryId_); + + // send the request either as a POST or GET + this.xmlHttpChunkStart_ = 0; + const useSecondaryDomains = this.channel_.shouldUseSecondaryDomains(); + this.fetchResponseState_ = new goog.labs.net.webChannel.FetchResponseState(); + // If the request is a GET request, start a backchannel to transfer streaming + // data. Note that WebChannel GET request can also be used for closing the + // channel as in method ChannelRequest#sendCloseRequest. + // The second parameter of Channel#createXhrIo is JS only. + this.xmlHttp_ = this.channel_.createXhrIo( + useSecondaryDomains ? hostPrefix : null, !this.postData_); + + if (this.readyStateChangeThrottleMs_ > 0) { + this.readyStateChangeThrottle_ = new Throttle( + goog.bind(this.xmlHttpHandler_, this, this.xmlHttp_), + this.readyStateChangeThrottleMs_); + } + + this.eventHandler_.listen( + this.xmlHttp_, EventType.READY_STATE_CHANGE, + this.readyStateChangeHandler_); + + const headers = + this.extraHeaders_ ? googObject.clone(this.extraHeaders_) : {}; + if (this.postData_) { + if (!this.verb_) { + this.verb_ = 'POST'; + } + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + this.xmlHttp_.send(this.requestUri_, this.verb_, this.postData_, headers); + } else { + this.verb_ = 'GET'; + this.xmlHttp_.send(this.requestUri_, this.verb_, null, headers); + } + requestStats.notifyServerReachabilityEvent( + requestStats.ServerReachability.REQUEST_MADE); + this.channelDebug_.xmlHttpChannelRequest( + this.verb_, this.requestUri_, this.rid_, this.retryId_, this.postData_); +}; + +/** + * Handles a readystatechange event. + * @param {GoogEvent} evt The event. + * @private + */ +ChannelRequest.prototype.readyStateChangeHandler_ = function(evt) { + const xhr = /** @type {XhrIo} */ (evt.target); + const throttle = this.readyStateChangeThrottle_; + if (throttle && xhr.getReadyState() == XmlHttp.ReadyState.INTERACTIVE) { + // Only throttle in the partial data case. + this.channelDebug_.debug('Throttling readystatechange.'); + throttle.fire(); + } else { + // If we haven't throttled, just handle response directly. + this.xmlHttpHandler_(xhr); + } +}; + +/** + * XmlHttp handler + * @param {XhrIo} xmlhttp The XhrIo object for the current request. + * @private + */ +ChannelRequest.prototype.xmlHttpHandler_ = function(xmlhttp) { + requestStats.onStartExecution(); + + try { + if (xmlhttp == this.xmlHttp_) { + this.onXmlHttpReadyStateChanged_(); + } else { + this.channelDebug_.warning( + 'Called back with an ' + + 'unexpected xmlhttp'); + } + } catch (ex) { + this.channelDebug_.debug('Failed call to OnXmlHttpReadyStateChanged_'); + if (this.hasResponseBody_()) { + const channelRequest = this; + this.channelDebug_.dumpException(ex, function() { + return 'ResponseText: ' + channelRequest.xmlHttp_.getResponseText(); + }); + } else { + this.channelDebug_.dumpException(ex, 'No response text'); + } + } finally { + requestStats.onEndExecution(); + } +}; + +/** + * Called by the readystate handler for XMLHTTP requests. + * + * @private + */ +ChannelRequest.prototype.onXmlHttpReadyStateChanged_ = function() { + const readyState = this.xmlHttp_.getReadyState(); + const errorCode = this.xmlHttp_.getLastErrorCode(); + const statusCode = this.xmlHttp_.getStatus(); + + // we get partial results in browsers that support ready state interactive. + // We also make sure that getResponseText is not null in interactive mode + // before we continue. + if (readyState < XmlHttp.ReadyState.INTERACTIVE || + (readyState == XmlHttp.ReadyState.INTERACTIVE && + !this.hasResponseBody_())) { + return; // not yet ready + } + + // Dispatch any appropriate network events. + if (!this.cancelled_ && readyState == XmlHttp.ReadyState.COMPLETE && + errorCode != ErrorCode.ABORT) { + // Pretty conservative, these are the only known scenarios which we'd + // consider indicative of a truly non-functional network connection. + if (errorCode == ErrorCode.TIMEOUT || statusCode <= 0) { + requestStats.notifyServerReachabilityEvent( + requestStats.ServerReachability.REQUEST_FAILED); + } else { + requestStats.notifyServerReachabilityEvent( + requestStats.ServerReachability.REQUEST_SUCCEEDED); + } + } + + // got some data so cancel the watchdog timer + this.cancelWatchDogTimer_(); + + const status = this.xmlHttp_.getStatus(); + this.lastStatusCode_ = status; + const responseText = this.decodeXmlHttpResponse_(); + + if (!this.hasResponseBody_()) { + const channelRequest = this; + this.channelDebug_.debug(function() { + return 'No response text for uri ' + channelRequest.requestUri_ + + ' status ' + status; + }); + } + this.successful_ = (status == 200); + + this.channelDebug_.xmlHttpChannelResponseMetaData( + /** @type {string} */ (this.verb_), this.requestUri_, this.rid_, + this.retryId_, readyState, status); + + if (!this.successful_) { + this.errorResponseHeaders_ = this.xmlHttp_.getResponseHeaders(); + if (status == 400 && responseText.indexOf('Unknown SID') > 0) { + // the server error string will include 'Unknown SID' which indicates the + // server doesn't know about the session (maybe it got restarted, maybe + // the user got moved to another server, etc.,). Handlers can special + // case this error + this.lastError_ = ChannelRequest.Error.UNKNOWN_SESSION_ID; + requestStats.notifyStatEvent( + requestStats.Stat.REQUEST_UNKNOWN_SESSION_ID); + this.channelDebug_.warning('XMLHTTP Unknown SID (' + this.rid_ + ')'); + } else { + this.lastError_ = ChannelRequest.Error.STATUS; + requestStats.notifyStatEvent(requestStats.Stat.REQUEST_BAD_STATUS); + this.channelDebug_.warning( + 'XMLHTTP Bad status ' + status + ' (' + this.rid_ + ')'); + } + this.cleanup_(); + this.dispatchFailure_(); + return; + } + + if (this.shouldCheckInitialResponse_()) { + const initialResponse = this.getInitialResponse_(); + if (initialResponse) { + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, initialResponse, + 'Initial handshake response via ' + + WebChannel.X_HTTP_INITIAL_RESPONSE); + this.initialResponseDecoded_ = true; + this.safeOnRequestData_(initialResponse); + } else { + this.successful_ = false; + this.lastError_ = ChannelRequest.Error.UNKNOWN_SESSION_ID; // fail-fast + requestStats.notifyStatEvent( + requestStats.Stat.REQUEST_UNKNOWN_SESSION_ID); + this.channelDebug_.warning( + 'XMLHTTP Missing X_HTTP_INITIAL_RESPONSE' + + ' (' + this.rid_ + ')'); + this.cleanup_(); + this.dispatchFailure_(); + return; + } + } + + if (this.decodeChunks_) { + this.decodeNextChunks_(readyState, responseText); + } else { + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, responseText, null); + this.safeOnRequestData_(responseText); + } + + if (readyState == XmlHttp.ReadyState.COMPLETE) { + this.cleanup_(); + } + + if (!this.successful_) { + return; + } + + if (!this.cancelled_) { + if (readyState == XmlHttp.ReadyState.COMPLETE) { + this.channel_.onRequestComplete(this); + } else { + // The default is false, the result from this callback shouldn't carry + // over to the next callback, otherwise the request looks successful if + // the watchdog timer gets called + this.successful_ = false; + this.ensureWatchDogTimer_(); + } + } +}; + +/** + * Whether we need check the initial-response header that is sent during the + * fast handshake. + * + * @return {boolean} true if the initial-response header is yet to be processed. + * @private + */ +ChannelRequest.prototype.shouldCheckInitialResponse_ = function() { + return this.decodeInitialResponse_ && !this.initialResponseDecoded_; +}; + +/** + * Queries the initial response header that is sent during the handshake. + * + * @return {?string} The non-empty header value or null. + * @private + */ +ChannelRequest.prototype.getInitialResponse_ = function() { + if (this.xmlHttp_) { + const value = this.xmlHttp_.getStreamingResponseHeader( + WebChannel.X_HTTP_INITIAL_RESPONSE); + if (value && !googString.isEmptyOrWhitespace(value)) { + return value; + } + } + + return null; +}; + +/** + * Check if the initial response header has been handled. + * + * @return {boolean} true if X_HTTP_INITIAL_RESPONSE has been handled. + */ +ChannelRequest.prototype.isInitialResponseDecoded = function() { + return this.initialResponseDecoded_; +}; + +/** + * Decodes X_HTTP_INITIAL_RESPONSE if present. + */ +ChannelRequest.prototype.setDecodeInitialResponse = function() { + this.decodeInitialResponse_ = true; +}; + +/** + * Decodes the responses from XhrIo object. + * @returns {string} responseText + * @private + */ +ChannelRequest.prototype.decodeXmlHttpResponse_ = function() { + if (!this.useFetchStreamsForResponse_()) { + return this.xmlHttp_.getResponseText(); + } + const responseChunks = + /** @type {!Array|string} */ (this.xmlHttp_.getResponse()); + if (responseChunks === '') { + // in case of net::ERR_INTERNET_DISCONNECTED, this.xmlHttp_.getResponse() + // returns '' (empty string) + return ''; + } + let responseText = ''; + const responseLength = responseChunks.length; + const requestCompleted = + this.xmlHttp_.getReadyState() == XmlHttp.ReadyState.COMPLETE; + if (!this.fetchResponseState_.textDecoder) { + if (typeof TextDecoder === 'undefined') { + this.channelDebug_.severe( + 'TextDecoder is not supported by this browser.'); + this.cleanup_(); + this.dispatchFailure_(); + return ''; + } + this.fetchResponseState_.textDecoder = new goog.global.TextDecoder(); + } + for (let i = 0; i < responseLength; i++) { + this.fetchResponseState_.responseArrivedForFetch = true; + const isLastChunk = requestCompleted && i == responseLength - 1; + responseText += this.fetchResponseState_.textDecoder.decode( + responseChunks[i], {stream: !isLastChunk}); + } + responseChunks.length = 0; // Empty the `responseChunks` array. + this.fetchResponseState_.responseBuffer += responseText; + this.xmlHttpChunkStart_ = 0; + return this.fetchResponseState_.responseBuffer; +}; + +/** + * Whether or not the response has response body. + * @private + * @returns {boolean} + */ +ChannelRequest.prototype.hasResponseBody_ = function() { + if (!this.xmlHttp_) { + return false; + } + if (this.fetchResponseState_.responseArrivedForFetch) { + return true; + } + return !(!this.xmlHttp_.getResponseText() && !this.xmlHttp_.getResponse()); +}; + +/** + * Whether or not the response body is streamed. + * @private + * @returns {boolean} + */ +ChannelRequest.prototype.useFetchStreamsForResponse_ = function() { + if (!this.xmlHttp_) { + return false; + } + return ( + this.verb_ == 'GET' && this.type_ != ChannelRequest.Type_.CLOSE_REQUEST && + this.channel_.usesFetchStreams()); +}; + +/** + * Decodes the next set of available chunks in the response. + * @param {number} readyState The value of readyState. + * @param {string} responseText The value of responseText. + * @private + */ +ChannelRequest.prototype.decodeNextChunks_ = function( + readyState, responseText) { + let decodeNextChunksSuccessful = true; + + let chunkText; + while (!this.cancelled_ && this.xmlHttpChunkStart_ < responseText.length) { + chunkText = this.getNextChunk_(responseText); + if (chunkText == ChannelRequest.INCOMPLETE_CHUNK_) { + if (readyState == XmlHttp.ReadyState.COMPLETE) { + // should have consumed entire response when the request is done + this.lastError_ = ChannelRequest.Error.BAD_DATA; + requestStats.notifyStatEvent(requestStats.Stat.REQUEST_INCOMPLETE_DATA); + decodeNextChunksSuccessful = false; + } + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, null, '[Incomplete Response]'); + break; + } else if (chunkText == ChannelRequest.INVALID_CHUNK_) { + this.lastError_ = ChannelRequest.Error.BAD_DATA; + requestStats.notifyStatEvent(requestStats.Stat.REQUEST_BAD_DATA); + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, responseText, '[Invalid Chunk]'); + decodeNextChunksSuccessful = false; + break; + } else { + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, /** @type {string} */ (chunkText), null); + this.safeOnRequestData_(/** @type {string} */ (chunkText)); + } + } + + if (this.useFetchStreamsForResponse_() && this.xmlHttpChunkStart_ != 0) { + // Remove processed chunk text from response buffer. + this.fetchResponseState_.responseBuffer = + this.fetchResponseState_.responseBuffer.slice(this.xmlHttpChunkStart_); + this.xmlHttpChunkStart_ = 0; + } + + if (readyState == XmlHttp.ReadyState.COMPLETE && responseText.length == 0 && + !this.fetchResponseState_.responseArrivedForFetch) { + // also an error if we didn't get any response + this.lastError_ = ChannelRequest.Error.NO_DATA; + requestStats.notifyStatEvent(requestStats.Stat.REQUEST_NO_DATA); + decodeNextChunksSuccessful = false; + } + + this.successful_ = this.successful_ && decodeNextChunksSuccessful; + + if (!decodeNextChunksSuccessful) { + // malformed response - we make this trigger retry logic + this.channelDebug_.xmlHttpChannelResponseText( + this.rid_, responseText, '[Invalid Chunked Response]'); + this.cleanup_(); + this.dispatchFailure_(); + } else { + if (responseText.length > 0 && !this.firstByteReceived_) { + this.firstByteReceived_ = true; + this.channel_.onFirstByteReceived(this, responseText); + } + } +}; + +/** + * Returns the next chunk of a chunk-encoded response. This is not standard + * HTTP chunked encoding because browsers don't expose the chunk boundaries to + * the application through XMLHTTP. So we have an additional chunk encoding at + * the application level that lets us tell where the beginning and end of + * individual responses are so that we can only try to eval a complete JS array. + * + * The encoding is the size of the chunk encoded as a decimal string followed + * by a newline followed by the data. + * + * @param {string} responseText The response text from the XMLHTTP response. + * @return {string|!Object} The next chunk string or a sentinel object + * indicating a special condition. + * @private + */ +ChannelRequest.prototype.getNextChunk_ = function(responseText) { + const sizeStartIndex = this.xmlHttpChunkStart_; + const sizeEndIndex = responseText.indexOf('\n', sizeStartIndex); + if (sizeEndIndex == -1) { + return ChannelRequest.INCOMPLETE_CHUNK_; + } + + const sizeAsString = responseText.substring(sizeStartIndex, sizeEndIndex); + const size = Number(sizeAsString); + if (isNaN(size)) { + return ChannelRequest.INVALID_CHUNK_; + } + + const chunkStartIndex = sizeEndIndex + 1; + if (chunkStartIndex + size > responseText.length) { + return ChannelRequest.INCOMPLETE_CHUNK_; + } + + const chunkText = responseText.slice(chunkStartIndex, chunkStartIndex + size); + this.xmlHttpChunkStart_ = chunkStartIndex + size; + return chunkText; +}; + +/** + * Uses an IMG tag or navigator.sendBeacon to send an HTTP get to the server. + * + * This is only currently used to terminate the connection, as an IMG tag is + * the most reliable way to send something to the server while the page + * is getting torn down. + * + * Navigator.sendBeacon is available on Chrome and Firefox as a formal + * solution to ensure delivery without blocking window close. See + * https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon + * + * For Chrome Apps, sendBeacon is always necessary due to Content Security + * Policy (CSP) violation of using an IMG tag. + * + * For react-native, we use xhr to send the actual close request, and assume + * there is no page-close issue with react-native. + * + * @param {Uri} uri The uri to send a request to. + */ +ChannelRequest.prototype.sendCloseRequest = function(uri) { + this.type_ = ChannelRequest.Type_.CLOSE_REQUEST; + this.baseUri_ = uri.clone().makeUnique(); + + let requestSent = false; + + if (goog.global.navigator && goog.global.navigator.sendBeacon) { + try { + // empty string body to avoid 413 error on chrome < 41 + requestSent = + goog.global.navigator.sendBeacon(this.baseUri_.toString(), ''); + } catch { + // Intentionally left empty; sendBeacon might throw TypeError in certain + // unexpected cases. + } + } + + if (!requestSent && goog.global.Image) { + const eltImg = new Image(); + eltImg.src = this.baseUri_; + requestSent = true; + } + + if (!requestSent) { + // no handler is set to match the sendBeacon/Image behavior + this.xmlHttp_ = this.channel_.createXhrIo(null); + this.xmlHttp_.send(this.baseUri_); + } + + this.requestStartTime_ = Date.now(); + this.ensureWatchDogTimer_(); +}; + +/** + * Cancels the request no matter what the underlying transport is. + */ +ChannelRequest.prototype.cancel = function() { + this.cancelled_ = true; + this.cleanup_(); +}; + +/** + * Resets the timeout. + * + * @param {number=} opt_timeout The new timeout + */ +ChannelRequest.prototype.resetTimeout = function(opt_timeout) { + if (opt_timeout) { + this.setTimeout(opt_timeout); + } + // restart only if a timer is currently set + if (this.watchDogTimerId_) { + this.cancelWatchDogTimer_(); + this.ensureWatchDogTimer_(); + } +}; + +/** + * Ensures that there is watchdog timeout which is used to ensure that + * the connection completes in time. + * + * @private + */ +ChannelRequest.prototype.ensureWatchDogTimer_ = function() { + this.watchDogTimeoutTime_ = Date.now() + this.timeout_; + this.startWatchDogTimer_(this.timeout_); +}; + +/** + * Starts the watchdog timer which is used to ensure that the connection + * completes in time. + * @param {number} time The number of milliseconds to wait. + * @private + */ +ChannelRequest.prototype.startWatchDogTimer_ = function(time) { + if (this.watchDogTimerId_ != null) { + // assertion + throw new Error('WatchDog timer not null'); + } + this.watchDogTimerId_ = + requestStats.setTimeout(goog.bind(this.onWatchDogTimeout_, this), time); +}; + +/** + * Cancels the watchdog timer if it has been started. + * + * @private + */ +ChannelRequest.prototype.cancelWatchDogTimer_ = function() { + if (this.watchDogTimerId_) { + goog.global.clearTimeout(this.watchDogTimerId_); + this.watchDogTimerId_ = null; + } +}; + +/** + * Called when the watchdog timer is triggered. It also handles a case where it + * is called too early which we suspect may be happening sometimes + * (not sure why) + * + * @private + */ +ChannelRequest.prototype.onWatchDogTimeout_ = function() { + this.watchDogTimerId_ = null; + const now = Date.now(); + asserts.assert(this.watchDogTimeoutTime_, 'WatchDog timeout time missing?'); + if (now - this.watchDogTimeoutTime_ >= 0) { + this.handleTimeout_(); + } else { + // got called too early for some reason + this.channelDebug_.warning('WatchDog timer called too early'); + this.startWatchDogTimer_(this.watchDogTimeoutTime_ - now); + } +}; + +/** + * Called when the request has actually timed out. Will cleanup and notify the + * channel of the failure. + * + * @private + */ +ChannelRequest.prototype.handleTimeout_ = function() { + if (this.successful_) { + // Should never happen. + this.channelDebug_.severe( + 'Received watchdog timeout even though request loaded successfully'); + } + + this.channelDebug_.timeoutResponse(this.requestUri_); + + // IMG or SendBeacon requests never notice if they were successful, + // and always 'time out'. This fact says nothing about reachability. + if (this.type_ != ChannelRequest.Type_.CLOSE_REQUEST) { + requestStats.notifyServerReachabilityEvent( + requestStats.ServerReachability.REQUEST_FAILED); + requestStats.notifyStatEvent(requestStats.Stat.REQUEST_TIMEOUT); + } + + this.cleanup_(); + + // Set error and dispatch failure. + // This is called for CLOSE_REQUEST too to ensure channel_.onRequestComplete. + this.lastError_ = ChannelRequest.Error.TIMEOUT; + this.dispatchFailure_(); +}; + +/** + * Notifies the channel that this request failed. + * @private + */ +ChannelRequest.prototype.dispatchFailure_ = function() { + if (this.channel_.isClosed() || this.cancelled_) { + return; + } + + this.channel_.onRequestComplete(this); +}; + +/** + * Cleans up the objects used to make the request. This function is + * idempotent. + * + * @private + */ +ChannelRequest.prototype.cleanup_ = function() { + this.cancelWatchDogTimer_(); + + dispose(this.readyStateChangeThrottle_); + this.readyStateChangeThrottle_ = null; + + // Unhook all event handlers. + this.eventHandler_.removeAll(); + + if (this.xmlHttp_) { + // clear out this.xmlHttp_ before aborting so we handle getting reentered + // inside abort + const xmlhttp = this.xmlHttp_; + this.xmlHttp_ = null; + xmlhttp.abort(); + xmlhttp.dispose(); + } +}; + +/** + * Indicates whether the request was successful. Only valid after the handler + * is called to indicate completion of the request. + * + * @return {boolean} True if the request succeeded. + */ +ChannelRequest.prototype.getSuccess = function() { + return this.successful_; +}; + +/** + * If the request was not successful, returns the reason. + * + * @return {?ChannelRequest.Error} The last error. + */ +ChannelRequest.prototype.getLastError = function() { + return this.lastError_; +}; + +/** + * @return {!Object|undefined} Response headers received + * along with the non-200 status, as a key-value map. + */ +ChannelRequest.prototype.getErrorResponseHeaders = function() { + return this.errorResponseHeaders_; +}; + +/** + * Returns the status code of the last request. + * + * @return {number} The status code of the last request. + */ +ChannelRequest.prototype.getLastStatusCode = function() { + return this.lastStatusCode_; +}; + +/** + * Returns the session id for this channel. + * + * @return {string|undefined} The session ID. + */ +ChannelRequest.prototype.getSessionId = function() { + return this.sid_; +}; + +/** + * Returns the request id for this request. Each request has a unique request + * id and the request IDs are a sequential increasing count. + * + * @return {string|number|undefined} The request ID. + */ +ChannelRequest.prototype.getRequestId = function() { + return this.rid_; +}; + +/** + * Returns the data for a post, if this request is a post. + * + * @return {?string} The POST data provided by the request initiator. + */ +ChannelRequest.prototype.getPostData = function() { + return this.postData_; +}; + +/** + * Returns the XhrIo request object. + * + * @return {?XhrIo} Any XhrIo request created for this object. + */ +ChannelRequest.prototype.getXhr = function() { + return this.xmlHttp_; +}; + +/** + * Returns the time that the request started, if it has started. + * + * @return {?number} The time the request started, as returned by Date.now(). + */ +ChannelRequest.prototype.getRequestStartTime = function() { + return this.requestStartTime_; +}; + +/** + * Helper to call the callback's onRequestData, which catches any + * exception. + * @param {string} data The request data. + * @private + */ +ChannelRequest.prototype.safeOnRequestData_ = function(data) { + try { + this.channel_.onRequestData(this, data); + const stats = requestStats.ServerReachability; + requestStats.notifyServerReachabilityEvent(stats.BACK_CHANNEL_ACTIVITY); + } catch (e) { + // Dump debug info, but keep going without closing the channel. + this.channelDebug_.dumpException(e, 'Error in httprequest callback'); + } +}; + +/** + * Convenience factory method. + * + * @param {Channel} channel The channel object that owns this request. + * @param {WebChannelDebug} channelDebug A WebChannelDebug to use for logging. + * @param {string=} opt_sessionId The session id for the channel. + * @param {string|number=} opt_requestId The request id for this request. + * @param {number=} opt_retryId The retry id for this request. + * @return {!ChannelRequest} The created channel request. + */ +ChannelRequest.createChannelRequest = function( + channel, channelDebug, opt_sessionId, opt_requestId, opt_retryId) { + return new ChannelRequest( + channel, channelDebug, opt_sessionId, opt_requestId, opt_retryId); +}; + +exports = ChannelRequest; diff --git a/webchannel/js/imported_src/connectionstate.js b/webchannel/js/imported_src/connectionstate.js new file mode 100644 index 0000000..cfa508f --- /dev/null +++ b/webchannel/js/imported_src/connectionstate.js @@ -0,0 +1,46 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview This class manages the network connectivity state. + * + */ + + +goog.module('goog.labs.net.webChannel.ConnectionState'); + +goog.module.declareLegacyNamespace(); + +/** + * The connectivity state of the channel. + * + * To be used for the new buffering-proxy detection algorithm. + * + * @constructor + * @struct + */ +function ConnectionState() { + /** + * Handshake result. + * @type {?Array} + */ + this.handshakeResult = null; + + /** + * The result of checking if there is a buffering proxy in the network. + * True means the connection is buffered, False means unbuffered, + * null means that the result is not available. + * @type {?boolean} + */ + this.bufferingProxyResult = null; +} + +// MOE:begin_strip +// Ensure ES2021 inputs. go/transpile-js +null?.(6_6); +// MOE:end_strip + +exports = ConnectionState; diff --git a/webchannel/js/imported_src/environment.js b/webchannel/js/imported_src/environment.js new file mode 100644 index 0000000..02db3af --- /dev/null +++ b/webchannel/js/imported_src/environment.js @@ -0,0 +1,162 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A single module to define user-agent specific environment + * details. + * + */ + +goog.module('goog.labs.net.webChannel.environment'); +goog.module.declareLegacyNamespace(); + +/** + * Origin trial token for google.com + * + * https://developers.chrome.com/origintrials/#/trials + * + * http://googlechrome.github.io/OriginTrials/check-token.html + * Origin: https://google.com:443 + * Matches Subdomains? Yes + * Matches Third-party? Yes + * Feature: FetchUploadStreaming + * Up to Chrome 95 (ends with the rollout of next Chrome release), no later + * than Nov 9, 2021 + * + * Token for googleapis.com will be registered after google.com's is deployed. + * + */ +const OT_TOKEN_GOOGLE_COM = + 'A0eNbltY1nd4MP7XTHXnTxWogDL6mWTdgIIKfKOTJoUHNbFFMZQBoiHHjJ9UK9lgYndWFaxOWR7ld8uUjcWmcwIAAAB/eyJvcmlnaW4iOiJodHRwczovL2dvb2dsZS5jb206NDQzIiwiZmVhdHVyZSI6IkZldGNoVXBsb2FkU3RyZWFtaW5nIiwiZXhwaXJ5IjoxNjM2NTAyMzk5LCJpc1N1YmRvbWFpbiI6dHJ1ZSwiaXNUaGlyZFBhcnR5Ijp0cnVlfQ=='; + + +/** + * Creates ReadableStream to upload + * @return {!ReadableStream} ReadableStream to upload + */ +function createStream() { + const encoder = new goog.global.TextEncoder(); + return new goog.global.ReadableStream({ + start: controller => { + for (const obj of ['test\r\n', 'test\r\n']) { + controller.enqueue(encoder.encode(obj)); + } + controller.close(); + } + }); +} + +/** + * Detect the user agent is chrome and its version is higher than M90. + * This code is hard-coded from goog.labs.userAgent.browser to avoid file size + * increasing. + * @return {boolean} Whether the above is true. + */ +function isChromeM90OrHigher() { + const userAgentStr = function() { + const navigator = goog.global.navigator; + if (navigator) { + const userAgent = navigator.userAgent; + if (userAgent) { + return userAgent; + } + } + return ''; + }(); + + const matchUserAgent = function(str) { + return userAgentStr.indexOf(str) != -1; + }; + + if (!matchUserAgent('Chrome') || matchUserAgent('Edg')) { + return false; + } + + const match = /Chrome\/(\d+)/.exec(userAgentStr); + const chromeVersion = parseInt(match[1], 10); + return chromeVersion >= 90; +} + +/** + * Detect the URL origin is *.google.com. + * @param {string} url The target URL. + * @return {boolean} Whether the above is true. + */ +function isUrlGoogle(url) { + const match = /\/\/([^\/]+)\//.exec(url); + if (!match) { + return false; + } + const origin = match[1]; + return origin.endsWith('google.com'); +} + +/** + * The flag to run the origin trials code only once. + */ +let isStartOriginTrialsCalled = false; + +/** + * For Fetch/upload OT, make three requests against the server endpoint. + * POST requests contain only dummy payload. + * + * https://developers.chrome.com/origintrials/#/view_trial/3524066708417413121 + * + * This function is expected to be called from background during the handshake. + * Exceptions will be logged by the caller. + * + * No stats or logs are collected on the client-side. To be disabled once the + * OT is expired. + * + * @param {string} path The base URL path for the requests + * @param {function(*)} logError A function to execute when exceptions are + * caught. + */ +exports.startOriginTrials = function(path, logError) { + if (isStartOriginTrialsCalled) { + return; + } + isStartOriginTrialsCalled = true; + // NE: may need check if path has already contains query params? + + // Accept only Chrome M90 or later due to service worker support. + if (!isChromeM90OrHigher()) { + return; + } + + // Accept only only google.com and subdoamins. + if (!isUrlGoogle(path)) { + return; + } + // Since 3P OT is not supported yet, we should check the current page matches + // the path (absolute one?) to disable this OT for cross-origin calls + if (!window || !window.document || !isUrlGoogle(window.document.URL)) { + return; + } + + // Enable origin trial by injecting OT tag + const tokenElement = + /** @type {! HTMLMetaElement} */ (document.createElement('meta')); + tokenElement.httpEquiv = 'origin-trial'; + tokenElement.content = OT_TOKEN_GOOGLE_COM; + // appendChild() synchronously enables OT. + document.head.appendChild(tokenElement); + + // Check if fetch upload stream is actually enabled. + // By the spec, Streaming request doesn't has the Content-Type header: + // https://fetch.spec.whatwg.org/#concept-bodyinit-extract + // If Chrome doesn't support Streaming, the body stream is converted to a + // string "[object ReadableStream]" for fallback then it has "Content-Type: + // text/plain;charset=UTF-8". + const supportsRequestStreams = !new Request('', { + body: new ReadableStream(), + method: 'POST', + }).headers.has('Content-Type'); + + if (supportsRequestStreams) { + logError('OriginTrial unexpected.'); + } +}; diff --git a/webchannel/js/imported_src/forwardchannelrequestpool.js b/webchannel/js/imported_src/forwardchannelrequestpool.js new file mode 100644 index 0000000..cb57025 --- /dev/null +++ b/webchannel/js/imported_src/forwardchannelrequestpool.js @@ -0,0 +1,323 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A pool of forward channel requests to enable real-time + * messaging from the client to server. + * + */ + +goog.module('goog.labs.net.webChannel.ForwardChannelRequestPool'); + +goog.module.declareLegacyNamespace(); + +const ChannelRequest = goog.require('goog.labs.net.webChannel.ChannelRequest'); +const Wire = goog.require('goog.labs.net.webChannel.Wire'); +const array = goog.require('goog.array'); +const googString = goog.require('goog.string'); + + +/** + * This class represents the state of all forward channel requests. + * + * @param {number=} opt_maxPoolSize The maximum pool size. + * + * @struct @constructor @final + */ +const ForwardChannelRequestPool = function(opt_maxPoolSize) { + /** + * The max pool size as configured. + * + * @private {number} + */ + this.maxPoolSizeConfigured_ = + opt_maxPoolSize || ForwardChannelRequestPool.MAX_POOL_SIZE_; + + /** + * The current size limit of the request pool. This limit is meant to be + * read-only after the channel is fully opened. + * + * If SPDY or HTTP2 is enabled, set it to the max pool size, which is also + * configurable. + * + * @private {number} + */ + this.maxSize_ = ForwardChannelRequestPool.isSpdyOrHttp2Enabled_() ? + this.maxPoolSizeConfigured_ : + 1; + + /** + * The container for all the pending request objects. + * + * @private {?Set} + */ + this.requestPool_ = null; + + if (this.maxSize_ > 1) { + this.requestPool_ = new Set(); + } + + /** + * The single request object when the pool size is limited to one. + * + * @private {?ChannelRequest} + */ + this.request_ = null; + + /** + * Saved pending messages when the pool is cancelled. + * + * @private {!Array} + */ + this.pendingMessages_ = []; +}; + + +/** + * The default size limit of the request pool. + * + * @private {number} + */ +ForwardChannelRequestPool.MAX_POOL_SIZE_ = 10; + + +/** + * @return {boolean} True if SPDY or HTTP2 is enabled. Uses chrome-specific APIs + * as a fallback and will always return false for other browsers where + * PerformanceNavigationTiming is not available. + * @private + */ +ForwardChannelRequestPool.isSpdyOrHttp2Enabled_ = function() { + if (goog.global.PerformanceNavigationTiming) { + const entrys = /** @type {!Array} */ ( + goog.global.performance.getEntriesByType('navigation')); + return entrys.length > 0 && + (entrys[0].nextHopProtocol == 'hq' || + entrys[0].nextHopProtocol == 'h2'); + } + return !!( + goog.global.chrome && goog.global.chrome.loadTimes && + goog.global.chrome.loadTimes() && + goog.global.chrome.loadTimes().wasFetchedViaSpdy); +}; + + +/** + * Once we know the client protocol (from the handshake), check if we need + * enable the request pool accordingly. This is more robust than using + * browser-internal APIs (specific to Chrome). + * + * @param {string} clientProtocol The client protocol + */ +ForwardChannelRequestPool.prototype.applyClientProtocol = function( + clientProtocol) { + if (this.requestPool_) { + return; + } + + if (googString.contains(clientProtocol, 'spdy') || + googString.contains(clientProtocol, 'quic') || + googString.contains(clientProtocol, 'h2')) { + this.maxSize_ = this.maxPoolSizeConfigured_; + this.requestPool_ = new Set(); + if (this.request_) { + this.addRequest(this.request_); + this.request_ = null; + } + } +}; + + +/** + * @return {boolean} True if the pool is full. + */ +ForwardChannelRequestPool.prototype.isFull = function() { + if (this.request_) { + return true; + } + + if (this.requestPool_) { + return this.requestPool_.size >= this.maxSize_; + } + + return false; +}; + + +/** + * @return {number} The current size limit. + */ +ForwardChannelRequestPool.prototype.getMaxSize = function() { + return this.maxSize_; +}; + + +/** + * @return {number} The number of pending requests in the pool. + */ +ForwardChannelRequestPool.prototype.getRequestCount = function() { + if (this.request_) { + return 1; + } + + if (this.requestPool_) { + return this.requestPool_.size; + } + + return 0; +}; + + +/** + * @param {ChannelRequest} req The channel request. + * @return {boolean} True if the request is a included inside the pool. + */ +ForwardChannelRequestPool.prototype.hasRequest = function(req) { + if (this.request_) { + return this.request_ == req; + } + + if (this.requestPool_) { + return this.requestPool_.has(req); + } + + return false; +}; + + +/** + * Adds a new request to the pool. + * + * @param {!ChannelRequest} req The new channel request. + */ +ForwardChannelRequestPool.prototype.addRequest = function(req) { + if (this.requestPool_) { + this.requestPool_.add(req); + } else { + this.request_ = req; + } +}; + + +/** + * Removes the given request from the pool. + * + * @param {ChannelRequest} req The channel request. + * @return {boolean} Whether the request has been removed from the pool. + */ +ForwardChannelRequestPool.prototype.removeRequest = function(req) { + if (this.request_ && this.request_ == req) { + this.request_ = null; + return true; + } + + if (this.requestPool_ && this.requestPool_.has(req)) { + this.requestPool_.delete(req); + return true; + } + + return false; +}; + + +/** + * Clears the pool and cancel all the pending requests. + */ +ForwardChannelRequestPool.prototype.cancel = function() { + // save any pending messages + this.pendingMessages_ = this.getPendingMessages(); + + if (this.request_) { + this.request_.cancel(); + this.request_ = null; + return; + } + + if (this.requestPool_ && this.requestPool_.size !== 0) { + for (const val of this.requestPool_.values()) { + val.cancel(); + } + this.requestPool_.clear(); + } +}; + + +/** + * @return {boolean} Whether there are any pending requests. + */ +ForwardChannelRequestPool.prototype.hasPendingRequest = function() { + return (this.request_ != null) || + (this.requestPool_ != null && this.requestPool_.size !== 0); +}; + + +/** + * @return {!Array} All the pending messages from the pool, + * as a new array. + */ +ForwardChannelRequestPool.prototype.getPendingMessages = function() { + if (this.request_ != null) { + return this.pendingMessages_.concat(this.request_.getPendingMessages()); + } + + if (this.requestPool_ != null && this.requestPool_.size !== 0) { + let result = this.pendingMessages_; + for (const val of this.requestPool_.values()) { + result = result.concat(val.getPendingMessages()); + } + return result; + } + + return array.clone(this.pendingMessages_); +}; + + +/** + * Records pending messages, e.g. when a request receives a failed response. + * + * @param {!Array} messages Pending messages. + */ +ForwardChannelRequestPool.prototype.addPendingMessages = function(messages) { + this.pendingMessages_ = this.pendingMessages_.concat(messages); +}; + + +/** + * Clears any recorded pending messages. + */ +ForwardChannelRequestPool.prototype.clearPendingMessages = function() { + this.pendingMessages_.length = 0; +}; + + +/** + * Cancels all pending requests and force the completion of channel requests. + * + * Need go through the standard onRequestComplete logic to expose the max-retry + * failure in the standard way. + * + * @param {function(!ChannelRequest)} onComplete The completion callback. + * @return {boolean} true if any request has been forced to complete. + */ +ForwardChannelRequestPool.prototype.forceComplete = function(onComplete) { + if (this.request_ != null) { + this.request_.cancel(); + onComplete(this.request_); + return true; + } + + if (this.requestPool_ && this.requestPool_.size !== 0) { + for (const val of this.requestPool_.values()) { + val.cancel(); + onComplete(val); + } + return true; + } + + return false; +}; + +exports = ForwardChannelRequestPool; diff --git a/webchannel/js/imported_src/netutils.js b/webchannel/js/imported_src/netutils.js new file mode 100644 index 0000000..e542d63 --- /dev/null +++ b/webchannel/js/imported_src/netutils.js @@ -0,0 +1,159 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Utility functions for managing networking, such as + * testing network connectivity. + * + */ + +goog.module('goog.labs.net.webChannel.netUtils'); +goog.module.declareLegacyNamespace(); + +const Uri = goog.require('goog.Uri'); +const WebChannelDebug = goog.require('goog.labs.net.webChannel.WebChannelDebug'); + +/** + * Default timeout to allow for URI pings. + * @type {number} + */ +const NETWORK_TIMEOUT = 10000; + +/** + * Pings the network to check if an error is a server error or user's network + * error. + * + * @param {function(boolean)} callback The function to call back with results. + * @param {string=} opt_baseUrl The base URI to use for the network test. + */ +function testNetwork(callback, opt_baseUrl) { + // default google.com image + let baseUrl = opt_baseUrl || '//www.google.com/images/cleardot.gif'; + const useImageLoader = !opt_baseUrl; + + let uri = new Uri(baseUrl); + + if (!(goog.global.location && goog.global.location.protocol == 'http')) { + uri.setScheme('https'); // e.g. chrome-extension + } + uri.makeUnique(); + + if (useImageLoader) { + testLoadImage(uri.toString(), NETWORK_TIMEOUT, callback); + } else { + testPingServer(uri.toString(), NETWORK_TIMEOUT, callback); + } +} + +/** + * Test loading the given image. + * @param {string} url URL to the image. + * @param {number} timeout Milliseconds before giving up. + * @param {function(boolean)} callback Function to call with results. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +function testLoadImage(url, timeout, callback) { + const channelDebug = new WebChannelDebug(); + channelDebug.debug('TestLoadImage: loading ' + url); + if (goog.global.Image) { + const img = new Image(); + img.onload = goog.partial( + networkTestCallback, channelDebug, 'TestLoadImage: loaded', true, + callback, img); + img.onerror = goog.partial( + networkTestCallback, channelDebug, 'TestLoadImage: error', false, + callback, img); + img.onabort = goog.partial( + networkTestCallback, channelDebug, 'TestLoadImage: abort', false, + callback, img); + img.ontimeout = goog.partial( + networkTestCallback, channelDebug, 'TestLoadImage: timeout', false, + callback, img); + + goog.global.setTimeout(function() { + if (img.ontimeout) { + img.ontimeout(); + } + }, timeout); + img.src = url; + } else { + // log ERROR_OTHER from environements where Image is not supported + callback(false); + } +} + +/** + * Pings the given server URL to test network availability. + * @param {string} url URL to the server endpoint. + * @param {number} timeout Milliseconds before giving up. + * @param {function(boolean)} callback Function to call with results. + */ +function testPingServer(url, timeout, callback) { + const channelDebug = new WebChannelDebug(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + networkTestCallback( + channelDebug, 'TestPingServer: timeout', false, callback); + }, timeout); + + fetch(url, {signal: controller.signal}) + .then((response) => { + clearTimeout(timeoutId); + if (response.ok) { + networkTestCallback( + channelDebug, 'TestPingServer: ok', true, callback); + } else { + networkTestCallback( + channelDebug, 'TestPingServer: server error', false, callback); + } + }) + .catch((error) => { + clearTimeout(timeoutId); + networkTestCallback( + channelDebug, 'TestPingServer: error', false, callback); + }); +} + +/** + * Wraps the network test callback with debug and cleanup logic. + * @param {!WebChannelDebug} channelDebug The WebChannelDebug object. + * @param {string} debugText The debug text. + * @param {boolean} result The result of image loading. + * @param {function(boolean)} callback Function to call with results. + * @param {!Image=} opt_img The image element. + */ +function networkTestCallback( + channelDebug, debugText, result, callback, opt_img) { + try { + channelDebug.debug(debugText); + if (opt_img) { + clearImageCallbacks(opt_img); + } + callback(result); + } catch (e) { + channelDebug.dumpException(e); + } +} + +/** + * Clears handlers to avoid memory leaks. + * @param {Image} img The image to clear handlers from. + * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration + */ +function clearImageCallbacks(img) { + img.onload = null; + img.onerror = null; + img.onabort = null; + img.ontimeout = null; +} + +exports = { + NETWORK_TIMEOUT, + testLoadImage, + testNetwork, + testPingServer, +}; diff --git a/webchannel/js/imported_src/requeststats.js b/webchannel/js/imported_src/requeststats.js new file mode 100644 index 0000000..fcb89bc --- /dev/null +++ b/webchannel/js/imported_src/requeststats.js @@ -0,0 +1,351 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Static utilities for collecting stats associated with + * ChannelRequest. + * + */ +goog.module('goog.labs.net.webChannel.requestStats'); +goog.module.declareLegacyNamespace(); + +const GoogEvent = goog.require('goog.events.Event'); +const GoogEventTarget = goog.require('goog.events.EventTarget'); + +/** + * Events fired. + * @const + */ +const Event = {}; + +/** + * Singleton event target for firing stat events + * @type {?GoogEventTarget} + */ +let eventTargetInternal = null; + +/** + * Singleton event target for firing stat events + * @return {!GoogEventTarget} + */ +function getStatEventTargetInternal() { + eventTargetInternal = eventTargetInternal || new GoogEventTarget(); + return eventTargetInternal; +} + +/** + * The type of event that occurs every time some information about how reachable + * the server is is discovered. + */ +Event.SERVER_REACHABILITY_EVENT = 'serverreachability'; + +/** + * Types of events which reveal information about the reachability of the + * server. + * @enum {number} + */ +const ServerReachability = { + REQUEST_MADE: 1, + REQUEST_SUCCEEDED: 2, + REQUEST_FAILED: 3, + BACK_CHANNEL_ACTIVITY: 4 // any response data received +}; + +/** + * Event class for SERVER_REACHABILITY_EVENT. + * + * @param {GoogEventTarget} target The stat event target for + the channel. + * @param {ServerReachability} reachabilityType + * The reachability event type. + * @constructor + * @extends {GoogEvent} + */ +function ServerReachabilityEvent(target, reachabilityType) { + GoogEvent.call(this, Event.SERVER_REACHABILITY_EVENT, target); + + /** + * @type {ServerReachability} + */ + this.reachabilityType = reachabilityType; +} +goog.inherits(ServerReachabilityEvent, GoogEvent); + +/** + * Notify the channel that a particular fine grained network event has occurred. + * Should be considered package-private. + * @param {ServerReachability} reachabilityType + * The reachability event type. + */ +function notifyServerReachabilityEvent(reachabilityType) { + const target = getStatEventTargetInternal(); + target.dispatchEvent(new ServerReachabilityEvent(target, reachabilityType)); +} + +/** + * Stat Event that fires when things of interest happen that may be useful for + * applications to know about for stats or debugging purposes. + */ +Event.STAT_EVENT = 'statevent'; + +/** + * Enum that identifies events for statistics that are interesting to track. + * @enum {number} + */ +const Stat = { + /** Event indicating a new connection attempt. */ + CONNECT_ATTEMPT: 0, + + /** Event indicating a connection error due to a general network problem. */ + ERROR_NETWORK: 1, + + /** + * Event indicating a connection error that isn't due to a general network + * problem. + */ + ERROR_OTHER: 2, + + /** Event indicating the start of test stage one. */ + TEST_STAGE_ONE_START: 3, + + /** Event indicating the start of test stage two. */ + TEST_STAGE_TWO_START: 4, + + /** Event indicating the first piece of test data was received. */ + TEST_STAGE_TWO_DATA_ONE: 5, + + /** + * Event indicating that the second piece of test data was received and it was + * received separately from the first. + */ + TEST_STAGE_TWO_DATA_TWO: 6, + + /** Event indicating both pieces of test data were received simultaneously. */ + TEST_STAGE_TWO_DATA_BOTH: 7, + + /** Event indicating stage one of the test request failed. */ + TEST_STAGE_ONE_FAILED: 8, + + /** Event indicating stage two of the test request failed. */ + TEST_STAGE_TWO_FAILED: 9, + + /** + * Event indicating that a buffering proxy is likely between the client and + * the server. + */ + PROXY: 10, + + /** + * Event indicating that no buffering proxy is likely between the client and + * the server. + */ + NOPROXY: 11, + + /** Event indicating an unknown SID error. */ + REQUEST_UNKNOWN_SESSION_ID: 12, + + /** Event indicating a bad status code was received. */ + REQUEST_BAD_STATUS: 13, + + /** Event indicating incomplete data was received */ + REQUEST_INCOMPLETE_DATA: 14, + + /** Event indicating bad data was received */ + REQUEST_BAD_DATA: 15, + + /** Event indicating no data was received when data was expected. */ + REQUEST_NO_DATA: 16, + + /** Event indicating a request timeout. */ + REQUEST_TIMEOUT: 17, + + /** + * Event indicating that the server never received our hanging GET and so it + * is being retried. + */ + BACKCHANNEL_MISSING: 18, + + /** + * Event indicating that we have determined that our hanging GET is not + * receiving data when it should be. Thus it is dead dead and will be retried. + */ + BACKCHANNEL_DEAD: 19, + + /** + * The browser declared itself offline during the lifetime of a request, or + * was offline when a request was initially made. + */ + BROWSER_OFFLINE: 20 +}; + +/** + * Event class for STAT_EVENT. + * + * @param {GoogEventTarget} eventTarget The stat event target for + the channel. + * @param {Stat} stat The stat. + * @constructor + * @extends {GoogEvent} + */ +function StatEvent(eventTarget, stat) { + GoogEvent.call(this, Event.STAT_EVENT, eventTarget); + + /** + * The stat + * @type {Stat} + */ + this.stat = stat; +} +goog.inherits(StatEvent, GoogEvent); + +/** + * Returns the singleton event target for stat events. + * @return {!GoogEventTarget} The event target for stat events. + */ +function getStatEventTarget() { + return getStatEventTargetInternal(); +} + +/** + * Helper function to call the stat event callback. + * @param {Stat} stat The stat. + */ +function notifyStatEvent(stat) { + const target = getStatEventTargetInternal(); + target.dispatchEvent(new StatEvent(target, stat)); +} + +/** + * An event that fires when POST requests complete successfully, indicating + * the size of the POST and the round trip time. + */ +Event.TIMING_EVENT = 'timingevent'; + +/** + * Event class for Event.TIMING_EVENT + * + * @param {GoogEventTarget} target The stat event target for + the channel. + * @param {number} size The number of characters in the POST data. + * @param {number} rtt The total round trip time from POST to response in MS. + * @param {number} retries The number of times the POST had to be retried. + * @constructor + * @extends {GoogEvent} + */ +function TimingEvent(target, size, rtt, retries) { + GoogEvent.call(this, Event.TIMING_EVENT, target); + + /** + * @type {number} + */ + this.size = size; + + /** + * @type {number} + */ + this.rtt = rtt; + + /** + * @type {number} + */ + this.retries = retries; +} +goog.inherits(TimingEvent, GoogEvent); + +/** + * Helper function to notify listeners about POST request performance. + * + * @param {number} size Number of characters in the POST data. + * @param {number} rtt The amount of time from POST start to response. + * @param {number} retries The number of times the POST had to be retried. + */ +function notifyTimingEvent(size, rtt, retries) { + const target = getStatEventTargetInternal(); + target.dispatchEvent(new TimingEvent(target, size, rtt, retries)); +} + +/** + * Allows the application to set an execution hooks for when a channel + * starts processing requests. This is useful to track timing or logging + * special information. The function takes no parameters and return void. + * @param {Function} startHook The function for the start hook. + */ +function setStartThreadExecutionHook(startHook) { + startExecutionHook = startHook; +} + +/** + * Allows the application to set an execution hooks for when a channel + * stops processing requests. This is useful to track timing or logging + * special information. The function takes no parameters and return void. + * @param {Function} endHook The function for the end hook. + */ +function setEndThreadExecutionHook(endHook) { + endExecutionHook = endHook; +} + +/** + * Application provided execution hook for the start hook. + * @type {Function} + */ +let startExecutionHook = function() {}; + +/** + * Application provided execution hook for the end hook. + * @type {Function} + */ +let endExecutionHook = function() {}; + +/** + * Helper function to call the start hook + */ +function onStartExecution() { + startExecutionHook(); +} + +/** + * Helper function to call the end hook + */ +function onEndExecution() { + endExecutionHook(); +} + +/** + * Wrapper around SafeTimeout which calls the start and end execution hooks + * with a try...finally block. + * @param {Function} fn The callback function. + * @param {number} ms The time in MS for the timer. + * @return {number} The ID of the timer. + */ +function setTimeout(fn, ms) { + if (typeof fn !== 'function') { + throw new Error('Fn must not be null and must be a function'); + } + return goog.global.setTimeout(function() { + onStartExecution(); + try { + fn(); + } finally { + onEndExecution(); + } + }, ms); +} + +exports.Event = Event; +exports.ServerReachability = ServerReachability; +exports.ServerReachabilityEvent = ServerReachabilityEvent; +exports.Stat = Stat; +exports.StatEvent = StatEvent; +exports.TimingEvent = TimingEvent; +exports.getStatEventTarget = getStatEventTarget; +exports.notifyServerReachabilityEvent = notifyServerReachabilityEvent; +exports.notifyStatEvent = notifyStatEvent; +exports.notifyTimingEvent = notifyTimingEvent; +exports.onEndExecution = onEndExecution; +exports.onStartExecution = onStartExecution; +exports.setEndThreadExecutionHook = setEndThreadExecutionHook; +exports.setStartThreadExecutionHook = setStartThreadExecutionHook; +exports.setTimeout = setTimeout; diff --git a/webchannel/js/imported_src/testing/fakewebchannel.js b/webchannel/js/imported_src/testing/fakewebchannel.js new file mode 100644 index 0000000..30bca0b --- /dev/null +++ b/webchannel/js/imported_src/testing/fakewebchannel.js @@ -0,0 +1,95 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Implementation of `goog.net.WebChannel` for use in tests. */ + +goog.module('goog.labs.net.webChannel.testing.FakeWebChannel'); +goog.setTestOnly(); + +const EventTarget = goog.require('goog.events.EventTarget'); +const WebChannel = goog.requireType('goog.net.WebChannel'); +const {clear} = goog.require('goog.array'); +const {fail} = goog.require('goog.testing.asserts'); + +/** + * A fake web channel that captures all "sent" messages to memory, for testing. + * @implements {WebChannel} + * @final + */ +class FakeWebChannel extends EventTarget { + constructor() { + super(); + + /** @private {?boolean} */ + this.open_ = null; + + /** @private @const {!Array} */ + this.messages_ = []; + } + + /** @override */ + open() { + this.open_ = true; + } + + /** + * @param {!WebChannel.MessageData} messageData + * @override + */ + send(messageData) { + this.messages_.push(messageData); + } + + /** @override */ + halfClose() { + fail('Should not be called: not implemented by library'); + } + + /** @override */ + close() { + this.open_ = false; + } + + /** + * @return {!WebChannel.RuntimeProperties} + * @override + */ + getRuntimeProperties() { + return /** @type {!WebChannel.RuntimeProperties} */ ({}); + } + + /** + * @return {?boolean} whether the channel is open, or `null` if the state has + * never changed since initialization. + */ + isOpen() { + return this.open_; + } + + /** Clears the record of sent messages. */ + clearSentMessages() { + clear(this.messages_); + } + + /** + * @return {!Array} the record of sent messages, in + * order of when they were sent. + */ + getSentMessages() { + return this.messages_; + } + + /** + * @return {!WebChannel.MessageData} the only sent message thus far, if one + * and only one such message exists. + */ + getOnlySentMessage() { + assertEquals(1, this.messages_.length); + return this.messages_[0]; + } +} + +exports = {FakeWebChannel}; diff --git a/webchannel/js/imported_src/tests/channelrequest_test.js b/webchannel/js/imported_src/tests/channelrequest_test.js new file mode 100644 index 0000000..22fbb60 --- /dev/null +++ b/webchannel/js/imported_src/tests/channelrequest_test.js @@ -0,0 +1,264 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @fileoverview Unit tests for ChannelRequest. */ + +goog.module('goog.labs.net.webChannel.channelRequestTest'); +goog.setTestOnly(); + +const ChannelRequest = goog.require('goog.labs.net.webChannel.ChannelRequest'); +const MockClock = goog.require('goog.testing.MockClock'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const Uri = goog.require('goog.Uri'); +const WebChannelDebug = goog.require('goog.labs.net.webChannel.WebChannelDebug'); +const XhrIo = goog.require('goog.testing.net.XhrIo'); +const functions = goog.require('goog.functions'); +const recordFunction = goog.require('goog.testing.recordFunction'); +const requestStats = goog.require('goog.labs.net.webChannel.requestStats'); + +let channelRequest; +let mockChannel; +let mockClock; +let stubs; +let xhrIo; +let reachabilityEvents; + +/** Time to wait for a network request to time out, before aborting. */ +const WATCHDOG_TIME = 2000; + +/** Time to throttle readystatechange events. */ +const THROTTLE_TIME = 500; + +/** A really long time - used to make sure no more timeouts will fire. */ +const ALL_DAY_MS = 1000 * 60 * 60 * 24; + +function shouldRunTests() { + return ChannelRequest.supportsXhrStreaming(); +} + +/** + * Constructs a duck-type WebChannelBase that tracks the completed requests. + * @final + */ +class MockWebChannelBase { + constructor() { + this.isClosed = () => false; + this.isActive = () => true; + this.usesFetchStreams = () => false; + this.shouldUseSecondaryDomains = () => false; + this.completedRequests = []; + this.onRequestComplete = function(request) { + this.completedRequests.push(request); + }; + this.onRequestData = (request, data) => {}; + } +} + +/** + * Creates a real ChannelRequest object, with some modifications for + * testability: + *
    + *
  • The channel is a mock channel. + *
  • The new watchdogTimeoutCallCount property tracks onWatchDogTimeout() + * calls. + *
  • The timeout is set to WATCHDOG_TIME. + *
+ */ +function createChannelRequest() { + xhrIo = new XhrIo(); + xhrIo.abort = xhrIo.abort || function() { this.active_ = false; }; + + // Install mock channel and no-op debug logger. + mockChannel = new MockWebChannelBase(); + /** @suppress {checkTypes} suppression added to enable type checking */ + channelRequest = new ChannelRequest(mockChannel, new WebChannelDebug()); + + // Install test XhrIo. + /** @suppress {checkTypes} suppression added to enable type checking */ + mockChannel.createXhrIo = () => xhrIo; + + // Install watchdogTimeoutCallCount. + /** @suppress {checkTypes} suppression added to enable type checking */ + channelRequest.watchdogTimeoutCallCount = 0; + /** + * @suppress {checkTypes,visibility} suppression added to enable type + * checking + */ + channelRequest.originalOnWatchDogTimeout = channelRequest.onWatchDogTimeout_; + /** + * @suppress {visibility,checkTypes,missingProperties} suppression added to + * enable type checking + */ + channelRequest.onWatchDogTimeout_ = function() { + channelRequest.watchdogTimeoutCallCount++; + return channelRequest.originalOnWatchDogTimeout(); + }; + + channelRequest.setTimeout(WATCHDOG_TIME); +} + +function checkReachabilityEvents(reqMade, reqSucceeded, reqFail, backChannel) { + expect(reachabilityEvents[requestStats.ServerReachability.REQUEST_MADE] || 0) + .toBe(reqMade); + expect( + reachabilityEvents[requestStats.ServerReachability.REQUEST_SUCCEEDED] || + 0) + .toBe(reqSucceeded); + expect( + reachabilityEvents[requestStats.ServerReachability.REQUEST_FAILED] || 0) + .toBe(reqFail); + expect( + reachabilityEvents[requestStats.ServerReachability + .BACK_CHANNEL_ACTIVITY] || + 0) + .toBe(backChannel); +} + +describe('goog.labs.net.webChannel.channelRequestTest', () => { + beforeEach(() => { + mockClock = new MockClock(); + mockClock.install(); + reachabilityEvents = {}; + stubs = new PropertyReplacer(); + + // Mock out the stat notification code. + const notifyServerReachabilityEvent = (reachabilityType) => { + if (!reachabilityEvents[reachabilityType]) { + reachabilityEvents[reachabilityType] = 0; + } + reachabilityEvents[reachabilityType]++; + }; + stubs.set( + requestStats, 'notifyServerReachabilityEvent', + notifyServerReachabilityEvent); + }); + + afterEach(() => { + stubs.reset(); + mockClock.uninstall(); + }); + + /** + * Run through the lifecycle of a long lived request, checking that the right + * network events are reported. + */ + it('network events', () => { + createChannelRequest(); + + channelRequest.xmlHttpPost(new Uri('some_uri'), 'some_postdata', true); + checkReachabilityEvents(1, 0, 0, 0); + if (ChannelRequest.supportsXhrStreaming()) { + xhrIo.simulatePartialResponse('17\nI am a BC Message'); + checkReachabilityEvents(1, 0, 0, 1); + xhrIo.simulatePartialResponse('23\nI am another BC Message'); + checkReachabilityEvents(1, 0, 0, 2); + xhrIo.simulateResponse(200, '16Final BC Message'); + checkReachabilityEvents(1, 1, 0, 2); + } else { + xhrIo.simulateResponse(200, '16Final BC Message'); + checkReachabilityEvents(1, 1, 0, 0); + } + }); + + /** Test throttling of readystatechange events. */ + it('network events, throttle ready state change', () => { + createChannelRequest(); + channelRequest.setReadyStateChangeThrottle(THROTTLE_TIME); + + /** @suppress {visibility} suppression added to enable type checking */ + const recordedHandler = recordFunction(channelRequest.xmlHttpHandler_); + stubs.set(channelRequest, 'xmlHttpHandler_', recordedHandler); + + channelRequest.xmlHttpPost(new Uri('some_uri'), 'some_postdata', true); + expect(recordedHandler.getCallCount()).toBe(1); + + checkReachabilityEvents(1, 0, 0, 0); + if (ChannelRequest.supportsXhrStreaming()) { + xhrIo.simulatePartialResponse('17\nI am a BC Message'); + checkReachabilityEvents(1, 0, 0, 1); + expect(recordedHandler.getCallCount()).toBe(3); + + // Second event should be throttled + xhrIo.simulatePartialResponse('23\nI am another BC Message'); + expect(recordedHandler.getCallCount()).toBe(3); + + xhrIo.simulatePartialResponse('27\nI am yet another BC Message'); + expect(recordedHandler.getCallCount()).toBe(3); + mockClock.tick(THROTTLE_TIME); + + checkReachabilityEvents(1, 0, 0, 3); + // Only one more call because of throttling. + expect(recordedHandler.getCallCount()).toBe(4); + + xhrIo.simulateResponse(200, '16Final BC Message'); + checkReachabilityEvents(1, 1, 0, 3); + expect(recordedHandler.getCallCount()).toBe(5); + } else { + xhrIo.simulateResponse(200, '16Final BC Message'); + checkReachabilityEvents(1, 1, 0, 0); + } + }); + + /** + * Make sure that the request "completes" with an error when the timeout + * expires. + * @suppress {missingProperties,visibility} suppression added to enable type + * checking + */ + it('request timeout', () => { + createChannelRequest(); + + channelRequest.xmlHttpPost(new Uri('some_uri'), 'some_postdata', true); + expect(channelRequest.watchdogTimeoutCallCount).toBe(0); + expect(channelRequest.channel_.completedRequests.length).toBe(0); + + // Watchdog timeout. + mockClock.tick(WATCHDOG_TIME); + expect(channelRequest.watchdogTimeoutCallCount).toBe(1); + expect(channelRequest.channel_.completedRequests.length).toBe(1); + expect(channelRequest.getSuccess()).toBe(false); + + // Make sure no more timers are firing. + mockClock.tick(ALL_DAY_MS); + expect(channelRequest.watchdogTimeoutCallCount).toBe(1); + expect(channelRequest.channel_.completedRequests.length).toBe(1); + + checkReachabilityEvents(1, 0, 1, 0); + }); + + /** + @suppress {missingProperties,visibility} suppression added to enable type + checking + */ + it('request timeout with unexpected exception', () => { + createChannelRequest(); + /** @suppress {visibility} suppression added to enable type checking */ + channelRequest.channel_.createXhrIo = functions.error('Weird error'); + + try { + channelRequest.xmlHttpGet(new Uri('some_uri'), true, null); + fail('Expected error'); + } catch (e) { + expect(e.message).toBe('Weird error'); + } + + expect(channelRequest.watchdogTimeoutCallCount).toBe(0); + expect(channelRequest.channel_.completedRequests.length).toBe(0); + + // Watchdog timeout. + mockClock.tick(WATCHDOG_TIME); + expect(channelRequest.watchdogTimeoutCallCount).toBe(1); + expect(channelRequest.channel_.completedRequests.length).toBe(1); + expect(channelRequest.getSuccess()).toBe(false); + + // Make sure no more timers are firing. + mockClock.tick(ALL_DAY_MS); + expect(channelRequest.watchdogTimeoutCallCount).toBe(1); + expect(channelRequest.channel_.completedRequests.length).toBe(1); + + checkReachabilityEvents(0, 0, 1, 0); + }); +}); diff --git a/webchannel/js/imported_src/tests/channelrequest_test_dom.html b/webchannel/js/imported_src/tests/channelrequest_test_dom.html new file mode 100644 index 0000000..ab88c2f --- /dev/null +++ b/webchannel/js/imported_src/tests/channelrequest_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/webchannel/js/imported_src/tests/forwardchannelrequestpool_test.js b/webchannel/js/imported_src/tests/forwardchannelrequestpool_test.js new file mode 100644 index 0000000..78adff6 --- /dev/null +++ b/webchannel/js/imported_src/tests/forwardchannelrequestpool_test.js @@ -0,0 +1,254 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for ForwardChannelRequestPool. + * @suppress {accessControls} Private methods are accessed for test purposes. + */ + +goog.module('goog.labs.net.webChannel.ForwardChannelRequestPoolTest'); +goog.setTestOnly('goog.labs.net.webChannel.ForwardChannelRequestPoolTest'); + +const ChannelRequest = goog.require('goog.labs.net.webChannel.ChannelRequest'); +const ForwardChannelRequestPool = goog.require('goog.labs.net.webChannel.ForwardChannelRequestPool'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); + +const propertyReplacer = new PropertyReplacer(); +const req = new ChannelRequest(null, null); + +describe('goog.labs.net.webChannel.ForwardChannelRequestPoolTest', () => { + afterEach(() => { + propertyReplacer.reset(); + }); + + it('spdy enabled', () => { + stubSpdyCheck(true); + + const pool = new ForwardChannelRequestPool(); + expect(pool.isFull()).toBe(false); + expect(pool.getRequestCount()).toBe(0); + pool.addRequest(req); + expect(pool.hasPendingRequest()).toBe(true); + expect(pool.hasRequest(req)).toBe(true); + pool.removeRequest(req); + expect(pool.hasPendingRequest()).toBe(false); + + for (let i = 0; i < pool.getMaxSize(); i++) { + pool.addRequest(new ChannelRequest(null, null)); + } + expect(pool.isFull()).toBe(true); + + // do not fail + pool.addRequest(req); + expect(pool.isFull()).toBe(true); + }); + + it('spdy not enabled', () => { + stubSpdyCheck(false); + + const pool = new ForwardChannelRequestPool(); + expect(pool.isFull()).toBe(false); + expect(pool.getRequestCount()).toBe(0); + pool.addRequest(req); + expect(pool.hasPendingRequest()).toBe(true); + expect(pool.hasRequest(req)).toBe(true); + expect(pool.isFull()).toBe(true); + pool.removeRequest(req); + expect(pool.hasPendingRequest()).toBe(false); + + // do not fail + pool.addRequest(req); + expect(pool.isFull()).toBe(true); + }); + + it('apply client protocol', () => { + stubSpdyCheck(false); + + let pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize()).toBe(1); + pool.applyClientProtocol('spdy/3'); + expect(pool.getMaxSize() > 1).toBe(true); + pool.applyClientProtocol('foo-bar'); // no effect + expect(pool.getMaxSize() > 1).toBe(true); + + pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize()).toBe(1); + pool.applyClientProtocol('quic/x'); + expect(pool.getMaxSize() > 1).toBe(true); + + pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize()).toBe(1); + pool.applyClientProtocol('h2'); + expect(pool.getMaxSize() > 1).toBe(true); + + stubSpdyCheck(true); + + pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize() > 1).toBe(true); + pool.applyClientProtocol('foo/3'); // no effect + expect(pool.getMaxSize() > 1).toBe(true); + }); + + it('pending messages with spdy disabled', () => { + stubSpdyCheck(false); + + const pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize()).toBe(1); + expect(pool.getPendingMessages().length).toBe(0); + + let req = new ChannelRequest(null, null); + pool.addRequest(req); + + expect(pool.getPendingMessages().length).toBe(0); + + req.setPendingMessages([null, null]); // null represents the message + expect(pool.getPendingMessages().length).toBe(2); + + req = new ChannelRequest(null, null); + req.setPendingMessages([null]); + pool.addRequest(req); + expect(pool.getPendingMessages().length).toBe(1); + + pool.removeRequest(req); + expect(pool.getPendingMessages().length).toBe(0); + }); + + it('canel and pending messages with spdy disabled', () => { + stubSpdyCheck(false); + + const pool = new ForwardChannelRequestPool(); + + const req = new ChannelRequest(null, null); + req.setPendingMessages([null, null]); // null represents the + // message + pool.addRequest(req); + expect(pool.getPendingMessages().length).toBe(2); + + const req1 = new ChannelRequest(null, null); + pool.addRequest(req1); + req1.setPendingMessages([null]); + expect(pool.getPendingMessages().length).toBe(1); + + pool.cancel(); + expect(pool.getRequestCount()).toBe(0); + + expect(pool.getPendingMessages().length).toBe(1); + }); + + it('add pending messages with spdy enabled', () => { + stubSpdyCheck(false); + + const pool = new ForwardChannelRequestPool(); + + pool.addPendingMessages([null, null]); + expect(pool.getPendingMessages().length).toBe(2); + + const req = new ChannelRequest(null, null); + req.setPendingMessages([null, null]); // null represents the + // message + pool.addRequest(req); + + expect(pool.getPendingMessages().length).toBe(4); + + pool.addPendingMessages([null]); + expect(pool.getPendingMessages().length).toBe(5); + }); + + it('pending messages with spdy enabled', () => { + stubSpdyCheck(true); + + const pool = new ForwardChannelRequestPool(); + expect(pool.getMaxSize() > 1).toBe(true); + expect(pool.getPendingMessages().length).toBe(0); + + const req = new ChannelRequest(null, null); + pool.addRequest(req); + + expect(pool.getPendingMessages().length).toBe(0); + + req.setPendingMessages([null, null]); // null represents the message + expect(pool.getPendingMessages().length).toBe(2); + + const req1 = new ChannelRequest(null, null); + pool.addRequest(req1); + expect(pool.getPendingMessages().length).toBe(2); + req1.setPendingMessages([null]); + expect(pool.getPendingMessages().length).toBe(3); + + pool.removeRequest(req1); + expect(pool.getPendingMessages().length).toBe(2); + + pool.removeRequest(req); + expect(pool.getPendingMessages().length).toBe(0); + }); + + it('canel and pending messages with spdy enabled', () => { + stubSpdyCheck(true); + + const pool = new ForwardChannelRequestPool(); + + const req = new ChannelRequest(null, null); + req.setPendingMessages([null, null]); // null represents the + // message + pool.addRequest(req); + + const req1 = new ChannelRequest(null, null); + pool.addRequest(req1); + req1.setPendingMessages([null]); + + expect(pool.getPendingMessages().length).toBe(3); + + pool.cancel(); + expect(pool.getRequestCount()).toBe(0); + + expect(pool.getPendingMessages().length).toBe(3); + }); + + it('add and set pending messages with spdy enabled', () => { + stubSpdyCheck(true); + + const pool = new ForwardChannelRequestPool(); + + pool.addPendingMessages([null, null]); + expect(pool.getPendingMessages().length).toBe(2); + + const req = new ChannelRequest(null, null); + req.setPendingMessages([null, null]); // null represents the + // message + pool.addRequest(req); + + const req1 = new ChannelRequest(null, null); + pool.addRequest(req1); + req1.setPendingMessages([null]); + + expect(pool.getPendingMessages().length).toBe(5); + + pool.addPendingMessages([null, null]); + expect(pool.getPendingMessages().length).toBe(7); + }); + + it('clear pending messages', () => { + stubSpdyCheck(true); + + const pool = new ForwardChannelRequestPool(); + + pool.addPendingMessages([null, null]); + expect(pool.getPendingMessages().length).toBe(2); + + pool.clearPendingMessages(); + expect(pool.getPendingMessages().length).toBe(0); + }); +}); + +/** + * @param {boolean} spdyEnabled + */ +function stubSpdyCheck(spdyEnabled) { + propertyReplacer.set( + ForwardChannelRequestPool, 'isSpdyOrHttp2Enabled_', function() { + return spdyEnabled; + }); +} diff --git a/webchannel/js/imported_src/tests/forwardchannelrequestpool_test_dom.html b/webchannel/js/imported_src/tests/forwardchannelrequestpool_test_dom.html new file mode 100644 index 0000000..ab88c2f --- /dev/null +++ b/webchannel/js/imported_src/tests/forwardchannelrequestpool_test_dom.html @@ -0,0 +1,8 @@ + +
+
\ No newline at end of file diff --git a/webchannel/js/imported_src/tests/webchannelbase_test.js b/webchannel/js/imported_src/tests/webchannelbase_test.js new file mode 100644 index 0000000..00533e9 --- /dev/null +++ b/webchannel/js/imported_src/tests/webchannelbase_test.js @@ -0,0 +1,1533 @@ +/** + * @license + * Copyright The Closure Library Authors. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Unit tests for WebChannelBase.@suppress {accessControls} + * Private methods are accessed for test purposes. + */ + +goog.module('goog.labs.net.webChannel.webChannelBaseTest'); +goog.setTestOnly(); + +const ChannelRequest = goog.require('goog.labs.net.webChannel.ChannelRequest'); +const ForwardChannelRequestPool = goog.require('goog.labs.net.webChannel.ForwardChannelRequestPool'); +const MockClock = goog.require('goog.testing.MockClock'); +const PropertyReplacer = goog.require('goog.testing.PropertyReplacer'); +const StructsMap = goog.require('goog.structs.Map'); +const Timer = goog.require('goog.Timer'); +const Uri = goog.requireType('goog.Uri'); +const WebChannelBase = goog.require('goog.labs.net.webChannel.WebChannelBase'); +const WebChannelBaseTransport = goog.require('goog.labs.net.webChannel.WebChannelBaseTransport'); +const WebChannelDebug = goog.require('goog.labs.net.webChannel.WebChannelDebug'); +const Wire = goog.require('goog.labs.net.webChannel.Wire'); +const XhrIo = goog.requireType('goog.net.XhrIo'); +const dom = goog.require('goog.dom'); +const functions = goog.require('goog.functions'); +const googArray = goog.require('goog.array'); +const googJson = goog.require('goog.json'); +const netUtils = goog.require('goog.labs.net.webChannel.netUtils'); +const requestStats = goog.require('goog.labs.net.webChannel.requestStats'); + +/** Delay between a network failure and the next network request. */ +const RETRY_TIME = 1000; + +/** A really long time - used to make sure no more timeouts will fire. */ +const ALL_DAY_MS = 1000 * 60 * 60 * 24; + +const DEFAULT_ERROR_HTTP_STATUS_CODE = 503; + +const stubs = new PropertyReplacer(); + +let channel; +let deliveredMaps; +let handledMessages; +let handler; +let mockClock; +let gotError; +let numStatEvents; +let lastStatEvent; +let numTimingEvents; +let lastPostSize; +let lastPostRtt; +let lastPostRetryCount; + +// Set to true to see the channel debug output in the browser window. +const debug = false; +// Debug message to print out when debug is true. +let debugMessage = ''; + +function debugToWindow(message) { + if (debug) { + debugMessage += `${message}
`; + dom.getElement('debug').innerHTML = debugMessage; + } +} + +/** + * Stubs netUtils to always time out. It maintains the + * contract given by netUtils.testNetwork, but always + * times out (calling callback(false)). + * stubNetUtils should be called in tests that require it before + * a call to testNetwork happens. It is reset at tearDown. + */ +function stubNetUtils() { + stubs.set(netUtils, 'testLoadImage', (url, timeout, callback) => { + Timer.callOnce(goog.partial(callback, false), timeout); + }); +} + +/** + * Stubs + * ForwardChannelRequestPool.isSpdyOrHttp2Enabled_ to + * manage the max pool size for the forward channel. + * @param {boolean} spdyEnabled Whether SPDY is enabled for the test. + */ +function stubSpdyCheck(spdyEnabled) { + stubs.set( + ForwardChannelRequestPool, 'isSpdyOrHttp2Enabled_', () => spdyEnabled); +} + +/** + * Mock ChannelRequest. + * @final + */ +class MockChannelRequest { + constructor( + channel, channelDebug, sessionId = undefined, requestId = undefined, + retryId = undefined) { + this.channel_ = channel; + this.channelDebug_ = channelDebug; + this.sessionId_ = sessionId; + this.requestId_ = requestId; + this.successful_ = true; + this.lastError_ = null; + this.lastStatusCode_ = 200; + this.errorResponseHeaders_ = undefined; + + // For debugging, keep track of whether this is a back or forward channel. + this.isBack = !!(requestId == 'rpc'); + this.isForward = !this.isBack; + + this.pendingMessages_ = []; + + this.postData_ = null; + this.requestStartTime_ = null; + } + + /** @param {?Object} extraHeaders The HTTP headers. */ + setExtraHeaders(extraHeaders) {} + + /** @param {number} timeout The timeout in MS for when we fail the request. */ + setTimeout(timeout) {} + + /** + * @param {number} throttle The throttle in ms. A value of zero indicates no + * throttle. + */ + setReadyStateChangeThrottle(throttle) {} + + /** + * @param {?Uri} uri The uri of the request. + * @param {?string} postData The data for the post body. + * @param {boolean} decodeChunks Whether to the result is expected to be + * encoded for chunking and thus requires decoding. + */ + xmlHttpPost(uri, postData, decodeChunks) { + this.channelDebug_.debug(`---> POST: ${uri}, ${postData}, ${decodeChunks}`); + this.postData_ = postData; + this.requestStartTime_ = Date.now(); + } + + /** + * @param {?Uri} uri The uri of the request. + * @param {boolean} decodeChunks Whether to the result is expected to be + * encoded for chunking and thus requires decoding. + * @param {?string} hostPrefix The host prefix, if we might be using a + * secondary domain. Note that it should also be in the URL, adding this + * won't cause it to be added to the URL. + */ + xmlHttpGet(uri, decodeChunks, hostPrefix) { + this.channelDebug_.debug( + `<--- GET: ${uri}, ${decodeChunks}, ${hostPrefix}`); + this.requestStartTime_ = Date.now(); + } + + /** @param {?Uri} uri The uri to send a request to. */ + sendCloseRequest(uri) { + this.requestStartTime_ = Date.now(); + } + + /** Cancel. */ + cancel() { + this.successful_ = false; + } + + /** @return {boolean} */ + getSuccess() { + return this.successful_; + } + + /** @return {?ChannelRequest.Error} The last error. */ + getLastError() { + return this.lastError_; + } + + /** @return {!Object|undefined} Error response headers. */ + getErrorResponseHeaders() { + return this.errorResponseHeaders_; + } + + /** @return {number} The status code of the last request. */ + getLastStatusCode() { + return this.lastStatusCode_; + } + + /** @return {string|undefined} The session ID. */ + getSessionId() { + return this.sessionId_; + } + + /** @return {string|number|undefined} The request ID. */ + getRequestId() { + return this.requestId_; + } + + /** @return {?string} The POST data provided by the request initiator. */ + getPostData() { + return this.postData_; + } + + /** + * @return {?number} The time the request started, as returned by Date.now(). + */ + getRequestStartTime() { + return this.requestStartTime_; + } + + /** @return {?XhrIo} Any XhrIo request created for this object. */ + getXhr() { + return null; + } + + /** + * @param {!Array} messages The pending messages for this + * request. + */ + setPendingMessages(messages) { + this.pendingMessages_ = messages; + } + + /** + * @return {!Array} The pending messages for this request. + */ + getPendingMessages() { + return this.pendingMessages_; + } + + /** @return {boolean} true if X_HTTP_INITIAL_RESPONSE has been handled. */ + isInitialResponseDecoded() { + return false; + } + + /** Decodes X_HTTP_INITIAL_RESPONSE if present. */ + setDecodeInitialResponse() {} +} + +function getSingleForwardRequest() { + /** @suppress {visibility} Accessing private properties. */ + const pool = channel.forwardChannelRequestPool_; + if (!pool.hasPendingRequest()) { + return null; + } + return pool.request_ || pool.requestPool_.getValues()[0]; +} + +/** + * Helper function to return a formatted string representing an array of maps. + */ +function formatArrayOfMaps(arrayOfMaps) { + const result = []; + for (let i = 0; i < arrayOfMaps.length; i++) { + const map = arrayOfMaps[i]; + + if (Object.getPrototypeOf(map.map) === Object.prototype) { // Object map + for (const key in map.map) { + const tmp = + key + ':' + map.map[key] + (map.context ? ':' + map.context : ''); + result.push(tmp); + } + } else if ( + typeof map.map.keys === 'function' && + typeof map.map.get === 'function') { // MapLike + for (const key of map.map.keys()) { + const tmp = key + ':' + map.map.get(key) + + (map.context ? ':' + map.context : ''); + result.push(tmp); + } + } else { + throw new Error('Unknown input type for map: ' + String(map)); + } + } + return result.join(', '); +} + +/** + * @param {number=} serverVersion + * @param {string=} hostPrefix + * @param {string=} opt_uriPrefix + * @param {boolean=} spdyEnabled + */ +function connectForwardChannel( + serverVersion = undefined, hostPrefix = undefined, opt_uriPrefix, + spdyEnabled = undefined) { + stubSpdyCheck(!!spdyEnabled); + const uriPrefix = opt_uriPrefix || ''; + channel.connect(`${uriPrefix}/bind`, null); + mockClock.tick(0); + completeForwardChannel(serverVersion, hostPrefix); +} + +/** + * @param {number=} serverVersion + * @param {string=} hostPrefix + * @param {string=} uriPrefix + * @param {boolean=} spdyEnabled + */ +function connect( + serverVersion = undefined, hostPrefix = undefined, uriPrefix = undefined, + spdyEnabled = undefined) { + connectForwardChannel(serverVersion, hostPrefix, uriPrefix, spdyEnabled); + completeBackChannel(); +} + +function disconnect() { + channel.disconnect(); + mockClock.tick(0); +} + +/** + * @param {number=} serverVersion + * @param {string=} hostPrefix + */ +function completeForwardChannel( + serverVersion = undefined, hostPrefix = undefined) { + const responseData = '[[0,["c","1234567890ABCDEF",' + + (hostPrefix ? `"${hostPrefix}"` : 'null') + + (serverVersion ? `,${serverVersion}` : '') + ']]]'; + channel.onRequestData(getSingleForwardRequest(), responseData); + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +/** @suppress {visibility} Accessing private properties. */ +function completeBackChannel() { + channel.onRequestData(channel.backChannelRequest_, '[[1,["foo"]]]'); + channel.onRequestComplete(channel.backChannelRequest_); + mockClock.tick(0); +} + +function responseDone() { + channel.onRequestData(getSingleForwardRequest(), '[1,0,0]'); // mock data + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +/** + * @param {number=} lastArrayIdSentFromServer + * @param {number=} outstandingDataSize + */ +function responseNoBackchannel( + lastArrayIdSentFromServer = undefined, outstandingDataSize = undefined) { + const responseData = + googJson.serialize([0, lastArrayIdSentFromServer, outstandingDataSize]); + channel.onRequestData(getSingleForwardRequest(), responseData); + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +function response(lastArrayIdSentFromServer, outstandingDataSize) { + const responseData = + googJson.serialize([1, lastArrayIdSentFromServer, outstandingDataSize]); + channel.onRequestData(getSingleForwardRequest(), responseData); + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +/** @suppress {visibility} Accessing private properties. */ +function receive(data) { + channel.onRequestData(channel.backChannelRequest_, `[[1,${data}]]`); + channel.onRequestComplete(channel.backChannelRequest_); + mockClock.tick(0); +} + +/** @suppress {visibility} Accessing private properties. */ +function receiveData(data) { + channel.onRequestData(channel.backChannelRequest_, data); + channel.onRequestComplete(channel.backChannelRequest_); + mockClock.tick(0); +} + +function responseTimeout() { + getSingleForwardRequest().lastError_ = ChannelRequest.Error.TIMEOUT; + getSingleForwardRequest().successful_ = false; + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +/** Fails the first forward request. */ +function responseRequestFailed() { + getSingleForwardRequest().lastError_ = ChannelRequest.Error.STATUS; + getSingleForwardRequest().lastStatusCode_ = DEFAULT_ERROR_HTTP_STATUS_CODE; + getSingleForwardRequest().successful_ = false; + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +function responseUnknownSessionId() { + getSingleForwardRequest().lastError_ = + ChannelRequest.Error.UNKNOWN_SESSION_ID; + getSingleForwardRequest().successful_ = false; + channel.onRequestComplete(getSingleForwardRequest()); + mockClock.tick(0); +} + +/** + * Enum for map types to test. + * @enum {number} + */ +const MapTypes = { + OBJECT_MAP: 0, + STRUCTS_MAP: 1, + ES6_MAP: 2, +}; + +/** + * @param {string} key + * @param {string} value + * @param {string=} context + * @param {!MapTypes=} mapType + */ +function sendMap( + key, value, context = undefined, mapType = MapTypes.OBJECT_MAP) { + let map; + if (mapType == MapTypes.OBJECT_MAP) { + map = {}; + map[key] = value; + } else if (mapType == MapTypes.STRUCTS_MAP) { + map = new StructsMap(); + map.set(key, value); + } else if (mapType == MapTypes.ES6_MAP) { + map = new Map(); + map.set(key, value); + } else { + throw new Error('Unsupported map type :)'); + } + + channel.sendMap(map, context); + mockClock.tick(0); +} + +function hasForwardChannel() { + return !!getSingleForwardRequest(); +} + +/** @suppress {visibility} Accessing private properties. */ +function hasBackChannel() { + return !!channel.backChannelRequest_; +} + +/** @suppress {visibility} Accessing private properties. */ +function hasDeadBackChannelTimer() { + return channel.deadBackChannelTimerId_ != null; +} + +function assertHasForwardChannel() { + expect(hasForwardChannel()) + .withContext('Forward channel missing.') + .toBe(true); +} + +function assertHasBackChannel() { + expect(hasBackChannel()).withContext('Back channel missing.').toBe(true); +} + +/** + * @param {!MapTypes=} mapType + */ +function sendMapOnce(mapType = MapTypes.OBJECT_MAP) { + expect(numTimingEvents).toBe(1); + sendMap('foo', 'bar', /* context= */ undefined, mapType); + responseDone(); + expect(numTimingEvents).toBe(2); + expect(formatArrayOfMaps(deliveredMaps)).toBe('foo:bar'); +} + +function sendMapTwice() { + sendMap('foo1', 'bar1'); + responseDone(); + expect(formatArrayOfMaps(deliveredMaps)).toBe('foo1:bar1'); + sendMap('foo2', 'bar2'); + responseDone(); + expect(formatArrayOfMaps(deliveredMaps)).toBe('foo2:bar2'); +} + +/** @suppress {visibility} Accessing private properties. */ +function setFailFastWhileWaitingForRetry() { + expect(numTimingEvents).toBe(1); + + sendMap('foo', 'bar'); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + + // Watchdog timeout. + responseTimeout(); + expect(channel.forwardChannelTimerId_).not.toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + + // Almost finish the between-retry timeout. + mockClock.tick(RETRY_TIME - 1); + expect(channel.forwardChannelTimerId_).not.toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + + // Setting max retries to 0 should cancel the timer and raise an error. + channel.setFailFast(true); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + + // We get the error immediately before starting to ping google.com. + expect(gotError).toBe(true); + expect(deliveredMaps.length).toBe(0); + + // Simulate that timing out. We should not get another error. + gotError = false; + mockClock.tick(netUtils.NETWORK_TIMEOUT); + expect(gotError) + .withContext('Extra error after network ping timed out.') + .toBe(false); + + // Make sure no more retry timers are firing. + mockClock.tick(ALL_DAY_MS); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + expect(numTimingEvents).toBe(1); +} + +/** @suppress {visibility} Accessing private properties. */ +function setFailFastWhileRetryXhrIsInFlight() { + expect(numTimingEvents).toBe(1); + + sendMap('foo', 'bar'); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + + // Watchdog timeout. + responseTimeout(); + expect(channel.forwardChannelTimerId_).not.toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + + // Wait for the between-retry timeout. + mockClock.tick(RETRY_TIME); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(1); + + // Simulate a second watchdog timeout. + responseTimeout(); + expect(channel.forwardChannelTimerId_).not.toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(2); + + // Wait for another between-retry timeout. + mockClock.tick(RETRY_TIME); + // Now the third req is in flight. + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(2); + + // Set fail fast, killing the request + channel.setFailFast(true); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(2); + + // We get the error immediately before starting to ping google.com. + expect(gotError).toBe(true); + + // Simulate that timing out. We should not get another error. + gotError = false; + mockClock.tick(netUtils.NETWORK_TIMEOUT); + expect(gotError) + .withContext('Extra error after network ping timed out.') + .toBe(false); + + // Make sure no more retry timers are firing. + mockClock.tick(ALL_DAY_MS); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(2); + expect(numTimingEvents).toBe(1); +} + +function requestFailedClosesChannel() { + expect(numTimingEvents).toBe(1); + + sendMap('foo', 'bar'); + responseRequestFailed(); + + expect(channel.getState()) + .withContext('Should be closed immediately after request failed.') + .toBe(WebChannelBase.State.CLOSED); + + mockClock.tick(netUtils.NETWORK_TIMEOUT); + + expect(channel.getState()) + .withContext('Should remain closed after the ping timeout.') + .toBe(WebChannelBase.State.CLOSED); + expect(numTimingEvents).toBe(1); + expect(channel.getLastStatusCode()).toBe(DEFAULT_ERROR_HTTP_STATUS_CODE); +} + +/** @suppress {visibility} Accessing private properties. */ +function outgoingMapsAwaitsResponse() { + expect(channel.outgoingMaps_.length).toBe(0); + + sendMap('foo1', 'bar'); + expect(channel.outgoingMaps_.length).toBe(0); + sendMap('foo2', 'bar'); + expect(channel.outgoingMaps_.length).toBe(1); + sendMap('foo3', 'bar'); + expect(channel.outgoingMaps_.length).toBe(2); + sendMap('foo4', 'bar'); + expect(channel.outgoingMaps_.length).toBe(3); + + responseDone(); + // Now the forward channel request is completed and a new started, so all maps + // are dequeued from the array of outgoing maps into this new forward request. + expect(channel.outgoingMaps_.length).toBe(0); +} + +describe('goog.labs.net.webChannel.webChannelBaseTest', () => { + /** + * @suppress {invalidCasts} The cast from MockChannelRequest to + * ChannelRequest is invalid and will not compile. + */ + beforeAll(() => { + // Use our MockChannelRequests instead of the real ones. + ChannelRequest.createChannelRequest = + (channel, channelDebug, opt_sessionId, opt_requestId, opt_retryId) => { + return /** @type {!ChannelRequest} */ (new MockChannelRequest( + channel, channelDebug, opt_sessionId, opt_requestId, + opt_retryId)); + }; + + // Mock out the stat notification code. + requestStats.notifyStatEvent = (stat) => { + numStatEvents++; + lastStatEvent = stat; + }; + + requestStats.notifyTimingEvent = (size, rtt, retries) => { + numTimingEvents++; + lastPostSize = size; + lastPostRtt = rtt; + lastPostRetryCount = retries; + }; + }); + + beforeEach(() => { + numTimingEvents = 0; + lastPostSize = null; + lastPostRtt = null; + lastPostRetryCount = null; + + mockClock = new MockClock(true); + /** @suppress {checkTypes} suppression added to enable type checking */ + channel = new WebChannelBase('1'); + + gotError = false; + + handler = new WebChannelBase.Handler(); + handler.channelOpened = () => {}; + handler.channelError = (channel, error) => { + gotError = true; + }; + handler.channelSuccess = (channel, request) => { + deliveredMaps = googArray.clone(request.getPendingMessages()); + }; + /** + * @suppress {checkTypes} The callback function type declaration is + * skipped. + */ + handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => { + // Mock out the handler, and let it set a formatted user readable string + // of the undelivered maps which we can use when verifying our assertions. + if (opt_pendingMaps) { + handler.pendingMapsString = formatArrayOfMaps(opt_pendingMaps); + } + if (opt_undeliveredMaps) { + handler.undeliveredMapsString = formatArrayOfMaps(opt_undeliveredMaps); + } + }; + handler.channelHandleMultipleArrays = (_, data) => { + handledMessages = googArray.clone(data); + }; + handler.channelHandleArray = () => {}; + + channel.setHandler(handler); + + // Provide a predictable retry time for testing. + /** @suppress {visibility} Accessing private properties. */ + channel.getRetryTime_ = (retryCount) => RETRY_TIME; + + const channelDebug = new WebChannelDebug(); + channelDebug.debug = (message) => { + debugToWindow(message); + }; + channel.setChannelDebug(channelDebug); + + numStatEvents = 0; + lastStatEvent = null; + }); + + afterEach(() => { + mockClock.dispose(); + stubs.reset(); + debugToWindow('
'); + }); + + it('format array of maps', () => { + // This function is used in a non-trivial test, so let's verify that it + // works. + const map1 = new Map(); + map1.set('k1', 'v1'); + map1.set('k2', 'v2'); + const map2 = new Map(); + map2.set('k3', 'v3'); + const map3 = new Map(); + map3.set('k4', 'v4'); + map3.set('k5', 'v5'); + map3.set('k6', 'v6'); + + // One map. + const a = []; + a.push(new Wire.QueuedMap(0, map1)); + expect(formatArrayOfMaps(a)).toBe('k1:v1, k2:v2'); + + // Many maps. + const b = []; + b.push(new Wire.QueuedMap(0, map1)); + b.push(new Wire.QueuedMap(0, map2)); + b.push(new Wire.QueuedMap(0, map3)); + expect(formatArrayOfMaps(b)) + .toBe('k1:v1, k2:v2, k3:v3, k4:v4, k5:v5, k6:v6'); + + // One map with a context. + const c = []; + c.push(new Wire.QueuedMap(0, map1, new String('c1'))); + expect(formatArrayOfMaps(c)).toBe('k1:v1:c1, k2:v2:c1'); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('connect', () => { + connect(); + expect(channel.getState()).toBe(WebChannelBase.State.OPENED); + // If the server specifies no version, the client assumes the latest version + expect(channel.channelVersion_).toBe(Wire.LATEST_CHANNEL_VERSION); + expect(channel.isBuffered()).toBe(false); + }); + + it('connect, back channel established', () => { + connect(); + assertHasBackChannel(); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('connect, with server host prefix', () => { + connect(undefined, 'serverHostPrefix'); + expect(channel.hostPrefix_).toBe('serverHostPrefix'); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('connect, with client host prefix', () => { + handler.correctHostPrefix = (hostPrefix) => 'clientHostPrefix'; + connect(); + expect(channel.hostPrefix_).toBe('clientHostPrefix'); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('connect, override server host prefix', () => { + handler.correctHostPrefix = (hostPrefix) => 'clientHostPrefix'; + connect(undefined, 'serverHostPrefix'); + expect(channel.hostPrefix_).toBe('clientHostPrefix'); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('connect, with server version', () => { + connect(8); + expect(channel.channelVersion_).toBe(8); + }); + + it('connect, not ok to make request for test', () => { + handler.okToMakeRequest = functions.constant(WebChannelBase.Error.NETWORK); + channel.connect('/bind', null); + mockClock.tick(0); + expect(channel.getState()).toBe(WebChannelBase.State.CLOSED); + }); + + it('connect, not ok to make request for bind', () => { + channel.connect('/bind', null); + mockClock.tick(0); + handler.okToMakeRequest = functions.constant(WebChannelBase.Error.NETWORK); + completeForwardChannel(); + expect(channel.getState()).toBe(WebChannelBase.State.CLOSED); + }); + + it('send map, with object map', () => { + connect(); + sendMapOnce(MapTypes.OBJECT_MAP); + }); + + it('send map, with structs map', () => { + connect(); + sendMapOnce(MapTypes.STRUCTS_MAP); + }); + + it('send map, with es6 map', () => { + connect(); + sendMapOnce(MapTypes.ES6_MAP); + }); + + it('send map with spdy enabled', () => { + connect(undefined, undefined, undefined, true); + sendMapOnce(); + }); + + it('send map, twice', () => { + connect(); + sendMapTwice(); + }); + + it('send map, twice with spdy enabled', () => { + connect(undefined, undefined, undefined, true); + sendMapTwice(); + }); + + it('send map, and receive', () => { + connect(); + sendMap('foo', 'bar'); + responseDone(); + receive('["the server reply"]'); + }); + + it('receive', () => { + connect(); + receive('["message from server"]'); + assertHasBackChannel(); + }); + + it('receive, twice', () => { + connect(); + receive('["message one from server"]'); + receive('["message two from server"]'); + assertHasBackChannel(); + }); + + it('receive, and send map', () => { + connect(); + receive('["the server reply"]'); + sendMap('foo', 'bar'); + responseDone(); + assertHasBackChannel(); + }); + + it('back channel remains established, after single send map', () => { + connect(); + + sendMap('foo', 'bar'); + responseDone(); + receive('["ack"]'); + + assertHasBackChannel(); + }); + + it('back channel remains established, after double send map', () => { + connect(); + + sendMap('foo1', 'bar1'); + sendMap('foo2', 'bar2'); + responseDone(); + receive('["ack"]'); + + // This assertion would fail prior to CL 13302660. + assertHasBackChannel(); + }); + + it('timing event', () => { + connect(); + expect(numTimingEvents).toBe(1); + sendMap('', ''); + expect(numTimingEvents).toBe(1); + mockClock.tick(20); + let expSize = getSingleForwardRequest().getPostData().length; + responseDone(); + + expect(numTimingEvents).toBe(2); + expect(lastPostSize).toBe(expSize); + expect(lastPostRtt).toBe(20); + expect(lastPostRetryCount).toBe(0); + + sendMap('abcdefg', '123456'); + expSize = getSingleForwardRequest().getPostData().length; + responseTimeout(); + expect(numTimingEvents).toBe(2); + mockClock.tick(RETRY_TIME + 1); + responseDone(); + expect(numTimingEvents).toBe(3); + expect(lastPostSize).toBe(expSize); + expect(lastPostRetryCount).toBe(1); + expect(lastPostRtt).toBe(1); + }); + + /** + * Make sure that dropping the forward channel retry limit below the retry + * count reports an error, and prevents another request from firing. + */ + it('set fail fast while waiting for retry', () => { + stubNetUtils(); + + connect(); + setFailFastWhileWaitingForRetry(); + }); + + it('set fail fast while waiting for retry with spdy enabled', () => { + stubNetUtils(); + + connect(undefined, undefined, undefined, true); + setFailFastWhileWaitingForRetry(); + }); + + /** + * Make sure that dropping the forward channel retry limit below the retry + * count reports an error, and prevents another request from firing. + */ + it('set fail fast while retry xhr is in flight', () => { + stubNetUtils(); + + connect(); + setFailFastWhileRetryXhrIsInFlight(); + }); + + it('set fail fast while retry xhr is in flight with spdy enabled', () => { + stubNetUtils(); + + connect(undefined, undefined, undefined, true); + setFailFastWhileRetryXhrIsInFlight(); + }); + + /** + * Makes sure that setting fail fast while not retrying doesn't cause a + * failure. + * @suppress {visibility} Accessing private properties. + */ + it('set fail fast at retry count', () => { + stubNetUtils(); + + connect(); + expect(numTimingEvents).toBe(1); + + sendMap('foo', 'bar'); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + + // Set fail fast. + channel.setFailFast(true); + // Request should still be alive. + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).not.toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + + // Watchdog timeout. Now we should get an error. + responseTimeout(); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + + // We get the error immediately before starting to ping google.com. + expect(gotError).toBe(true); + // We get the error immediately before starting to ping google.com. + // Simulate that timing out. We should not get another error in addition + // to the initial failure. + gotError = false; + mockClock.tick(netUtils.NETWORK_TIMEOUT); + expect(gotError) + .withContext('Extra error after network ping timed out.') + .toBe(false); + + // Make sure no more retry timers are firing. + mockClock.tick(ALL_DAY_MS); + expect(channel.forwardChannelTimerId_).toBeNull(); + expect(getSingleForwardRequest()).toBeNull(); + expect(channel.forwardChannelRetryCount_).toBe(0); + expect(numTimingEvents).toBe(1); + }); + + it('request failed closes channel', () => { + stubNetUtils(); + + connect(); + requestFailedClosesChannel(); + }); + + it('request failed closes channel with spdy enabled', () => { + stubNetUtils(); + + connect(undefined, undefined, undefined, true); + requestFailedClosesChannel(); + }); + + it('stat event reported only once', () => { + stubNetUtils(); + + connect(); + sendMap('foo', 'bar'); + numStatEvents = 0; + lastStatEvent = null; + responseUnknownSessionId(); + + expect(numStatEvents).toBe(1); + expect(lastStatEvent).toBe(requestStats.Stat.ERROR_OTHER); + + numStatEvents = 0; + mockClock.tick(netUtils.NETWORK_TIMEOUT); + expect(numStatEvents) + .withContext('No new stat events should be reported.') + .toBe(0); + }); + + it('stat event reported only once, on network up', () => { + stubNetUtils(); + + connect(); + sendMap('foo', 'bar'); + numStatEvents = 0; + lastStatEvent = null; + responseRequestFailed(); + + expect(numStatEvents) + .withContext( + 'No stat event should be reported before we know the reason.') + .toBe(0); + + // Let the ping time out. + mockClock.tick(netUtils.NETWORK_TIMEOUT); + + // Assert we report the correct stat event. + expect(numStatEvents).toBe(1); + expect(lastStatEvent).toBe(requestStats.Stat.ERROR_NETWORK); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('stat event reported only once, on network down', () => { + stubNetUtils(); + + connect(); + sendMap('foo', 'bar'); + numStatEvents = 0; + lastStatEvent = null; + responseRequestFailed(); + + expect(numStatEvents) + .withContext( + 'No stat event should be reported before we know the reason.') + .toBe(0); + + // Wait half the ping timeout period, and then fake the network being up. + mockClock.tick(netUtils.NETWORK_TIMEOUT / 2); + channel.testNetworkCallback_(true); + + // Assert we report the correct stat event. + expect(numStatEvents).toBe(1); + expect(lastStatEvent).toBe(requestStats.Stat.ERROR_OTHER); + }); + + it('outgoing maps awaits response', () => { + connect(); + outgoingMapsAwaitsResponse(); + }); + + it('outgoing maps awaits response with spdy enabled', () => { + connect(undefined, undefined, undefined, true); + outgoingMapsAwaitsResponse(); + }); + + it('undelivered maps, does not notify when successful', () => { + /** + * @suppress {checkTypes} The callback function type declaration is + * skipped. + */ + handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => { + if (opt_pendingMaps || opt_undeliveredMaps) { + fail('No pending or undelivered maps should be reported.'); + } + }; + + connect(); + sendMap('foo1', 'bar1'); + responseDone(); + sendMap('foo2', 'bar2'); + responseDone(); + disconnect(); + }); + + it('undelivered maps, does not notify if nothing was sent', () => { + /** + * @suppress {checkTypes} The callback function type declaration is + * skipped. + */ + handler.channelClosed = (channel, opt_pendingMaps, opt_undeliveredMaps) => { + if (opt_pendingMaps || opt_undeliveredMaps) { + fail('No pending or undelivered maps should be reported.'); + } + }; + + connect(); + mockClock.tick(ALL_DAY_MS); + disconnect(); + }); + + // NOTE: The current setup for ALL existing testUndeliveredMaps_* tests rely + // heavily on the non-HTTP2-or-SPDY behavior (i.e. one message can be sent at + // any given time). + /** @suppress {visibility} Accessing private properties. */ + it('undelivered maps, clears pending maps after notifying', () => { + connect(); + sendMap('foo1', 'bar1'); + sendMap('foo2', 'bar2'); + sendMap('foo3', 'bar3'); + + expect(channel.forwardChannelRequestPool_.getPendingMessages().length) + .toBe(1); + expect(channel.outgoingMaps_.length).toBe(2); + + disconnect(); + + expect(channel.forwardChannelRequestPool_.getPendingMessages().length) + .toBe(0); + expect(channel.outgoingMaps_.length).toBe(0); + }); + + /** @suppress {missingProperties} suppression added to enable type checking */ + it('undelivered maps, notifies with context', () => { + connect(); + + // First send two messages that succeed. + sendMap('foo1', 'bar1', 'context1'); + responseDone(); + sendMap('foo2', 'bar2', 'context2'); + responseDone(); + + // Pretend the server hangs and no longer responds. + sendMap('foo3', 'bar3', 'context3'); + sendMap('foo4', 'bar4', 'context4'); + sendMap('foo5', 'bar5', 'context5'); + + // Give up. + disconnect(); + + // Assert that we are informed of any undelivered messages; both about + // #3 that was sent but which we don't know if the server received, and + // #4 and #5 which remain in the outgoing maps and have not yet been sent. + expect(handler.pendingMapsString).toBe('foo3:bar3:context3'); + expect(handler.undeliveredMapsString) + .toBe('foo4:bar4:context4, foo5:bar5:context5'); + }); + + /** @suppress {missingProperties} suppression added to enable type checking */ + it('undelivered maps, service unavailable', () => { + // Send a few maps, and let one fail. + connect(); + sendMap('foo1', 'bar1'); + responseDone(); + sendMap('foo2', 'bar2'); + responseRequestFailed(); + + // After a failure, the channel should be closed. + disconnect(); + + expect(handler.pendingMapsString).toBe('foo2:bar2'); + expect(handler.undeliveredMapsString).toBe(''); + }); + + /** @suppress {missingProperties} suppression added to enable type checking */ + it('undelivered maps, on ping timeout', () => { + stubNetUtils(); + + connect(); + + // Send a message. + sendMap('foo1', 'bar1'); + + // Fake REQUEST_FAILED, triggering a ping to check the network. + responseRequestFailed(); + + // Let the ping time out, unsuccessfully. + mockClock.tick(netUtils.NETWORK_TIMEOUT); + + // Assert channel is closed. + expect(channel.getState()).toBe(WebChannelBase.State.CLOSED); + + // Assert that the handler is notified about the undelivered messages. + expect(handler.pendingMapsString).toBe('foo1:bar1'); + expect(handler.undeliveredMapsString).toBe(''); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('get non acked maps before channel close, returns union of pending and unsent maps', + () => { + connect(); + + // First send one message and respond with server ack. + sendMap('foo1', 'bar1'); + responseDone(); + + // Send 3 more messages which are non-acked. + sendMap('foo2', 'bar2'); + sendMap('foo3', 'bar3'); + sendMap('foo4', 'bar4'); + + // Verifies that we're indeed covering the case where 1 message is + // pending server ack and 2 message has not been sent to the network. + expect(channel.forwardChannelRequestPool_.getPendingMessages().length) + .toBe(1); + expect(channel.outgoingMaps_.length).toBe(2); + + expect(channel.getNonAckedMaps().map(queuedMap => queuedMap.map)) + .toEqual([{foo2: 'bar2'}, {foo3: 'bar3'}, {foo4: 'bar4'}]); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('get non acked maps after channel close, returns union of pending and unsent maps', + () => { + connect(); + + // First send one message and respond with server ack. + sendMap('foo1', 'bar1'); + responseDone(); + + // Send 3 more messages which are non-acked. + sendMap('foo2', 'bar2'); + sendMap('foo3', 'bar3'); + sendMap('foo4', 'bar4'); + + // Verifies that we're indeed covering the case where 1 message is + // pending server ack and 2 message has not been sent to the network. + expect(channel.forwardChannelRequestPool_.getPendingMessages().length) + .toBe(1); + expect(channel.outgoingMaps_.length).toBe(2); + + disconnect(); + + expect(channel.getNonAckedMaps().map(queuedMap => queuedMap.map)) + .toEqual([{foo2: 'bar2'}, {foo3: 'bar3'}, {foo4: 'bar4'}]); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response no backchannel post not before backchannel', () => { + connect(8); + sendMap('foo1', 'bar1'); + + mockClock.tick(10); + expect( + channel.backChannelRequest_.getRequestStartTime() < + getSingleForwardRequest().getRequestStartTime()) + .toBe(false); + responseNoBackchannel(); + expect(lastStatEvent).not.toBe(requestStats.Stat.BACKCHANNEL_MISSING); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response no backchannel', () => { + connect(8); + sendMap('foo1', 'bar1'); + response(-1, 0); + mockClock.tick(WebChannelBase.RTT_ESTIMATE + 1); + sendMap('foo2', 'bar2'); + expect( + channel.backChannelRequest_.getRequestStartTime() + + WebChannelBase.RTT_ESTIMATE < + getSingleForwardRequest().getRequestStartTime()) + .toBe(true); + responseNoBackchannel(); + expect(lastStatEvent).toBe(requestStats.Stat.BACKCHANNEL_MISSING); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response no backchannel with no backchannel', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.backChannelTimerId_).toBeNull(); + channel.backChannelRequest_.cancel(); + /** @suppress {visibility} Accessing private properties. */ + channel.backChannelRequest_ = null; + responseNoBackchannel(); + expect(lastStatEvent).toBe(requestStats.Stat.BACKCHANNEL_MISSING); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response no backchannel with start timer', () => { + connect(8); + sendMap('foo1', 'bar1'); + + channel.backChannelRequest_.cancel(); + /** @suppress {visibility} Accessing private properties. */ + channel.backChannelRequest_ = null; + /** @suppress {visibility} Accessing private properties. */ + channel.backChannelTimerId_ = 123; + responseNoBackchannel(); + expect(lastStatEvent).not.toBe(requestStats.Stat.BACKCHANNEL_MISSING); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response with no array sent', () => { + connect(8); + sendMap('foo1', 'bar1'); + + // Send a response as if the server hasn't sent down an array. + response(-1, 0); + + // POST response with an array ID lower than our last received is OK. + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(-1); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response with arrays missing', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + // Send a response as if the server has sent down seven arrays. + response(7, 111); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2); + expect(lastStatEvent).toBe(requestStats.Stat.BACKCHANNEL_DEAD); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('multiple responses with arrays missing', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + // Send a response as if the server has sent down seven arrays. + response(7, 111); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + sendMap('foo2', 'bar2'); + mockClock.tick(WebChannelBase.RTT_ESTIMATE); + response(8, 119); + mockClock.tick(WebChannelBase.RTT_ESTIMATE); + // The original timer should still fire. + expect(lastStatEvent).toBe(requestStats.Stat.BACKCHANNEL_DEAD); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('duplicate response', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + response(4, 111); + receiveData(`[[2,["foo2"]],[3,["foo3"]],[4,["foo4"]]]`); + + expect(channel.lastArrayId_).toBe(4); + expect(handledMessages).toEqual([['foo2'], ['foo3'], ['foo4']]); + + response(6, 0); + receiveData( + `[[2,["foo2"]],[3,["foo3"]],[4,["foo4"]],[5,["foo5"]],[6,["foo6"]]]`); + + expect(channel.lastArrayId_).toBe(6); + expect(handledMessages).toEqual([['foo5'], ['foo6']]); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('only retry once based on response', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + // Send a response as if the server has sent down seven arrays. + response(7, 111); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + expect(hasDeadBackChannelTimer()).toBe(true); + mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2); + expect(lastStatEvent).toBe(requestStats.Stat.BACKCHANNEL_DEAD); + expect(channel.backChannelRetryCount_).toBe(1); + mockClock.tick(WebChannelBase.RTT_ESTIMATE); + sendMap('foo2', 'bar2'); + expect(hasDeadBackChannelTimer()).toBe(false); + response(8, 119); + expect(hasDeadBackChannelTimer()).toBe(false); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response with arrays missing and live channel', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + // Send a response as if the server has sent down seven arrays. + response(7, 111); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + mockClock.tick(WebChannelBase.RTT_ESTIMATE); + expect(hasDeadBackChannelTimer()).toBe(true); + receive('["ack"]'); + expect(hasDeadBackChannelTimer()).toBe(false); + mockClock.tick(WebChannelBase.RTT_ESTIMATE); + expect(lastStatEvent).not.toBe(requestStats.Stat.BACKCHANNEL_DEAD); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response with big outstanding data', () => { + connect(8); + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + + // Send a response as if the server has sent down seven arrays and 50kbytes. + response(7, 50000); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + expect(hasDeadBackChannelTimer()).toBe(false); + mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2); + expect(lastStatEvent).not.toBe(requestStats.Stat.BACKCHANNEL_DEAD); + }); + + /** @suppress {visibility} Accessing private properties. */ + it('response in buffered mode', () => { + connect(8); + /** @suppress {visibility} Accessing private properties. */ + channel.enableStreaming_ = false; + sendMap('foo1', 'bar1'); + expect(channel.lastPostResponseArrayId_).toBe(-1); + response(7, 111); + + expect(channel.lastArrayId_).toBe(1); + expect(channel.lastPostResponseArrayId_).toBe(7); + expect(hasDeadBackChannelTimer()).toBe(false); + mockClock.tick(WebChannelBase.RTT_ESTIMATE * 2); + expect(lastStatEvent).not.toBe(requestStats.Stat.BACKCHANNEL_DEAD); + }); + + it('response with garbage', () => { + connect(8); + sendMap('foo1', 'bar1'); + channel.onRequestData(getSingleForwardRequest(), 'garbage'); + expect(channel.getState()).toBe(WebChannelBase.State.CLOSED); + }); + + it('response with garbage in array', () => { + connect(8); + sendMap('foo1', 'bar1'); + channel.onRequestData(getSingleForwardRequest(), '["garbage"]'); + expect(channel.getState()).toBe(WebChannelBase.State.CLOSED); + }); + + it('response with evil data', () => { + connect(8); + sendMap('foo1', 'bar1'); + channel.onRequestData( + getSingleForwardRequest(), + 'foo=