diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/AbortSignal.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/AbortSignal.ts new file mode 100644 index 0000000000..a84194d619 --- /dev/null +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/AbortSignal.ts @@ -0,0 +1,31 @@ +// 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. + +import { asyncit as it } from "./Utils"; +import { AbortController } from "../Microsoft.AspNetCore.SignalR.Client.TS/AbortController"; + +describe("AbortSignal", () => { + describe("aborted", () => { + it("is false on initialization", () => { + expect(new AbortController().signal.aborted).toBe(false); + }); + + it("is true when aborted", () => { + let controller = new AbortController(); + let signal = controller.signal; + controller.abort(); + expect(signal.aborted).toBe(true); + }) + }); + + describe("onabort", () => { + it("is called when abort is called", () => { + let controller = new AbortController(); + let signal = controller.signal; + let abortCalled = false; + signal.onabort = () => abortCalled = true; + controller.abort(); + expect(abortCalled).toBe(true); + }) + }) +}); diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpClient.spec.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpClient.spec.ts new file mode 100644 index 0000000000..7e03ce6c00 --- /dev/null +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpClient.spec.ts @@ -0,0 +1,86 @@ +// 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. + +import { asyncit as it } from "./Utils" +import { TestHttpClient } from "./TestHttpClient"; +import { HttpRequest } from "../Microsoft.AspNetCore.SignalR.Client.TS/index"; + +describe("HttpClient", () => { + describe("get", () => { + it("sets the method and URL appropriately", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.get("http://localhost"); + expect(request.method).toEqual("GET"); + expect(request.url).toEqual("http://localhost"); + }); + + it("overrides method and url in options", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.get("http://localhost", { + method: "OPTIONS", + url: "http://wrong" + }); + expect(request.method).toEqual("GET"); + expect(request.url).toEqual("http://localhost"); + }) + + it("copies other options", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.get("http://localhost", { + timeout: 42, + }); + expect(request.timeout).toEqual(42); + }) + }); + + describe("post", () => { + it("sets the method and URL appropriately", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.post("http://localhost"); + expect(request.method).toEqual("POST"); + expect(request.url).toEqual("http://localhost"); + }); + + it("overrides method and url in options", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.post("http://localhost", { + method: "OPTIONS", + url: "http://wrong" + }); + expect(request.method).toEqual("POST"); + expect(request.url).toEqual("http://localhost"); + }) + + it("copies other options", async () => { + let request: HttpRequest; + let testClient = new TestHttpClient().on(r => { + request = r; return ""; + }); + + await testClient.post("http://localhost", { + timeout: 42, + }); + expect(request.timeout).toEqual(42); + }) + }); +}); diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpConnection.spec.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpConnection.spec.ts index 1714e70995..347c2853d5 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpConnection.spec.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/HttpConnection.spec.ts @@ -1,14 +1,15 @@ // 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. -import { IHttpClient } from "../Microsoft.AspNetCore.SignalR.Client.TS/HttpClient" +import { TestHttpClient } from "./TestHttpClient" import { HttpConnection } from "../Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection" import { IHttpConnectionOptions } from "../Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions" import { DataReceived, TransportClosed } from "../Microsoft.AspNetCore.SignalR.Client.TS/Common" import { ITransport, TransportType, TransferMode } from "../Microsoft.AspNetCore.SignalR.Client.TS/Transports" import { eachTransport, eachEndpointUrl } from "./Common"; +import { HttpResponse } from "../Microsoft.AspNetCore.SignalR.Client.TS/index"; -describe("Connection", () => { +describe("HttpConnection", () => { it("cannot be created with relative url if document object is not present", () => { expect(() => new HttpConnection("/test")) .toThrow(new Error("Cannot resolve '/test'.")); @@ -23,14 +24,9 @@ describe("Connection", () => { it("starting connection fails if getting id fails", async (done) => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.reject("error"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + httpClient: new TestHttpClient() + .on("POST", r => Promise.reject("error")) + .on("GET", r => ""), logger: null } as IHttpConnectionOptions; @@ -49,8 +45,8 @@ describe("Connection", () => { it("cannot start a running connection", async (done) => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { + httpClient: new TestHttpClient() + .on("POST", r => { connection.start() .then(() => { fail(); @@ -60,13 +56,8 @@ describe("Connection", () => { expect(error.message).toBe("Cannot start a connection that is not in the 'Disconnected' state."); done(); }); - return Promise.reject("error"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + }), logger: null } as IHttpConnectionOptions; @@ -84,15 +75,12 @@ describe("Connection", () => { it("can start a stopped connection", async (done) => { let negotiateCalls = 0; let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { + httpClient: new TestHttpClient() + .on("POST", r => { negotiateCalls += 1; return Promise.reject("reached negotiate"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + }) + .on("GET", r => ""), logger: null } as IHttpConnectionOptions; @@ -115,16 +103,15 @@ describe("Connection", () => { it("can stop a starting connection", async (done) => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { + httpClient: new TestHttpClient() + .on("POST", r => { connection.stop(); - return Promise.resolve("{}"); - }, - get(url: string): Promise { + return "{}"; + }) + .on("GET", r => { connection.stop(); - return Promise.resolve(""); - } - }, + return ""; + }), logger: null } as IHttpConnectionOptions; @@ -164,16 +151,11 @@ describe("Connection", () => { } let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.resolve("{ \"connectionId\": \"42\" }"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + httpClient: new TestHttpClient() + .on("POST", r => "{ \"connectionId\": \"42\" }") + .on("GET", r => ""), transport: fakeTransport, - logger: null + logger: null, } as IHttpConnectionOptions; @@ -196,17 +178,16 @@ describe("Connection", () => { let negotiateUrl: string; let connection: HttpConnection; let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - negotiateUrl = url; + httpClient: new TestHttpClient() + .on("POST", r => { + negotiateUrl = r.url; connection.stop(); - return Promise.resolve("{}"); - }, - get(url: string): Promise { + return "{}"; + }) + .on("GET", r => { connection.stop(); - return Promise.resolve(""); - } - }, + return ""; + }), logger: null } as IHttpConnectionOptions; @@ -231,14 +212,9 @@ describe("Connection", () => { } it(`cannot be started if requested ${TransportType[requestedTransport]} transport not available on server`, async done => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + httpClient: new TestHttpClient() + .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }") + .on("GET", r => ""), transport: requestedTransport, logger: null } as IHttpConnectionOptions; @@ -258,14 +234,9 @@ describe("Connection", () => { it("cannot be started if no transport available on server and no transport requested", async done => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + httpClient: new TestHttpClient() + .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }") + .on("GET", r => ""), logger: null } as IHttpConnectionOptions; @@ -283,14 +254,7 @@ describe("Connection", () => { it('does not send negotiate request if WebSockets transport requested explicitly', async done => { let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.reject("Should not be called"); - }, - get(url: string): Promise { - return Promise.reject("Should not be called"); - } - }, + httpClient: new TestHttpClient(), transport: TransportType.WebSockets, logger: null } as IHttpConnectionOptions; @@ -327,14 +291,9 @@ describe("Connection", () => { } as ITransport; let options: IHttpConnectionOptions = { - httpClient: { - post(url: string): Promise { - return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); - }, - get(url: string): Promise { - return Promise.resolve(""); - } - }, + httpClient: new TestHttpClient() + .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }") + .on("GET", r => ""), transport: fakeTransport, logger: null } as IHttpConnectionOptions; diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/TestHttpClient.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/TestHttpClient.ts new file mode 100644 index 0000000000..bf3f51db80 --- /dev/null +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/TestHttpClient.ts @@ -0,0 +1,90 @@ +// 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. + +import { HttpClient, HttpRequest, HttpResponse } from "../Microsoft.AspNetCore.SignalR.Client.TS/HttpClient" + +type TestHttpHandlerResult = HttpResponse | string; +export type TestHttpHandler = (request: HttpRequest, next?: (request: HttpRequest) => Promise) => Promise | TestHttpHandlerResult; + +export class TestHttpClient extends HttpClient { + private handler: (request: HttpRequest) => Promise; + + constructor() { + super(); + this.handler = (request: HttpRequest) => + Promise.reject(`Request has no handler: ${request.method} ${request.url}`); + + } + + send(request: HttpRequest): Promise { + return this.handler(request); + } + + on(handler: TestHttpHandler): TestHttpClient; + on(method: string | RegExp, handler: TestHttpHandler): TestHttpClient; + on(method: string | RegExp, url: string, handler: TestHttpHandler): TestHttpClient; + on(method: string | RegExp, url: RegExp, handler: TestHttpHandler): TestHttpClient; + on(methodOrHandler: string | RegExp | TestHttpHandler, urlOrHandler?: string | RegExp | TestHttpHandler, handler?: TestHttpHandler): TestHttpClient { + let method: string | RegExp; + let url: string | RegExp; + if ((typeof methodOrHandler === "string") || (methodOrHandler instanceof RegExp)) { + method = methodOrHandler; + } + else if (methodOrHandler) { + handler = methodOrHandler; + } + + if ((typeof urlOrHandler === "string") || (urlOrHandler instanceof RegExp)) { + url = urlOrHandler; + } + else if (urlOrHandler) { + handler = urlOrHandler; + } + + // TypeScript callers won't be able to do this, because TypeScript checks this for us. + if (!handler) { + throw new Error("Missing required argument: 'handler'"); + } + + let oldHandler = this.handler; + let newHandler = async (request: HttpRequest) => { + if (matches(method, request.method) && matches(url, request.url)) { + let promise = handler(request, oldHandler); + + let val: TestHttpHandlerResult; + if (promise instanceof Promise) { + val = await promise; + } else { + val = promise; + } + + if (typeof val === "string") { + return new HttpResponse(200, "OK", val); + } + else { + return val; + } + } + else { + return await oldHandler(request); + } + }; + this.handler = newHandler; + + return this; + } +} + +function matches(pattern: string | RegExp, actual: string): boolean { + // Null or undefined pattern matches all. + if (!pattern) { + return true; + } + + if (typeof pattern === "string") { + return actual === pattern; + } + else { + return pattern.test(actual); + } +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts index 48f62664aa..60388fa4cd 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS.Tests/Utils.ts @@ -3,16 +3,20 @@ import { clearTimeout, setTimeout } from "timers"; -export function asyncit(expectation: string, assertion?: () => Promise, timeout?: number): void { +export function asyncit(expectation: string, assertion?: () => Promise | void, timeout?: number): void { let testFunction: (done: DoneFn) => void; if (assertion) { testFunction = done => { - assertion() - .then(() => done()) - .catch((err) => { - fail(err); - done(); - }); + let promise = assertion(); + if (promise) { + promise.then(() => done()) + .catch((err) => { + fail(err); + done(); + }); + } else { + done(); + } }; } @@ -54,4 +58,4 @@ export class PromiseSource { reject(reason?: any) { this.rejecter(reason); } -} \ No newline at end of file +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/AbortController.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/AbortController.ts new file mode 100644 index 0000000000..f872be739b --- /dev/null +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/AbortController.ts @@ -0,0 +1,33 @@ +// 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. + +// Rough polyfill of https://developer.mozilla.org/en-US/docs/Web/API/AbortController +// We don't actually ever use the API being polyfilled, we always use the polyfill because +// it's a very new API right now. + +export class AbortController implements AbortSignal { + private isAborted: boolean = false; + public onabort: () => void; + + abort() { + if (!this.isAborted) { + this.isAborted = true; + if (this.onabort) { + this.onabort(); + } + } + } + + get signal(): AbortSignal { + return this; + } + + get aborted(): boolean { + return this.isAborted; + } +} + +export interface AbortSignal { + aborted: boolean; + onabort: () => void; +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpError.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Errors.ts similarity index 71% rename from client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpError.ts rename to client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Errors.ts index 24e6383c0b..c27ce417c2 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpError.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Errors.ts @@ -7,4 +7,10 @@ export class HttpError extends Error { super(errorMessage); this.statusCode = statusCode; } -} \ No newline at end of file +} + +export class TimeoutError extends Error { + constructor(errorMessage: string = "A timeout occurred.") { + super(errorMessage); + } +} diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpClient.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpClient.ts index 51e25c2e4e..1ccc040d42 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpClient.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpClient.ts @@ -1,36 +1,86 @@ // 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. -import { HttpError } from "./HttpError" +import { TimeoutError, HttpError } from "./Errors"; +import { AbortSignal } from "./AbortController"; -export interface IHttpClient { - get(url: string, headers?: Map): Promise; - post(url: string, content: string, headers?: Map): Promise; +export interface HttpRequest { + method?: string, + url?: string, + content?: string | ArrayBuffer, + headers?: Map, + responseType?: XMLHttpRequestResponseType, + abortSignal?: AbortSignal, + timeout?: number, } -export class HttpClient implements IHttpClient { - get(url: string, headers?: Map): Promise { - return this.xhr("GET", url, headers); +export class HttpResponse { + constructor(statusCode: number, statusText: string, content: string); + constructor(statusCode: number, statusText: string, content: ArrayBuffer); + constructor( + public readonly statusCode: number, + public readonly statusText: string, + public readonly content: string | ArrayBuffer) { + } +} + +export abstract class HttpClient { + get(url: string): Promise; + get(url: string, options: HttpRequest): Promise; + get(url: string, options?: HttpRequest): Promise { + return this.send({ + ...options, + method: "GET", + url: url, + }); } - post(url: string, content: string, headers?: Map): Promise { - return this.xhr("POST", url, headers, content); + post(url: string): Promise; + post(url: string, options: HttpRequest): Promise; + post(url: string, options?: HttpRequest): Promise { + return this.send({ + ...options, + method: "POST", + url: url, + }); } - private xhr(method: string, url: string, headers?: Map, content?: string): Promise { - return new Promise((resolve, reject) => { + abstract send(request: HttpRequest): Promise; +} + +export class DefaultHttpClient extends HttpClient { + send(request: HttpRequest): Promise { + return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); - xhr.open(method, url, true); + xhr.open(request.method, request.url, true); xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); - if (headers) { - headers.forEach((value, header) => xhr.setRequestHeader(header, value)); + + if (request.headers) { + request.headers.forEach((value, header) => xhr.setRequestHeader(header, value)); + } + + if (request.responseType) { + xhr.responseType = request.responseType; + } + + if (request.abortSignal) { + request.abortSignal.onabort = () => { + xhr.abort(); + }; + } + + if (request.timeout) { + xhr.timeout = request.timeout; } - xhr.send(content); xhr.onload = () => { + if (request.abortSignal) { + request.abortSignal.onabort = null; + } + if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr.response || xhr.responseText); + resolve(new HttpResponse(xhr.status, xhr.statusText, xhr.response || xhr.responseText)) } else { reject(new HttpError(xhr.statusText, xhr.status)); @@ -40,6 +90,12 @@ export class HttpClient implements IHttpClient { xhr.onerror = () => { reject(new HttpError(xhr.statusText, xhr.status)); } + + xhr.ontimeout = () => { + reject(new TimeoutError()); + } + + xhr.send(request.content || ""); }); } } diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts index 3899506b04..af3bd81c68 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection.ts @@ -4,7 +4,7 @@ import { DataReceived, ConnectionClosed } from "./Common" import { IConnection } from "./IConnection" import { ITransport, TransferMode, TransportType, WebSocketTransport, ServerSentEventsTransport, LongPollingTransport } from "./Transports" -import { IHttpClient, HttpClient } from "./HttpClient" +import { HttpClient, DefaultHttpClient } from "./HttpClient" import { IHttpConnectionOptions } from "./IHttpConnectionOptions" import { ILogger, LogLevel } from "./ILogger" import { LoggerFactory } from "./Loggers" @@ -24,7 +24,7 @@ export class HttpConnection implements IConnection { private connectionState: ConnectionState; private baseUrl: string; private url: string; - private readonly httpClient: IHttpClient; + private readonly httpClient: HttpClient; private readonly logger: ILogger; private readonly options: IHttpConnectionOptions; private transport: ITransport; @@ -37,7 +37,7 @@ export class HttpConnection implements IConnection { this.logger = LoggerFactory.createLogger(options.logger); this.baseUrl = this.resolveUrl(url); options = options || {}; - this.httpClient = options.httpClient || new HttpClient(); + this.httpClient = options.httpClient || new DefaultHttpClient(); this.connectionState = ConnectionState.Disconnected; this.options = options; } @@ -67,9 +67,12 @@ export class HttpConnection implements IConnection { headers.set("Authorization", `Bearer ${this.options.accessToken()}`); } - let negotiatePayload = await this.httpClient.post(this.resolveNegotiateUrl(this.baseUrl), "", headers); + let negotiatePayload = await this.httpClient.post(this.resolveNegotiateUrl(this.baseUrl), { + content: "", + headers + }); - let negotiateResponse: INegotiateResponse = JSON.parse(negotiatePayload); + let negotiateResponse: INegotiateResponse = JSON.parse(negotiatePayload.content); this.connectionId = negotiateResponse.connectionId; // the user tries to stop the the connection when it is being started diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions.ts index df6aaa0a9c..5b12b4a120 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions.ts @@ -1,12 +1,12 @@ // 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. -import { IHttpClient } from "./HttpClient" +import { HttpClient } from "./HttpClient" import { TransportType, ITransport } from "./Transports" import { ILogger, LogLevel } from "./ILogger"; export interface IHttpConnectionOptions { - httpClient?: IHttpClient; + httpClient?: HttpClient; transport?: TransportType | ITransport; logger?: ILogger | LogLevel; accessToken?: () => string; diff --git a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts index 9258c7ce43..d8b886bf39 100644 --- a/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts +++ b/client-ts/Microsoft.AspNetCore.SignalR.Client.TS/Transports.ts @@ -1,11 +1,12 @@ // 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. -import { DataReceived, TransportClosed } from "./Common" -import { IHttpClient } from "./HttpClient" -import { HttpError } from "./HttpError" -import { ILogger, LogLevel } from "./ILogger" -import { IConnection } from "./IConnection" +import { DataReceived, TransportClosed } from "./Common"; +import { HttpClient, HttpRequest } from "./HttpClient"; +import { HttpError, TimeoutError } from "./Errors"; +import { ILogger, LogLevel } from "./ILogger"; +import { IConnection } from "./IConnection"; +import { AbortController } from "./AbortController"; export enum TransportType { WebSockets, @@ -103,13 +104,13 @@ export class WebSocketTransport implements ITransport { } export class ServerSentEventsTransport implements ITransport { - private readonly httpClient: IHttpClient; + private readonly httpClient: HttpClient; private readonly accessToken: () => string; private readonly logger: ILogger; private eventSource: EventSource; private url: string; - constructor(httpClient: IHttpClient, accessToken: () => string, logger: ILogger) { + constructor(httpClient: HttpClient, accessToken: () => string, logger: ILogger) { this.httpClient = httpClient; this.accessToken = accessToken; this.logger = logger; @@ -183,23 +184,23 @@ export class ServerSentEventsTransport implements ITransport { } export class LongPollingTransport implements ITransport { - private readonly httpClient: IHttpClient; + private readonly httpClient: HttpClient; private readonly accessToken: () => string; private readonly logger: ILogger; private url: string; private pollXhr: XMLHttpRequest; - private shouldPoll: boolean; + private pollAbort: AbortController; - constructor(httpClient: IHttpClient, accessToken: () => string, logger: ILogger) { + constructor(httpClient: HttpClient, accessToken: () => string, logger: ILogger) { this.httpClient = httpClient; this.accessToken = accessToken; this.logger = logger; + this.pollAbort = new AbortController(); } connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise { this.url = url; - this.shouldPoll = true; // Set a flag indicating we have inherent keep-alive in this transport. connection.features.inherentKeepAlive = true; @@ -213,73 +214,70 @@ export class LongPollingTransport implements ITransport { return Promise.resolve(requestedTransferMode); } - private poll(url: string, transferMode: TransferMode): void { - if (!this.shouldPoll) { - return; + private async poll(url: string, transferMode: TransferMode): Promise { + let pollOptions: HttpRequest = { + timeout: 120000, + abortSignal: this.pollAbort.signal, + headers: new Map(), + }; + + if (transferMode === TransferMode.Binary) { + pollOptions.responseType = "arraybuffer"; } - let pollXhr = new XMLHttpRequest(); + if (this.accessToken) { + pollOptions.headers.set("Authorization", `Bearer ${this.accessToken()}`); + } - pollXhr.onload = () => { - if (pollXhr.status == 200) { - if (this.onreceive) { - try { - let response = transferMode === TransferMode.Text - ? pollXhr.responseText - : pollXhr.response; + while (!this.pollAbort.signal.aborted) { + try { + let pollUrl = `${url}&_=${Date.now()}`; + this.logger.log(LogLevel.Trace, `(LongPolling transport) polling: ${pollUrl}`); + let response = await this.httpClient.get(pollUrl, pollOptions) + if (response.statusCode === 204) { + this.logger.log(LogLevel.Information, "(LongPolling transport) Poll terminated by server"); - if (response) { - this.logger.log(LogLevel.Trace, `(LongPolling transport) data received: ${response}`); - this.onreceive(response); + // Poll terminated by server + if (this.onclose) { + this.onclose(); + } + this.pollAbort.abort(); + } + else if (response.statusCode !== 200) { + this.logger.log(LogLevel.Error, `(LongPolling transport) Unexpected response code: ${response.statusCode}`); + + // Unexpected status code + if (this.onclose) { + this.onclose(new HttpError(response.statusText, response.statusCode)); + } + this.pollAbort.abort(); + } + else { + // Process the response + if (response.content) { + this.logger.log(LogLevel.Trace, `(LongPolling transport) data received: ${response.content}`); + if (this.onreceive) { + this.onreceive(response.content); } - else { - this.logger.log(LogLevel.Information, "(LongPolling transport) timed out"); - } - } catch (error) { - if (this.onclose) { - this.onclose(error); - } - return; + } + else { + // This is another way timeout manifest. + this.logger.log(LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing."); } } - this.poll(url, transferMode); - } - else if (this.pollXhr.status == 204) { - if (this.onclose) { - this.onclose(); + } catch (e) { + if (e instanceof TimeoutError) { + // Ignore timeouts and reissue the poll. + this.logger.log(LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing."); + } else { + // Close the connection with the error as the result. + if (this.onclose) { + this.onclose(e); + } + this.pollAbort.abort(); } } - else { - if (this.onclose) { - this.onclose(new HttpError(pollXhr.statusText, pollXhr.status)); - } - } - }; - - pollXhr.onerror = () => { - if (this.onclose) { - // network related error or denied cross domain request - this.onclose(new Error("Sending HTTP request failed.")); - } - }; - - pollXhr.ontimeout = () => { - this.poll(url, transferMode); } - - this.pollXhr = pollXhr; - - this.pollXhr.open("GET", `${url}&_=${Date.now()}`, true); - if (this.accessToken) { - this.pollXhr.setRequestHeader("Authorization", `Bearer ${this.accessToken()}`); - } - if (transferMode === TransferMode.Binary) { - this.pollXhr.responseType = "arraybuffer"; - } - - // TODO: consider making timeout configurable - this.pollXhr.timeout = 120000; - this.pollXhr.send(); } async send(data: any): Promise { @@ -287,11 +285,7 @@ export class LongPollingTransport implements ITransport { } stop(): Promise { - this.shouldPoll = false; - if (this.pollXhr) { - this.pollXhr.abort(); - this.pollXhr = null; - } + this.pollAbort.abort(); return Promise.resolve(); } @@ -299,12 +293,15 @@ export class LongPollingTransport implements ITransport { onclose: TransportClosed; } -async function send(httpClient: IHttpClient, url: string, accessToken: () => string, data: any): Promise { +async function send(httpClient: HttpClient, url: string, accessToken: () => string, content: string | ArrayBuffer): Promise { let headers; if (accessToken) { headers = new Map(); headers.set("Authorization", `Bearer ${accessToken()}`) } - await httpClient.post(url, data, headers); + await httpClient.post(url, { + content, + headers + }); } diff --git a/samples/SocketsSample/wwwroot/hubs.html b/samples/SocketsSample/wwwroot/hubs.html index 04d5868c23..451950b3c1 100644 --- a/samples/SocketsSample/wwwroot/hubs.html +++ b/samples/SocketsSample/wwwroot/hubs.html @@ -1,9 +1,11 @@ + +

@@ -48,16 +50,15 @@
    + diff --git a/samples/SocketsSample/wwwroot/sockets.html b/samples/SocketsSample/wwwroot/sockets.html index 4f5f492bf2..f32a11d5ec 100644 --- a/samples/SocketsSample/wwwroot/sockets.html +++ b/samples/SocketsSample/wwwroot/sockets.html @@ -1,9 +1,11 @@ + +

    Unknown Transport

    @@ -14,43 +16,42 @@
      + diff --git a/samples/SocketsSample/wwwroot/streaming.html b/samples/SocketsSample/wwwroot/streaming.html index 976b82362b..5b8b5ebeda 100644 --- a/samples/SocketsSample/wwwroot/streaming.html +++ b/samples/SocketsSample/wwwroot/streaming.html @@ -1,9 +1,11 @@ + +

      Unknown Transport

      @@ -24,14 +26,12 @@
        +