Refactor HttpClient and use it in LongPollingTransport (#1243)

This commit is contained in:
Andrew Stanton-Nurse 2018-01-02 16:49:02 -08:00 committed by GitHub
parent 9a128b42ef
commit 44052ffcf6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 557 additions and 289 deletions

View File

@ -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);
})
})
});

View File

@ -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);
})
});
});

View File

@ -1,14 +1,15 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // 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 { HttpConnection } from "../Microsoft.AspNetCore.SignalR.Client.TS/HttpConnection"
import { IHttpConnectionOptions } from "../Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions" import { IHttpConnectionOptions } from "../Microsoft.AspNetCore.SignalR.Client.TS/IHttpConnectionOptions"
import { DataReceived, TransportClosed } from "../Microsoft.AspNetCore.SignalR.Client.TS/Common" import { DataReceived, TransportClosed } from "../Microsoft.AspNetCore.SignalR.Client.TS/Common"
import { ITransport, TransportType, TransferMode } from "../Microsoft.AspNetCore.SignalR.Client.TS/Transports" import { ITransport, TransportType, TransferMode } from "../Microsoft.AspNetCore.SignalR.Client.TS/Transports"
import { eachTransport, eachEndpointUrl } from "./Common"; 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", () => { it("cannot be created with relative url if document object is not present", () => {
expect(() => new HttpConnection("/test")) expect(() => new HttpConnection("/test"))
.toThrow(new Error("Cannot resolve '/test'.")); .toThrow(new Error("Cannot resolve '/test'."));
@ -23,14 +24,9 @@ describe("Connection", () => {
it("starting connection fails if getting id fails", async (done) => { it("starting connection fails if getting id fails", async (done) => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => Promise.reject("error"))
return Promise.reject("error"); .on("GET", r => ""),
},
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -49,8 +45,8 @@ describe("Connection", () => {
it("cannot start a running connection", async (done) => { it("cannot start a running connection", async (done) => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => {
connection.start() connection.start()
.then(() => { .then(() => {
fail(); fail();
@ -60,13 +56,8 @@ describe("Connection", () => {
expect(error.message).toBe("Cannot start a connection that is not in the 'Disconnected' state."); expect(error.message).toBe("Cannot start a connection that is not in the 'Disconnected' state.");
done(); done();
}); });
return Promise.reject("error"); return Promise.reject("error");
}, }),
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -84,15 +75,12 @@ describe("Connection", () => {
it("can start a stopped connection", async (done) => { it("can start a stopped connection", async (done) => {
let negotiateCalls = 0; let negotiateCalls = 0;
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => {
negotiateCalls += 1; negotiateCalls += 1;
return Promise.reject("reached negotiate"); return Promise.reject("reached negotiate");
}, })
get(url: string): Promise<string> { .on("GET", r => ""),
return Promise.resolve("");
}
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -115,16 +103,15 @@ describe("Connection", () => {
it("can stop a starting connection", async (done) => { it("can stop a starting connection", async (done) => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => {
connection.stop(); connection.stop();
return Promise.resolve("{}"); return "{}";
}, })
get(url: string): Promise<string> { .on("GET", r => {
connection.stop(); connection.stop();
return Promise.resolve(""); return "";
} }),
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -164,16 +151,11 @@ describe("Connection", () => {
} }
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => "{ \"connectionId\": \"42\" }")
return Promise.resolve("{ \"connectionId\": \"42\" }"); .on("GET", r => ""),
},
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
transport: fakeTransport, transport: fakeTransport,
logger: null logger: null,
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -196,17 +178,16 @@ describe("Connection", () => {
let negotiateUrl: string; let negotiateUrl: string;
let connection: HttpConnection; let connection: HttpConnection;
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => {
negotiateUrl = url; negotiateUrl = r.url;
connection.stop(); connection.stop();
return Promise.resolve("{}"); return "{}";
}, })
get(url: string): Promise<string> { .on("GET", r => {
connection.stop(); connection.stop();
return Promise.resolve(""); return "";
} }),
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -231,14 +212,9 @@ describe("Connection", () => {
} }
it(`cannot be started if requested ${TransportType[requestedTransport]} transport not available on server`, async done => { it(`cannot be started if requested ${TransportType[requestedTransport]} transport not available on server`, async done => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }")
return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); .on("GET", r => ""),
},
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
transport: requestedTransport, transport: requestedTransport,
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -258,14 +234,9 @@ describe("Connection", () => {
it("cannot be started if no transport available on server and no transport requested", async done => { it("cannot be started if no transport available on server and no transport requested", async done => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }")
return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); .on("GET", r => ""),
},
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -283,14 +254,7 @@ describe("Connection", () => {
it('does not send negotiate request if WebSockets transport requested explicitly', async done => { it('does not send negotiate request if WebSockets transport requested explicitly', async done => {
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient(),
post(url: string): Promise<string> {
return Promise.reject("Should not be called");
},
get(url: string): Promise<string> {
return Promise.reject("Should not be called");
}
},
transport: TransportType.WebSockets, transport: TransportType.WebSockets,
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;
@ -327,14 +291,9 @@ describe("Connection", () => {
} as ITransport; } as ITransport;
let options: IHttpConnectionOptions = { let options: IHttpConnectionOptions = {
httpClient: <IHttpClient>{ httpClient: new TestHttpClient()
post(url: string): Promise<string> { .on("POST", r => "{ \"connectionId\": \"42\", \"availableTransports\": [] }")
return Promise.resolve("{ \"connectionId\": \"42\", \"availableTransports\": [] }"); .on("GET", r => ""),
},
get(url: string): Promise<string> {
return Promise.resolve("");
}
},
transport: fakeTransport, transport: fakeTransport,
logger: null logger: null
} as IHttpConnectionOptions; } as IHttpConnectionOptions;

View File

@ -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<HttpResponse>) => Promise<TestHttpHandlerResult> | TestHttpHandlerResult;
export class TestHttpClient extends HttpClient {
private handler: (request: HttpRequest) => Promise<HttpResponse>;
constructor() {
super();
this.handler = (request: HttpRequest) =>
Promise.reject(`Request has no handler: ${request.method} ${request.url}`);
}
send(request: HttpRequest): Promise<HttpResponse> {
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);
}
}

View File

@ -3,16 +3,20 @@
import { clearTimeout, setTimeout } from "timers"; import { clearTimeout, setTimeout } from "timers";
export function asyncit(expectation: string, assertion?: () => Promise<any>, timeout?: number): void { export function asyncit(expectation: string, assertion?: () => Promise<any> | void, timeout?: number): void {
let testFunction: (done: DoneFn) => void; let testFunction: (done: DoneFn) => void;
if (assertion) { if (assertion) {
testFunction = done => { testFunction = done => {
assertion() let promise = assertion();
.then(() => done()) if (promise) {
.catch((err) => { promise.then(() => done())
fail(err); .catch((err) => {
done(); fail(err);
}); done();
});
} else {
done();
}
}; };
} }

View File

@ -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;
}

View File

@ -8,3 +8,9 @@ export class HttpError extends Error {
this.statusCode = statusCode; this.statusCode = statusCode;
} }
} }
export class TimeoutError extends Error {
constructor(errorMessage: string = "A timeout occurred.") {
super(errorMessage);
}
}

View File

@ -1,36 +1,86 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // 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 { export interface HttpRequest {
get(url: string, headers?: Map<string, string>): Promise<string>; method?: string,
post(url: string, content: string, headers?: Map<string, string>): Promise<string>; url?: string,
content?: string | ArrayBuffer,
headers?: Map<string, string>,
responseType?: XMLHttpRequestResponseType,
abortSignal?: AbortSignal,
timeout?: number,
} }
export class HttpClient implements IHttpClient { export class HttpResponse {
get(url: string, headers?: Map<string, string>): Promise<string> { constructor(statusCode: number, statusText: string, content: string);
return this.xhr("GET", url, headers); 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<HttpResponse>;
get(url: string, options: HttpRequest): Promise<HttpResponse>;
get(url: string, options?: HttpRequest): Promise<HttpResponse> {
return this.send({
...options,
method: "GET",
url: url,
});
} }
post(url: string, content: string, headers?: Map<string, string>): Promise<string> { post(url: string): Promise<HttpResponse>;
return this.xhr("POST", url, headers, content); post(url: string, options: HttpRequest): Promise<HttpResponse>;
post(url: string, options?: HttpRequest): Promise<HttpResponse> {
return this.send({
...options,
method: "POST",
url: url,
});
} }
private xhr(method: string, url: string, headers?: Map<string, string>, content?: string): Promise<string> { abstract send(request: HttpRequest): Promise<HttpResponse>;
return new Promise<string>((resolve, reject) => { }
export class DefaultHttpClient extends HttpClient {
send(request: HttpRequest): Promise<HttpResponse> {
return new Promise<HttpResponse>((resolve, reject) => {
let xhr = new XMLHttpRequest(); let xhr = new XMLHttpRequest();
xhr.open(method, url, true); xhr.open(request.method, request.url, true);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); 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 = () => { xhr.onload = () => {
if (request.abortSignal) {
request.abortSignal.onabort = null;
}
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response || xhr.responseText); resolve(new HttpResponse(xhr.status, xhr.statusText, xhr.response || xhr.responseText))
} }
else { else {
reject(new HttpError(xhr.statusText, xhr.status)); reject(new HttpError(xhr.statusText, xhr.status));
@ -40,6 +90,12 @@ export class HttpClient implements IHttpClient {
xhr.onerror = () => { xhr.onerror = () => {
reject(new HttpError(xhr.statusText, xhr.status)); reject(new HttpError(xhr.statusText, xhr.status));
} }
xhr.ontimeout = () => {
reject(new TimeoutError());
}
xhr.send(request.content || "");
}); });
} }
} }

View File

@ -4,7 +4,7 @@
import { DataReceived, ConnectionClosed } from "./Common" import { DataReceived, ConnectionClosed } from "./Common"
import { IConnection } from "./IConnection" import { IConnection } from "./IConnection"
import { ITransport, TransferMode, TransportType, WebSocketTransport, ServerSentEventsTransport, LongPollingTransport } from "./Transports" import { ITransport, TransferMode, TransportType, WebSocketTransport, ServerSentEventsTransport, LongPollingTransport } from "./Transports"
import { IHttpClient, HttpClient } from "./HttpClient" import { HttpClient, DefaultHttpClient } from "./HttpClient"
import { IHttpConnectionOptions } from "./IHttpConnectionOptions" import { IHttpConnectionOptions } from "./IHttpConnectionOptions"
import { ILogger, LogLevel } from "./ILogger" import { ILogger, LogLevel } from "./ILogger"
import { LoggerFactory } from "./Loggers" import { LoggerFactory } from "./Loggers"
@ -24,7 +24,7 @@ export class HttpConnection implements IConnection {
private connectionState: ConnectionState; private connectionState: ConnectionState;
private baseUrl: string; private baseUrl: string;
private url: string; private url: string;
private readonly httpClient: IHttpClient; private readonly httpClient: HttpClient;
private readonly logger: ILogger; private readonly logger: ILogger;
private readonly options: IHttpConnectionOptions; private readonly options: IHttpConnectionOptions;
private transport: ITransport; private transport: ITransport;
@ -37,7 +37,7 @@ export class HttpConnection implements IConnection {
this.logger = LoggerFactory.createLogger(options.logger); this.logger = LoggerFactory.createLogger(options.logger);
this.baseUrl = this.resolveUrl(url); this.baseUrl = this.resolveUrl(url);
options = options || {}; options = options || {};
this.httpClient = options.httpClient || new HttpClient(); this.httpClient = options.httpClient || new DefaultHttpClient();
this.connectionState = ConnectionState.Disconnected; this.connectionState = ConnectionState.Disconnected;
this.options = options; this.options = options;
} }
@ -67,9 +67,12 @@ export class HttpConnection implements IConnection {
headers.set("Authorization", `Bearer ${this.options.accessToken()}`); 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(<string>negotiatePayload.content);
this.connectionId = negotiateResponse.connectionId; this.connectionId = negotiateResponse.connectionId;
// the user tries to stop the the connection when it is being started // the user tries to stop the the connection when it is being started

View File

@ -1,12 +1,12 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // 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 { TransportType, ITransport } from "./Transports"
import { ILogger, LogLevel } from "./ILogger"; import { ILogger, LogLevel } from "./ILogger";
export interface IHttpConnectionOptions { export interface IHttpConnectionOptions {
httpClient?: IHttpClient; httpClient?: HttpClient;
transport?: TransportType | ITransport; transport?: TransportType | ITransport;
logger?: ILogger | LogLevel; logger?: ILogger | LogLevel;
accessToken?: () => string; accessToken?: () => string;

View File

@ -1,11 +1,12 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
import { DataReceived, TransportClosed } from "./Common" import { DataReceived, TransportClosed } from "./Common";
import { IHttpClient } from "./HttpClient" import { HttpClient, HttpRequest } from "./HttpClient";
import { HttpError } from "./HttpError" import { HttpError, TimeoutError } from "./Errors";
import { ILogger, LogLevel } from "./ILogger" import { ILogger, LogLevel } from "./ILogger";
import { IConnection } from "./IConnection" import { IConnection } from "./IConnection";
import { AbortController } from "./AbortController";
export enum TransportType { export enum TransportType {
WebSockets, WebSockets,
@ -103,13 +104,13 @@ export class WebSocketTransport implements ITransport {
} }
export class ServerSentEventsTransport implements ITransport { export class ServerSentEventsTransport implements ITransport {
private readonly httpClient: IHttpClient; private readonly httpClient: HttpClient;
private readonly accessToken: () => string; private readonly accessToken: () => string;
private readonly logger: ILogger; private readonly logger: ILogger;
private eventSource: EventSource; private eventSource: EventSource;
private url: string; private url: string;
constructor(httpClient: IHttpClient, accessToken: () => string, logger: ILogger) { constructor(httpClient: HttpClient, accessToken: () => string, logger: ILogger) {
this.httpClient = httpClient; this.httpClient = httpClient;
this.accessToken = accessToken; this.accessToken = accessToken;
this.logger = logger; this.logger = logger;
@ -183,23 +184,23 @@ export class ServerSentEventsTransport implements ITransport {
} }
export class LongPollingTransport implements ITransport { export class LongPollingTransport implements ITransport {
private readonly httpClient: IHttpClient; private readonly httpClient: HttpClient;
private readonly accessToken: () => string; private readonly accessToken: () => string;
private readonly logger: ILogger; private readonly logger: ILogger;
private url: string; private url: string;
private pollXhr: XMLHttpRequest; 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.httpClient = httpClient;
this.accessToken = accessToken; this.accessToken = accessToken;
this.logger = logger; this.logger = logger;
this.pollAbort = new AbortController();
} }
connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise<TransferMode> { connect(url: string, requestedTransferMode: TransferMode, connection: IConnection): Promise<TransferMode> {
this.url = url; this.url = url;
this.shouldPoll = true;
// Set a flag indicating we have inherent keep-alive in this transport. // Set a flag indicating we have inherent keep-alive in this transport.
connection.features.inherentKeepAlive = true; connection.features.inherentKeepAlive = true;
@ -213,73 +214,70 @@ export class LongPollingTransport implements ITransport {
return Promise.resolve(requestedTransferMode); return Promise.resolve(requestedTransferMode);
} }
private poll(url: string, transferMode: TransferMode): void { private async poll(url: string, transferMode: TransferMode): Promise<void> {
if (!this.shouldPoll) { let pollOptions: HttpRequest = {
return; timeout: 120000,
abortSignal: this.pollAbort.signal,
headers: new Map<string, string>(),
};
if (transferMode === TransferMode.Binary) {
pollOptions.responseType = "arraybuffer";
} }
let pollXhr = new XMLHttpRequest(); if (this.accessToken) {
pollOptions.headers.set("Authorization", `Bearer ${this.accessToken()}`);
}
pollXhr.onload = () => { while (!this.pollAbort.signal.aborted) {
if (pollXhr.status == 200) { try {
if (this.onreceive) { let pollUrl = `${url}&_=${Date.now()}`;
try { this.logger.log(LogLevel.Trace, `(LongPolling transport) polling: ${pollUrl}`);
let response = transferMode === TransferMode.Text let response = await this.httpClient.get(pollUrl, pollOptions)
? pollXhr.responseText if (response.statusCode === 204) {
: pollXhr.response; this.logger.log(LogLevel.Information, "(LongPolling transport) Poll terminated by server");
if (response) { // Poll terminated by server
this.logger.log(LogLevel.Trace, `(LongPolling transport) data received: ${response}`); if (this.onclose) {
this.onreceive(response); 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"); else {
} // This is another way timeout manifest.
} catch (error) { this.logger.log(LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing.");
if (this.onclose) {
this.onclose(error);
}
return;
} }
} }
this.poll(url, transferMode); } catch (e) {
} if (e instanceof TimeoutError) {
else if (this.pollXhr.status == 204) { // Ignore timeouts and reissue the poll.
if (this.onclose) { this.logger.log(LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing.");
this.onclose(); } 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<void> { async send(data: any): Promise<void> {
@ -287,11 +285,7 @@ export class LongPollingTransport implements ITransport {
} }
stop(): Promise<void> { stop(): Promise<void> {
this.shouldPoll = false; this.pollAbort.abort();
if (this.pollXhr) {
this.pollXhr.abort();
this.pollXhr = null;
}
return Promise.resolve(); return Promise.resolve();
} }
@ -299,12 +293,15 @@ export class LongPollingTransport implements ITransport {
onclose: TransportClosed; onclose: TransportClosed;
} }
async function send(httpClient: IHttpClient, url: string, accessToken: () => string, data: any): Promise<void> { async function send(httpClient: HttpClient, url: string, accessToken: () => string, content: string | ArrayBuffer): Promise<void> {
let headers; let headers;
if (accessToken) { if (accessToken) {
headers = new Map<string, string>(); headers = new Map<string, string>();
headers.set("Authorization", `Bearer ${accessToken()}`) headers.set("Authorization", `Bearer ${accessToken()}`)
} }
await httpClient.post(url, data, headers); await httpClient.post(url, {
content,
headers
});
} }

View File

@ -1,9 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title></title> <title></title>
</head> </head>
<body> <body>
<h1 id="head1"></h1> <h1 id="head1"></h1>
<div> <div>
@ -48,16 +50,15 @@
<ul id="message-list"></ul> <ul id="message-list"></ul>
</body> </body>
</html> </html>
<script type="text/javascript"> <script type="text/javascript">
if (typeof Promise === 'undefined') if (typeof Promise === 'undefined') {
{
document.write( document.write(
'<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>' + '<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>' +
'<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocolES5.js"><\/script>'); '<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocolES5.js"><\/script>');
} }
else else {
{
document.write( document.write(
'<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>' + '<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>' +
'<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocol.js"><\/script>'); '<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocol.js"><\/script>');
@ -65,102 +66,102 @@
</script> </script>
<script src="utils.js"></script> <script src="utils.js"></script>
<script> <script>
var isConnected = false; var isConnected = false;
function invoke(connection, method) { function invoke(connection, method) {
if (!isConnected) { if (!isConnected) {
return; return;
} }
var argsArray = Array.prototype.slice.call(arguments); var argsArray = Array.prototype.slice.call(arguments);
connection.invoke.apply(connection, argsArray.slice(1)) connection.invoke.apply(connection, argsArray.slice(1))
.then(function(result) { .then(function (result) {
console.log("invocation completed successfully: " + (result === null ? '(null)' : result)); console.log("invocation completed successfully: " + (result === null ? '(null)' : result));
if (result) { if (result) {
addLine('message-list', result); addLine('message-list', result);
} }
}) })
.catch(function(err) { .catch(function (err) {
addLine('message-list', err, 'red'); addLine('message-list', err, 'red');
}); });
} }
function getText(id) { function getText(id) {
return document.getElementById(id).value; return document.getElementById(id).value;
} }
let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets; let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets;
let logger = new signalR.ConsoleLogger(signalR.LogLevel.Information); let logger = new signalR.ConsoleLogger(signalR.LogLevel.Trace);
let hubRoute = getParameterByName('hubType') || "default"; let hubRoute = getParameterByName('hubType') || "default";
console.log('Hub Route:' + hubRoute); console.log('Hub Route:' + hubRoute);
document.getElementById('head1').innerHTML = signalR.TransportType[transportType]; document.getElementById('head1').innerHTML = signalR.TransportType[transportType];
let connectButton = document.getElementById('connect'); let connectButton = document.getElementById('connect');
let disconnectButton = document.getElementById('disconnect'); let disconnectButton = document.getElementById('disconnect');
disconnectButton.disabled = true;
var connection;
click('connect', function(event) {
connectButton.disabled = true;
disconnectButton.disabled = false;
console.log('http://' + document.location.host + '/' + hubRoute);
connection = new signalR.HubConnection(hubRoute, { transport: transportType, logging: logger });
connection.on('Send', function(msg) {
addLine('message-list', msg);
});
connection.onclose(function(e) {
if (e) {
addLine('message-list', 'Connection closed with error: ' + e, 'red');
}
else {
addLine('message-list', 'Disconnected', 'green');
}
});
connection.start()
.then(function() {
isConnected = true;
addLine('message-list', 'Connected successfully', 'green');
})
.catch(function(err) {
addLine('message-list', err, 'red');
});
});
click('disconnect', function(event) {
connectButton.disabled = false;
disconnectButton.disabled = true; disconnectButton.disabled = true;
connection.stop() var connection;
.then(function() {
isConnected = false; click('connect', function (event) {
connectButton.disabled = true;
disconnectButton.disabled = false;
console.log('http://' + document.location.host + '/' + hubRoute);
connection = new signalR.HubConnection(hubRoute, { transport: transportType, logging: logger });
connection.on('Send', function (msg) {
addLine('message-list', msg);
}); });
});
click('broadcast', function(event) { connection.onclose(function (e) {
let data = getText('message-text'); if (e) {
invoke(connection, 'Send', data); addLine('message-list', 'Connection closed with error: ' + e, 'red');
}); }
else {
addLine('message-list', 'Disconnected', 'green');
}
});
click('join-group', function(event) { connection.start()
let groupName = getText('message-text'); .then(function () {
invoke(connection, 'JoinGroup', groupName); isConnected = true;
}); addLine('message-list', 'Connected successfully', 'green');
})
.catch(function (err) {
addLine('message-list', err, 'red');
});
});
click('leave-group', function(event) { click('disconnect', function (event) {
let groupName = getText('message-text'); connectButton.disabled = false;
invoke(connection, 'LeaveGroup', groupName); disconnectButton.disabled = true;
}); connection.stop()
.then(function () {
isConnected = false;
});
});
click('groupmsg', function(event) { click('broadcast', function (event) {
let groupName = getText('target'); let data = getText('message-text');
let message = getText('private-message-text'); invoke(connection, 'Send', data);
invoke(connection, 'SendToGroup', groupName, message); });
});
click('send', function(event) { click('join-group', function (event) {
let data = getText('me-message-text'); let groupName = getText('message-text');
invoke(connection, 'Echo', data); invoke(connection, 'JoinGroup', groupName);
}); });
click('leave-group', function (event) {
let groupName = getText('message-text');
invoke(connection, 'LeaveGroup', groupName);
});
click('groupmsg', function (event) {
let groupName = getText('target');
let message = getText('private-message-text');
invoke(connection, 'SendToGroup', groupName, message);
});
click('send', function (event) {
let data = getText('me-message-text');
invoke(connection, 'Echo', data);
});
</script> </script>

View File

@ -1,9 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title></title> <title></title>
</head> </head>
<body> <body>
<h1 id="transportName">Unknown Transport</h1> <h1 id="transportName">Unknown Transport</h1>
@ -14,43 +16,42 @@
<ul id="messages"></ul> <ul id="messages"></ul>
<script type="text/javascript"> <script type="text/javascript">
if (typeof Promise === 'undefined') if (typeof Promise === 'undefined') {
{
document.write('<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>'); document.write('<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>');
} }
else else {
{
document.write('<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>'); document.write('<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>');
} }
</script> </script>
<script src="utils.js"></script> <script src="utils.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets; let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets;
document.getElementById('transportName').innerHTML = signalR.TransportType[transportType]; document.getElementById('transportName').innerHTML = signalR.TransportType[transportType];
let url = 'http://' + document.location.host + '/chat'; let url = 'http://' + document.location.host + '/chat';
let connection = new signalR.HttpConnection(url, { transport: transportType, logging: new signalR.ConsoleLogger(signalR.LogLevel.Information) }); let connection = new signalR.HttpConnection(url, { transport: transportType, logging: new signalR.ConsoleLogger(signalR.LogLevel.Trace) });
connection.onreceive = function(data) { connection.onreceive = function (data) {
let child = document.createElement('li'); let child = document.createElement('li');
child.innerText = data; child.innerText = data;
document.getElementById('messages').appendChild(child); document.getElementById('messages').appendChild(child);
}; };
document.getElementById('sendmessage').addEventListener('submit', function(event) { document.getElementById('sendmessage').addEventListener('submit', function (event) {
let data = document.getElementById('data').value; let data = document.getElementById('data').value;
connection.send(data); connection.send(data);
event.preventDefault(); event.preventDefault();
}); });
connection.start().then(function() { connection.start().then(function () {
console.log("Opened"); console.log("Opened");
}, function() { }, function () {
console.log("Error opening connection"); console.log("Error opening connection");
}); });
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,9 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title></title> <title></title>
</head> </head>
<body> <body>
<h1 id="transportName">Unknown Transport</h1> <h1 id="transportName">Unknown Transport</h1>
@ -24,14 +26,12 @@
<ul id="messages"></ul> <ul id="messages"></ul>
<script type="text/javascript"> <script type="text/javascript">
if (typeof Promise === 'undefined') if (typeof Promise === 'undefined') {
{
document.write( document.write(
'<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>' + '<script type="text/javascript" src="lib/signalr-client/signalr-clientES5.js"><\/script>' +
'<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocolES5.js"><\/script>'); '<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocolES5.js"><\/script>');
} }
else else {
{
document.write( document.write(
'<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>' + '<script type="text/javascript" src="lib/signalr-client/signalr-client.js"><\/script>' +
'<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocol.js"><\/script>'); '<script type="text/javascript" src="lib/signalr-client/signalr-msgpackprotocol.js"><\/script>');
@ -39,7 +39,7 @@
</script> </script>
<script src="utils.js"></script> <script src="utils.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
let resultsList = document.getElementById('resultsList'); let resultsList = document.getElementById('resultsList');
let channelButton = document.getElementById('channelButton'); let channelButton = document.getElementById('channelButton');
let observableButton = document.getElementById('observableButton'); let observableButton = document.getElementById('observableButton');
@ -48,7 +48,7 @@
let connectButton = document.getElementById('connectButton'); let connectButton = document.getElementById('connectButton');
let disconnectButton = document.getElementById('disconnectButton'); let disconnectButton = document.getElementById('disconnectButton');
let logger = new signalR.ConsoleLogger(signalR.LogLevel.Information); let logger = new signalR.ConsoleLogger(signalR.LogLevel.Trace);
let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets; let transportType = signalR.TransportType[getParameterByName('transport')] || signalR.TransportType.WebSockets;
let invocationCounter = 0; let invocationCounter = 0;
@ -107,11 +107,12 @@
addLine('resultsList', method + '(' + id + '):' + err, 'red'); addLine('resultsList', method + '(' + id + '):' + err, 'red');
}, },
complete: function () { complete: function () {
addLine('resultsList', method + '(' + id + '): complete', 'green'); addLine('resultsList', method + '(' + id + '): complete', 'green');
} }
}); });
} }
}); });
</script> </script>
</body> </body>
</html> </html>