HttpConnection.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. // Copyright (c) .NET Foundation. All rights reserved.
  2. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
  3. import { DefaultHttpClient } from "./DefaultHttpClient";
  4. import { HttpClient } from "./HttpClient";
  5. import { IConnection } from "./IConnection";
  6. import { IHttpConnectionOptions } from "./IHttpConnectionOptions";
  7. import { ILogger, LogLevel } from "./ILogger";
  8. import { HttpTransportType, ITransport, TransferFormat } from "./ITransport";
  9. import { LongPollingTransport } from "./LongPollingTransport";
  10. import { ServerSentEventsTransport } from "./ServerSentEventsTransport";
  11. import { Arg, createLogger } from "./Utils";
  12. import { WebSocketTransport } from "./WebSocketTransport";
  13. /** @private */
  14. const enum ConnectionState {
  15. Connecting,
  16. Connected,
  17. Disconnected,
  18. }
  19. /** @private */
  20. export interface INegotiateResponse {
  21. connectionId?: string;
  22. availableTransports?: IAvailableTransport[];
  23. url?: string;
  24. accessToken?: string;
  25. error?: string;
  26. }
  27. /** @private */
  28. export interface IAvailableTransport {
  29. transport: keyof typeof HttpTransportType;
  30. transferFormats: Array<keyof typeof TransferFormat>;
  31. }
  32. const MAX_REDIRECTS = 100;
  33. let WebSocketModule: any = null;
  34. let EventSourceModule: any = null;
  35. if (typeof window === "undefined" && typeof require !== "undefined") {
  36. // In order to ignore the dynamic require in webpack builds we need to do this magic
  37. // @ts-ignore: TS doesn't know about these names
  38. const requireFunc = typeof __webpack_require__ === "function" ? __non_webpack_require__ : require;
  39. WebSocketModule = requireFunc("ws");
  40. EventSourceModule = requireFunc("eventsource");
  41. }
  42. /** @private */
  43. export class HttpConnection implements IConnection {
  44. private connectionState: ConnectionState;
  45. private baseUrl: string;
  46. private readonly httpClient: HttpClient;
  47. private readonly logger: ILogger;
  48. private readonly options: IHttpConnectionOptions;
  49. private transport?: ITransport;
  50. private startPromise?: Promise<void>;
  51. private stopError?: Error;
  52. private accessTokenFactory?: () => string | Promise<string>;
  53. public readonly features: any = {};
  54. public onreceive: ((data: string | ArrayBuffer) => void) | null;
  55. public onclose: ((e?: Error) => void) | null;
  56. constructor(url: string, options: IHttpConnectionOptions = {}) {
  57. Arg.isRequired(url, "url");
  58. this.logger = createLogger(options.logger);
  59. this.baseUrl = this.resolveUrl(url);
  60. options = options || {};
  61. options.logMessageContent = options.logMessageContent || false;
  62. const isNode = typeof window === "undefined";
  63. if (!isNode && typeof WebSocket !== "undefined" && !options.WebSocket) {
  64. options.WebSocket = WebSocket;
  65. } else if (isNode && !options.WebSocket) {
  66. if (WebSocketModule) {
  67. options.WebSocket = WebSocketModule;
  68. }
  69. }
  70. if (!isNode && typeof EventSource !== "undefined" && !options.EventSource) {
  71. options.EventSource = EventSource;
  72. } else if (isNode && !options.EventSource) {
  73. if (typeof EventSourceModule !== "undefined") {
  74. options.EventSource = EventSourceModule;
  75. }
  76. }
  77. this.httpClient = options.httpClient || new DefaultHttpClient(this.logger);
  78. this.connectionState = ConnectionState.Disconnected;
  79. this.options = options;
  80. this.onreceive = null;
  81. this.onclose = null;
  82. }
  83. public start(): Promise<void>;
  84. public start(transferFormat: TransferFormat): Promise<void>;
  85. public start(transferFormat?: TransferFormat): Promise<void> {
  86. transferFormat = transferFormat || TransferFormat.Binary;
  87. Arg.isIn(transferFormat, TransferFormat, "transferFormat");
  88. this.logger.log(LogLevel.Debug, `Starting connection with transfer format '${TransferFormat[transferFormat]}'.`);
  89. if (this.connectionState !== ConnectionState.Disconnected) {
  90. return Promise.reject(new Error("Cannot start a connection that is not in the 'Disconnected' state."));
  91. }
  92. this.connectionState = ConnectionState.Connecting;
  93. this.startPromise = this.startInternal(transferFormat);
  94. return this.startPromise;
  95. }
  96. public send(data: string | ArrayBuffer): Promise<void> {
  97. if (this.connectionState !== ConnectionState.Connected) {
  98. throw new Error("Cannot send data if the connection is not in the 'Connected' State.");
  99. }
  100. // Transport will not be null if state is connected
  101. return this.transport!.send(data);
  102. }
  103. public async stop(error?: Error): Promise<void> {
  104. this.connectionState = ConnectionState.Disconnected;
  105. // Set error as soon as possible otherwise there is a race between
  106. // the transport closing and providing an error and the error from a close message
  107. // We would prefer the close message error.
  108. this.stopError = error;
  109. try {
  110. await this.startPromise;
  111. } catch (e) {
  112. // this exception is returned to the user as a rejected Promise from the start method
  113. }
  114. // The transport's onclose will trigger stopConnection which will run our onclose event.
  115. if (this.transport) {
  116. await this.transport.stop();
  117. this.transport = undefined;
  118. }
  119. }
  120. private async startInternal(transferFormat: TransferFormat): Promise<void> {
  121. // Store the original base url and the access token factory since they may change
  122. // as part of negotiating
  123. let url = this.baseUrl;
  124. this.accessTokenFactory = this.options.accessTokenFactory;
  125. try {
  126. if (this.options.skipNegotiation) {
  127. if (this.options.transport === HttpTransportType.WebSockets) {
  128. // No need to add a connection ID in this case
  129. this.transport = this.constructTransport(HttpTransportType.WebSockets);
  130. // We should just call connect directly in this case.
  131. // No fallback or negotiate in this case.
  132. await this.transport!.connect(url, transferFormat);
  133. } else {
  134. throw Error("Negotiation can only be skipped when using the WebSocket transport directly.");
  135. }
  136. } else {
  137. let negotiateResponse: INegotiateResponse | null = null;
  138. let redirects = 0;
  139. do {
  140. negotiateResponse = await this.getNegotiationResponse(url);
  141. // the user tries to stop the connection when it is being started
  142. if (this.connectionState === ConnectionState.Disconnected) {
  143. return;
  144. }
  145. if (negotiateResponse.error) {
  146. throw Error(negotiateResponse.error);
  147. }
  148. if ((negotiateResponse as any).ProtocolVersion) {
  149. throw Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");
  150. }
  151. if (negotiateResponse.url) {
  152. url = negotiateResponse.url;
  153. }
  154. if (negotiateResponse.accessToken) {
  155. // Replace the current access token factory with one that uses
  156. // the returned access token
  157. const accessToken = negotiateResponse.accessToken;
  158. this.accessTokenFactory = () => accessToken;
  159. }
  160. redirects++;
  161. }
  162. while (negotiateResponse.url && redirects < MAX_REDIRECTS);
  163. if (redirects === MAX_REDIRECTS && negotiateResponse.url) {
  164. throw Error("Negotiate redirection limit exceeded.");
  165. }
  166. await this.createTransport(url, this.options.transport, negotiateResponse, transferFormat);
  167. }
  168. if (this.transport instanceof LongPollingTransport) {
  169. this.features.inherentKeepAlive = true;
  170. }
  171. this.transport!.onreceive = this.onreceive;
  172. this.transport!.onclose = (e) => this.stopConnection(e);
  173. // only change the state if we were connecting to not overwrite
  174. // the state if the connection is already marked as Disconnected
  175. this.changeState(ConnectionState.Connecting, ConnectionState.Connected);
  176. } catch (e) {
  177. this.logger.log(LogLevel.Error, "Failed to start the connection: " + e);
  178. this.connectionState = ConnectionState.Disconnected;
  179. this.transport = undefined;
  180. throw e;
  181. }
  182. }
  183. private async getNegotiationResponse(url: string): Promise<INegotiateResponse> {
  184. let headers;
  185. if (this.accessTokenFactory) {
  186. const token = await this.accessTokenFactory();
  187. if (token) {
  188. headers = {
  189. ["Authorization"]: `Bearer ${token}`,
  190. };
  191. }
  192. }
  193. const negotiateUrl = this.resolveNegotiateUrl(url);
  194. this.logger.log(LogLevel.Debug, `Sending negotiation request: ${negotiateUrl}.`);
  195. try {
  196. const response = await this.httpClient.post(negotiateUrl, {
  197. content: "",
  198. headers,
  199. });
  200. if (response.statusCode !== 200) {
  201. throw Error(`Unexpected status code returned from negotiate ${response.statusCode}`);
  202. }
  203. return JSON.parse(response.content as string) as INegotiateResponse;
  204. } catch (e) {
  205. this.logger.log(LogLevel.Error, "Failed to complete negotiation with the server: " + e);
  206. throw e;
  207. }
  208. }
  209. private createConnectUrl(url: string, connectionId: string | null | undefined) {
  210. if (!connectionId) {
  211. return url;
  212. }
  213. return url + (url.indexOf("?") === -1 ? "?" : "&") + `id=${connectionId}`;
  214. }
  215. private async createTransport(url: string, requestedTransport: HttpTransportType | ITransport | undefined, negotiateResponse: INegotiateResponse, requestedTransferFormat: TransferFormat): Promise<void> {
  216. let connectUrl = this.createConnectUrl(url, negotiateResponse.connectionId);
  217. if (this.isITransport(requestedTransport)) {
  218. this.logger.log(LogLevel.Debug, "Connection was provided an instance of ITransport, using that directly.");
  219. this.transport = requestedTransport;
  220. await this.transport.connect(connectUrl, requestedTransferFormat);
  221. // only change the state if we were connecting to not overwrite
  222. // the state if the connection is already marked as Disconnected
  223. this.changeState(ConnectionState.Connecting, ConnectionState.Connected);
  224. return;
  225. }
  226. const transports = negotiateResponse.availableTransports || [];
  227. for (const endpoint of transports) {
  228. this.connectionState = ConnectionState.Connecting;
  229. const transport = this.resolveTransport(endpoint, requestedTransport, requestedTransferFormat);
  230. if (typeof transport === "number") {
  231. this.transport = this.constructTransport(transport);
  232. if (!negotiateResponse.connectionId) {
  233. negotiateResponse = await this.getNegotiationResponse(url);
  234. connectUrl = this.createConnectUrl(url, negotiateResponse.connectionId);
  235. }
  236. try {
  237. await this.transport!.connect(connectUrl, requestedTransferFormat);
  238. this.changeState(ConnectionState.Connecting, ConnectionState.Connected);
  239. return;
  240. } catch (ex) {
  241. this.logger.log(LogLevel.Error, `Failed to start the transport '${HttpTransportType[transport]}': ${ex}`);
  242. this.connectionState = ConnectionState.Disconnected;
  243. negotiateResponse.connectionId = undefined;
  244. }
  245. }
  246. }
  247. throw new Error("Unable to initialize any of the available transports.");
  248. }
  249. private constructTransport(transport: HttpTransportType) {
  250. switch (transport) {
  251. case HttpTransportType.WebSockets:
  252. if (!this.options.WebSocket) {
  253. throw new Error("'WebSocket' is not supported in your environment.");
  254. }
  255. return new WebSocketTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.WebSocket);
  256. case HttpTransportType.ServerSentEvents:
  257. if (!this.options.EventSource) {
  258. throw new Error("'EventSource' is not supported in your environment.");
  259. }
  260. return new ServerSentEventsTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false, this.options.EventSource);
  261. case HttpTransportType.LongPolling:
  262. return new LongPollingTransport(this.httpClient, this.accessTokenFactory, this.logger, this.options.logMessageContent || false);
  263. default:
  264. throw new Error(`Unknown transport: ${transport}.`);
  265. }
  266. }
  267. private resolveTransport(endpoint: IAvailableTransport, requestedTransport: HttpTransportType | undefined, requestedTransferFormat: TransferFormat): HttpTransportType | null {
  268. const transport = HttpTransportType[endpoint.transport];
  269. if (transport === null || transport === undefined) {
  270. this.logger.log(LogLevel.Debug, `Skipping transport '${endpoint.transport}' because it is not supported by this client.`);
  271. } else {
  272. const transferFormats = endpoint.transferFormats.map((s) => TransferFormat[s]);
  273. if (transportMatches(requestedTransport, transport)) {
  274. if (transferFormats.indexOf(requestedTransferFormat) >= 0) {
  275. if ((transport === HttpTransportType.WebSockets && !this.options.WebSocket) ||
  276. (transport === HttpTransportType.ServerSentEvents && !this.options.EventSource)) {
  277. this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it is not supported in your environment.'`);
  278. } else {
  279. this.logger.log(LogLevel.Debug, `Selecting transport '${HttpTransportType[transport]}'.`);
  280. return transport;
  281. }
  282. } else {
  283. this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it does not support the requested transfer format '${TransferFormat[requestedTransferFormat]}'.`);
  284. }
  285. } else {
  286. this.logger.log(LogLevel.Debug, `Skipping transport '${HttpTransportType[transport]}' because it was disabled by the client.`);
  287. }
  288. }
  289. return null;
  290. }
  291. private isITransport(transport: any): transport is ITransport {
  292. return transport && typeof (transport) === "object" && "connect" in transport;
  293. }
  294. private changeState(from: ConnectionState, to: ConnectionState): boolean {
  295. if (this.connectionState === from) {
  296. this.connectionState = to;
  297. return true;
  298. }
  299. return false;
  300. }
  301. private stopConnection(error?: Error): void {
  302. this.transport = undefined;
  303. // If we have a stopError, it takes precedence over the error from the transport
  304. error = this.stopError || error;
  305. if (error) {
  306. this.logger.log(LogLevel.Error, `Connection disconnected with error '${error}'.`);
  307. } else {
  308. this.logger.log(LogLevel.Information, "Connection disconnected.");
  309. }
  310. this.connectionState = ConnectionState.Disconnected;
  311. if (this.onclose) {
  312. this.onclose(error);
  313. }
  314. }
  315. private resolveUrl(url: string): string {
  316. // startsWith is not supported in IE
  317. if (url.lastIndexOf("https://", 0) === 0 || url.lastIndexOf("http://", 0) === 0) {
  318. return url;
  319. }
  320. if (typeof window === "undefined" || !window || !window.document) {
  321. throw new Error(`Cannot resolve '${url}'.`);
  322. }
  323. // Setting the url to the href propery of an anchor tag handles normalization
  324. // for us. There are 3 main cases.
  325. // 1. Relative path normalization e.g "b" -> "http://localhost:5000/a/b"
  326. // 2. Absolute path normalization e.g "/a/b" -> "http://localhost:5000/a/b"
  327. // 3. Networkpath reference normalization e.g "//localhost:5000/a/b" -> "http://localhost:5000/a/b"
  328. const aTag = window.document.createElement("a");
  329. aTag.href = url;
  330. this.logger.log(LogLevel.Information, `Normalizing '${url}' to '${aTag.href}'.`);
  331. return aTag.href;
  332. }
  333. private resolveNegotiateUrl(url: string): string {
  334. const index = url.indexOf("?");
  335. let negotiateUrl = url.substring(0, index === -1 ? url.length : index);
  336. if (negotiateUrl[negotiateUrl.length - 1] !== "/") {
  337. negotiateUrl += "/";
  338. }
  339. negotiateUrl += "negotiate";
  340. negotiateUrl += index === -1 ? "" : url.substring(index);
  341. return negotiateUrl;
  342. }
  343. }
  344. function transportMatches(requestedTransport: HttpTransportType | undefined, actualTransport: HttpTransportType) {
  345. return !requestedTransport || ((actualTransport & requestedTransport) !== 0);
  346. }