From 10efae64e06f5c886a757280548d3dd35fb87d9a Mon Sep 17 00:00:00 2001 From: BrennanConroy Date: Fri, 1 Jun 2018 09:25:25 -0700 Subject: [PATCH] Add WebSocket and EventSource polyfills to options (#2408) --- build/buildpipeline/windows.groovy | 2 +- clients/ts/signalr/README.md | 1 - clients/ts/signalr/src/HttpConnection.ts | 22 +++- .../ts/signalr/src/IHttpConnectionOptions.ts | 15 +++ clients/ts/signalr/src/Polyfills.ts | 16 +++ .../signalr/src/ServerSentEventsTransport.ts | 12 +- clients/ts/signalr/src/WebSocketTransport.ts | 12 +- .../ts/signalr/tests/HttpConnection.test.ts | 116 ++++++++++++++++++ 8 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 clients/ts/signalr/src/Polyfills.ts diff --git a/build/buildpipeline/windows.groovy b/build/buildpipeline/windows.groovy index 3d5715bd45..c1fcbd36e0 100644 --- a/build/buildpipeline/windows.groovy +++ b/build/buildpipeline/windows.groovy @@ -2,7 +2,7 @@ // 'node' indicates to Jenkins that the enclosed block runs on a node that matches // the label 'windows-with-vs' -simpleNode('Windows.10.Enterprise.RS3.ASPNET') { +simpleNode('Windows.10.Amd64.EnterpriseRS3.ASPNET.Open') { stage ('Checking out source') { checkout scm } diff --git a/clients/ts/signalr/README.md b/clients/ts/signalr/README.md index 4a15d4eb6c..677dad2ab7 100644 --- a/clients/ts/signalr/README.md +++ b/clients/ts/signalr/README.md @@ -20,7 +20,6 @@ The following polyfills are required to use the client in Node.js applications: - `XmlHttpRequest` - always - `WebSockets` - to use the WebSockets transport - `EventSource` - to use the ServerSentEvents transport -- `btoa/atob` - to use binary protocols (e.g. MessagePack) over text transports (ServerSentEvents) ### Example (Browser) diff --git a/clients/ts/signalr/src/HttpConnection.ts b/clients/ts/signalr/src/HttpConnection.ts index 6f56c5f841..76d02bca09 100644 --- a/clients/ts/signalr/src/HttpConnection.ts +++ b/clients/ts/signalr/src/HttpConnection.ts @@ -7,6 +7,7 @@ import { IHttpConnectionOptions } from "./IHttpConnectionOptions"; import { ILogger, LogLevel } from "./ILogger"; import { HttpTransportType, ITransport, TransferFormat } from "./ITransport"; import { LongPollingTransport } from "./LongPollingTransport"; +import { EventSourceConstructor, WebSocketConstructor } from "./Polyfills"; import { ServerSentEventsTransport } from "./ServerSentEventsTransport"; import { Arg, createLogger } from "./Utils"; import { WebSocketTransport } from "./WebSocketTransport"; @@ -55,6 +56,13 @@ export class HttpConnection implements IConnection { options = options || {}; options.logMessageContent = options.logMessageContent || false; + if (typeof WebSocket !== "undefined" && !options.WebSocket) { + options.WebSocket = WebSocket; + } + if (typeof EventSource !== "undefined" && !options.EventSource) { + options.EventSource = EventSource; + } + this.httpClient = options.httpClient || new DefaultHttpClient(this.logger); this.connectionState = ConnectionState.Disconnected; this.options = options; @@ -253,9 +261,15 @@ export class HttpConnection implements IConnection { private constructTransport(transport: HttpTransportType) { switch (transport) { case HttpTransportType.WebSockets: - return new WebSocketTransport(this.accessTokenFactory, this.logger, this.options.logMessageContent || false); + if (!this.options.WebSocket) { + throw new Error("'WebSocket' is not supported in your environment."); + } + return new WebSocketTransport(this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.WebSocket); case HttpTransportType.ServerSentEvents: - return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false); + if (!this.options.EventSource) { + throw new Error("'EventSource' is not supported in your environment."); + } + return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.EventSource); case HttpTransportType.LongPolling: return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false); default: @@ -271,8 +285,8 @@ export class HttpConnection implements IConnection { const transferFormats = endpoint.transferFormats.map((s) => TransferFormat[s]); if (transportMatches(requestedTransport, transport)) { if (transferFormats.indexOf(requestedTransferFormat) >= 0) { - if ((transport === HttpTransportType.WebSockets && typeof WebSocket === "undefined") || - (transport === HttpTransportType.ServerSentEvents && typeof EventSource === "undefined")) { + if ((transport === HttpTransportType.WebSockets && !this.options.WebSocket) || + (transport === HttpTransportType.ServerSentEvents && !this.options.EventSource)) { this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it is not supported in your environment.'`); } else { this.logger.log(LogLevel.Debug, `Selecting transport '${HttpTransportType[transport]}'`); diff --git a/clients/ts/signalr/src/IHttpConnectionOptions.ts b/clients/ts/signalr/src/IHttpConnectionOptions.ts index d6433d2def..6c36defa2b 100644 --- a/clients/ts/signalr/src/IHttpConnectionOptions.ts +++ b/clients/ts/signalr/src/IHttpConnectionOptions.ts @@ -4,6 +4,7 @@ import { HttpClient } from "./HttpClient"; import { ILogger, LogLevel } from "./ILogger"; import { HttpTransportType, ITransport } from "./ITransport"; +import { EventSourceConstructor, WebSocketConstructor } from "./Polyfills"; /** Options provided to the 'withUrl' method on {@link HubConnectionBuilder} to configure options for the HTTP-based transports. */ export interface IHttpConnectionOptions { @@ -38,4 +39,18 @@ export interface IHttpConnectionOptions { * Negotiation can only be skipped when the {@link transport} property is set to 'HttpTransportType.WebSockets'. */ skipNegotiation?: boolean; + + // Used for unit testing and code spelunkers + /** A constructor that can be used to create a WebSocket. + * + * @internal + */ + WebSocket?: WebSocketConstructor; + + // Used for unit testing and code spelunkers + /** A constructor that can be used to create an EventSource. + * + * @internal + */ + EventSource?: EventSourceConstructor; } diff --git a/clients/ts/signalr/src/Polyfills.ts b/clients/ts/signalr/src/Polyfills.ts new file mode 100644 index 0000000000..d242527d62 --- /dev/null +++ b/clients/ts/signalr/src/Polyfills.ts @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// Not exported from index + +export interface EventSourceConstructor { + new(url: string, eventSourceInitDict?: EventSourceInit): EventSource; +} + +export interface WebSocketConstructor { + new(url: string, protocols?: string | string[]): WebSocket; + readonly CLOSED: number; + readonly CLOSING: number; + readonly CONNECTING: number; + readonly OPEN: number; +} diff --git a/clients/ts/signalr/src/ServerSentEventsTransport.ts b/clients/ts/signalr/src/ServerSentEventsTransport.ts index 3ae7ed7e51..a25d7522be 100644 --- a/clients/ts/signalr/src/ServerSentEventsTransport.ts +++ b/clients/ts/signalr/src/ServerSentEventsTransport.ts @@ -4,6 +4,7 @@ import { HttpClient } from "./HttpClient"; import { ILogger, LogLevel } from "./ILogger"; import { ITransport, TransferFormat } from "./ITransport"; +import { EventSourceConstructor } from "./Polyfills"; import { Arg, getDataDetail, sendMessage } from "./Utils"; export class ServerSentEventsTransport implements ITransport { @@ -11,17 +12,20 @@ export class ServerSentEventsTransport implements ITransport { private readonly accessTokenFactory: (() => string | Promise) | undefined; private readonly logger: ILogger; private readonly logMessageContent: boolean; + private readonly eventSourceConstructor: EventSourceConstructor; private eventSource?: EventSource; private url?: string; public onreceive: ((data: string | ArrayBuffer) => void) | null; public onclose: ((error?: Error) => void) | null; - constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, logMessageContent: boolean) { + constructor(httpClient: HttpClient, accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, + logMessageContent: boolean, eventSourceConstructor: EventSourceConstructor) { this.httpClient = httpClient; this.accessTokenFactory = accessTokenFactory; this.logger = logger; this.logMessageContent = logMessageContent; + this.eventSourceConstructor = eventSourceConstructor; this.onreceive = null; this.onclose = null; @@ -32,10 +36,6 @@ export class ServerSentEventsTransport implements ITransport { Arg.isRequired(transferFormat, "transferFormat"); Arg.isIn(transferFormat, TransferFormat, "transferFormat"); - if (typeof (EventSource) === "undefined") { - throw new Error("'EventSource' is not supported in your environment."); - } - this.logger.log(LogLevel.Trace, "(SSE transport) Connecting"); if (this.accessTokenFactory) { @@ -52,7 +52,7 @@ export class ServerSentEventsTransport implements ITransport { reject(new Error("The Server-Sent Events transport only supports the 'Text' transfer format")); } - const eventSource = new EventSource(url, { withCredentials: true }); + const eventSource = new this.eventSourceConstructor(url, { withCredentials: true }); try { eventSource.onmessage = (e: MessageEvent) => { diff --git a/clients/ts/signalr/src/WebSocketTransport.ts b/clients/ts/signalr/src/WebSocketTransport.ts index 58719d1d15..ea11328d39 100644 --- a/clients/ts/signalr/src/WebSocketTransport.ts +++ b/clients/ts/signalr/src/WebSocketTransport.ts @@ -3,21 +3,25 @@ import { ILogger, LogLevel } from "./ILogger"; import { ITransport, TransferFormat } from "./ITransport"; +import { WebSocketConstructor } from "./Polyfills"; import { Arg, getDataDetail } from "./Utils"; export class WebSocketTransport implements ITransport { private readonly logger: ILogger; private readonly accessTokenFactory: (() => string | Promise) | undefined; private readonly logMessageContent: boolean; + private readonly webSocketConstructor: WebSocketConstructor; private webSocket?: WebSocket; public onreceive: ((data: string | ArrayBuffer) => void) | null; public onclose: ((error?: Error) => void) | null; - constructor(accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, logMessageContent: boolean) { + constructor(accessTokenFactory: (() => string | Promise) | undefined, logger: ILogger, + logMessageContent: boolean, webSocketConstructor: WebSocketConstructor) { this.logger = logger; this.accessTokenFactory = accessTokenFactory; this.logMessageContent = logMessageContent; + this.webSocketConstructor = webSocketConstructor; this.onreceive = null; this.onclose = null; @@ -28,10 +32,6 @@ export class WebSocketTransport implements ITransport { Arg.isRequired(transferFormat, "transferFormat"); Arg.isIn(transferFormat, TransferFormat, "transferFormat"); - if (typeof (WebSocket) === "undefined") { - throw new Error("'WebSocket' is not supported in your environment."); - } - this.logger.log(LogLevel.Trace, "(WebSockets transport) Connecting"); if (this.accessTokenFactory) { @@ -43,7 +43,7 @@ export class WebSocketTransport implements ITransport { return new Promise((resolve, reject) => { url = url.replace(/^http/, "ws"); - const webSocket = new WebSocket(url); + const webSocket = new this.webSocketConstructor(url); if (transferFormat === TransferFormat.Binary) { webSocket.binaryType = "arraybuffer"; } diff --git a/clients/ts/signalr/tests/HttpConnection.test.ts b/clients/ts/signalr/tests/HttpConnection.test.ts index 9d59b966cc..c3641c2712 100644 --- a/clients/ts/signalr/tests/HttpConnection.test.ts +++ b/clients/ts/signalr/tests/HttpConnection.test.ts @@ -8,6 +8,9 @@ import { HttpTransportType, ITransport, TransferFormat } from "../src/ITransport import { HttpError } from "../src/Errors"; import { LogLevel } from "../src/ILogger"; +import { EventSourceConstructor, WebSocketConstructor } from "../src/Polyfills"; +import { TextMessageFormat } from "../src/TextMessageFormat"; +import { WebSocketTransport } from "../src/WebSocketTransport"; import { eachEndpointUrl, eachTransport } from "./Common"; import { TestHttpClient } from "./TestHttpClient"; import { PromiseSource } from "./Utils"; @@ -577,6 +580,119 @@ describe("HttpConnection", () => { // Force TypeScript to let us call the constructor incorrectly :) expect(() => new (HttpConnection as any)()).toThrowError("The 'url' argument is required."); }); + + it("uses global WebSocket if defined", async () => { + // tslint:disable-next-line:no-string-literal + global["WebSocket"] = class WebSocket { + constructor(url: string, protocols?: string | string[]) { + throw new Error("WebSocket constructor called."); + } + }; + + const options: IHttpConnectionOptions = { + ...commonOptions, + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + } as IHttpConnectionOptions; + + const connection = new HttpConnection("http://tempuri.org", options); + + await expect(connection.start()) + .rejects + .toThrow("WebSocket constructor called."); + + // tslint:disable-next-line:no-string-literal + delete global["WebSocket"]; + }); + + it("uses global EventSource if defined", async () => { + let eventSourceConstructorCalled: boolean = false; + // tslint:disable-next-line:no-string-literal + global["EventSource"] = class EventSource { + constructor(url: string, eventSourceInitDict?: EventSourceInit) { + eventSourceConstructorCalled = true; + throw new Error("EventSource constructor called."); + } + }; + + const options: IHttpConnectionOptions = { + ...commonOptions, + httpClient: new TestHttpClient().on("POST", (r) => { + return { + availableTransports: [ + { transport: "ServerSentEvents", transferFormats: ["Text"] }, + ], + connectionId: defaultConnectionId, + }; + }), + transport: HttpTransportType.ServerSentEvents, + } as IHttpConnectionOptions; + + const connection = new HttpConnection("http://tempuri.org", options); + + await expect(connection.start(TransferFormat.Text)) + .rejects + .toThrow("Unable to initialize any of the available transports."); + + expect(eventSourceConstructorCalled).toEqual(true); + + // tslint:disable-next-line:no-string-literal + delete global["EventSource"]; + }); + + it("uses EventSource constructor from options if provided", async () => { + let eventSourceConstructorCalled: boolean = false; + + const customEventSourceType = class EventSource { + constructor(url: string, eventSourceInitDict?: EventSourceInit) { + eventSourceConstructorCalled = true; + throw new Error("EventSource constructor called."); + } + }; + + const options: IHttpConnectionOptions = { + ...commonOptions, + EventSource: customEventSourceType as EventSourceConstructor, + httpClient: new TestHttpClient().on("POST", (r) => { + return { + availableTransports: [ + { transport: "ServerSentEvents", transferFormats: ["Text"] }, + ], + connectionId: defaultConnectionId, + }; + }), + transport: HttpTransportType.ServerSentEvents, + } as IHttpConnectionOptions; + + const connection = new HttpConnection("http://tempuri.org", options); + + await expect(connection.start(TransferFormat.Text)) + .rejects + .toThrow("Unable to initialize any of the available transports."); + + expect(eventSourceConstructorCalled).toEqual(true); + }); + + it("uses WebSocket constructor from options if provided", async () => { + const customWebSocketType = class WebSocket { + constructor(url: string, protocols?: string | string[]) { + throw new Error("WebSocket constructor called."); + } + }; + + const options: IHttpConnectionOptions = { + ...commonOptions, + WebSocket: customWebSocketType as WebSocketConstructor, + skipNegotiation: true, + transport: HttpTransportType.WebSockets, + } as IHttpConnectionOptions; + + const connection = new HttpConnection("http://tempuri.org", options); + + await expect(connection.start()) + .rejects + .toThrow("WebSocket constructor called."); + }); }); describe("startAsync", () => {