From f4d898ee9652939a4550a41ac0e8143056154c0a Mon Sep 17 00:00:00 2001 From: Damien Arrachequesne Date: Fri, 31 May 2024 16:56:25 +0200 Subject: [PATCH] feat: allow to provide a list of transport implementations This commit adds the ability to provide a list of transport implementations to use when connecting to an Engine.IO server. This can be used to use HTTP long-polling based on `fetch()`, instead of the default implementation based on the `XMLHttpRequest` object. ``` import { Socket, Fetch, WebSocket } from "engine.io-client"; const socket = new Socket({ transports: [Fetch, WebSocket] }); ``` This is useful in some environments that do not provide a `XMLHttpRequest` object, like Chrome extension background scripts. > XMLHttpRequest() can't be called from a service worker, extension or otherwise. Replace calls from your background script to XMLHttpRequest() with calls to global fetch(). Source: https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers#replace-xmlhttprequest Related: - https://github.com/socketio/engine.io-client/issues/716 - https://github.com/socketio/socket.io/issues/4980 This is also useful when running the client with Deno or Bun, as it allows to use the built-in `fetch()` method and `WebSocket` object, instead of using the `xmlhttprequest-ssl` and `ws` Node.js packages. Related: https://github.com/socketio/socket.io-deno/issues/12 This feature also comes with the ability to exclude the code related to unused transports (a.k.a. "tree-shaking"): ```js import { SocketWithoutUpgrade, WebSocket } from "engine.io-client"; const socket = new SocketWithoutUpgrade({ transports: [WebSocket] }); ``` In that case, the code related to HTTP long-polling and WebTransport will be excluded from the final bundle. Related: https://github.com/socketio/socket.io/discussions/4393 --- lib/globalThis.browser.ts | 9 - lib/globalThis.ts | 1 - .../xmlhttprequest.ts => globals.node.ts} | 6 +- ...cket-constructor.browser.ts => globals.ts} | 16 +- lib/index.ts | 12 +- lib/socket.ts | 433 +++++++++++------- lib/transport.ts | 1 + lib/transports/index.ts | 4 +- lib/transports/polling-fetch.ts | 7 +- lib/transports/polling-xhr.node.ts | 22 + lib/transports/polling-xhr.ts | 112 +++-- lib/transports/websocket-constructor.ts | 6 - lib/transports/websocket.node.ts | 39 ++ lib/transports/websocket.ts | 96 ++-- lib/transports/webtransport.ts | 6 +- lib/transports/xmlhttprequest.browser.ts | 25 - lib/util.ts | 2 +- package.json | 12 +- support/package.cjs.json | 6 +- support/package.esm.json | 6 +- test/node.js | 2 +- test/socket.js | 26 +- test/xmlhttprequest.js | 24 +- 23 files changed, 538 insertions(+), 335 deletions(-) delete mode 100644 lib/globalThis.browser.ts delete mode 100644 lib/globalThis.ts rename lib/{transports/xmlhttprequest.ts => globals.node.ts} (94%) rename lib/{transports/websocket-constructor.browser.ts => globals.ts} (57%) create mode 100644 lib/transports/polling-xhr.node.ts delete mode 100644 lib/transports/websocket-constructor.ts create mode 100644 lib/transports/websocket.node.ts delete mode 100644 lib/transports/xmlhttprequest.browser.ts diff --git a/lib/globalThis.browser.ts b/lib/globalThis.browser.ts deleted file mode 100644 index 0962057d1..000000000 --- a/lib/globalThis.browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const globalThisShim = (() => { - if (typeof self !== "undefined") { - return self; - } else if (typeof window !== "undefined") { - return window; - } else { - return Function("return this")(); - } -})(); diff --git a/lib/globalThis.ts b/lib/globalThis.ts deleted file mode 100644 index 27a616bd4..000000000 --- a/lib/globalThis.ts +++ /dev/null @@ -1 +0,0 @@ -export const globalThisShim = global; diff --git a/lib/transports/xmlhttprequest.ts b/lib/globals.node.ts similarity index 94% rename from lib/transports/xmlhttprequest.ts rename to lib/globals.node.ts index 6d52c45a3..2ca95f9c7 100644 --- a/lib/transports/xmlhttprequest.ts +++ b/lib/globals.node.ts @@ -1,6 +1,6 @@ -import * as XMLHttpRequestModule from "xmlhttprequest-ssl"; - -export const XHR = XMLHttpRequestModule.default || XMLHttpRequestModule; +export const nextTick = process.nextTick; +export const globalThisShim = global; +export const defaultBinaryType = "nodebuffer"; export function createCookieJar() { return new CookieJar(); diff --git a/lib/transports/websocket-constructor.browser.ts b/lib/globals.ts similarity index 57% rename from lib/transports/websocket-constructor.browser.ts rename to lib/globals.ts index 99716d006..902540e19 100644 --- a/lib/transports/websocket-constructor.browser.ts +++ b/lib/globals.ts @@ -1,5 +1,3 @@ -import { globalThisShim as globalThis } from "../globalThis.js"; - export const nextTick = (() => { const isPromiseAvailable = typeof Promise === "function" && typeof Promise.resolve === "function"; @@ -10,6 +8,16 @@ export const nextTick = (() => { } })(); -export const WebSocket = globalThis.WebSocket || globalThis.MozWebSocket; -export const usingBrowserWebSocket = true; +export const globalThisShim = (() => { + if (typeof self !== "undefined") { + return self; + } else if (typeof window !== "undefined") { + return window; + } else { + return Function("return this")(); + } +})(); + export const defaultBinaryType = "arraybuffer"; + +export function createCookieJar() {} diff --git a/lib/index.ts b/lib/index.ts index 2688b8bd0..80c5041fb 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,13 +1,21 @@ import { Socket } from "./socket.js"; export { Socket }; -export { SocketOptions } from "./socket.js"; +export { + SocketOptions, + SocketWithoutUpgrade, + SocketWithUpgrade, +} from "./socket.js"; export const protocol = Socket.protocol; export { Transport, TransportError } from "./transport.js"; export { transports } from "./transports/index.js"; export { installTimerFunctions } from "./util.js"; export { parse } from "./contrib/parseuri.js"; -export { nextTick } from "./transports/websocket-constructor.js"; +export { nextTick } from "./globals.node.js"; export { Fetch } from "./transports/polling-fetch.js"; +export { XHR as NodeXHR } from "./transports/polling-xhr.node.js"; export { XHR } from "./transports/polling-xhr.js"; +export { WS as NodeWebSocket } from "./transports/websocket.node.js"; +export { WS as WebSocket } from "./transports/websocket.js"; +export { WT as WebTransport } from "./transports/webtransport.js"; diff --git a/lib/socket.ts b/lib/socket.ts index 13f6a9ef2..407ea61b0 100644 --- a/lib/socket.ts +++ b/lib/socket.ts @@ -1,13 +1,13 @@ -import { transports } from "./transports/index.js"; +import { transports as DEFAULT_TRANSPORTS } from "./transports/index.js"; import { installTimerFunctions, byteLength } from "./util.js"; import { decode } from "./contrib/parseqs.js"; import { parse } from "./contrib/parseuri.js"; -import debugModule from "debug"; // debug() import { Emitter } from "@socket.io/component-emitter"; import { protocol } from "engine.io-parser"; import type { Packet, BinaryType, PacketType, RawData } from "engine.io-parser"; import { CloseDetails, Transport } from "./transport.js"; -import { defaultBinaryType } from "./transports/websocket-constructor.js"; +import { defaultBinaryType } from "./globals.node.js"; +import debugModule from "debug"; // debug() const debug = debugModule("engine.io-client:socket"); // debug() @@ -81,7 +81,7 @@ export interface SocketOptions { * * @default ['polling','websocket', 'webtransport'] */ - transports?: string[]; + transports?: string[] | TransportCtor[]; /** * Whether all the transports should be tested, instead of just the first one. @@ -231,6 +231,12 @@ export interface SocketOptions { protocols?: string | string[]; } +type TransportCtor = { new (o: any): Transport }; + +type BaseSocketOptions = Omit & { + transports: TransportCtor[]; +}; + interface HandshakeData { sid: string; upgrades: string[]; @@ -264,7 +270,30 @@ interface WriteOptions { compress?: boolean; } -export class Socket extends Emitter< +/** + * This class provides a WebSocket-like interface to connect to an Engine.IO server. The connection will be established + * with one of the available low-level transports, like HTTP long-polling, WebSocket or WebTransport. + * + * This class comes without upgrade mechanism, which means that it will keep the first low-level transport that + * successfully establishes the connection. + * + * In order to allow tree-shaking, there are no transports included, that's why the `transports` option is mandatory. + * + * @example + * import { SocketWithoutUpgrade, WebSocket } from "engine.io-client"; + * + * const socket = new SocketWithoutUpgrade({ + * transports: [WebSocket] + * }); + * + * socket.on("open", () => { + * socket.send("hello"); + * }); + * + * @see SocketWithUpgrade + * @see Socket + */ +export class SocketWithoutUpgrade extends Emitter< Record, Record, SocketReservedEvents @@ -275,23 +304,24 @@ export class Socket extends Emitter< public readyState: SocketState; public writeBuffer: Packet[] = []; + protected readonly opts: BaseSocketOptions; + protected readonly transports: string[]; + protected upgrading: boolean; + protected setTimeoutFn: typeof setTimeout; + private prevBufferLen: number; - private upgrades: string[]; private pingInterval: number; private pingTimeout: number; private pingTimeoutTimer: NodeJS.Timer; - private setTimeoutFn: typeof setTimeout; private clearTimeoutFn: typeof clearTimeout; private readonly beforeunloadEventListener: () => void; private readonly offlineEventListener: () => void; - private upgrading: boolean; private maxPayload?: number; - private readonly opts: Partial; private readonly secure: boolean; private readonly hostname: string; private readonly port: string | number; - private readonly transports: string[]; + private readonly transportsByName: Record; static priorWebsocketSuccess: boolean; static protocol = protocol; @@ -302,9 +332,7 @@ export class Socket extends Emitter< * @param {String|Object} uri - uri or options * @param {Object} opts - options */ - constructor(uri?: string, opts?: SocketOptions); - constructor(opts: SocketOptions); - constructor(uri?: string | SocketOptions, opts: SocketOptions = {}) { + constructor(uri: string | BaseSocketOptions, opts: BaseSocketOptions) { super(); if (uri && "object" === typeof uri) { @@ -346,11 +374,14 @@ export class Socket extends Emitter< ? "443" : "80"); - this.transports = opts.transports || [ - "polling", - "websocket", - "webtransport", - ]; + this.transports = []; + this.transportsByName = {}; + opts.transports.forEach((t) => { + const transportName = t.prototype.name; + this.transports.push(transportName); + this.transportsByName[transportName] = t; + }); + this.writeBuffer = []; this.prevBufferLen = 0; @@ -383,7 +414,6 @@ export class Socket extends Emitter< // set on handshake this.id = null; - this.upgrades = null; this.pingInterval = null; this.pingTimeout = null; @@ -424,7 +454,7 @@ export class Socket extends Emitter< * @return {Transport} * @private */ - private createTransport(name: string) { + protected createTransport(name: string) { debug('creating transport "%s"', name); const query: any = Object.assign({}, this.opts.query); @@ -452,7 +482,7 @@ export class Socket extends Emitter< debug("options: %j", opts); - return new transports[name](opts); + return new this.transportsByName[name](opts); } /** @@ -471,7 +501,7 @@ export class Socket extends Emitter< const transportName = this.opts.rememberUpgrade && - Socket.priorWebsocketSuccess && + SocketWithoutUpgrade.priorWebsocketSuccess && this.transports.indexOf("websocket") !== -1 ? "websocket" : this.transports[0]; @@ -487,7 +517,7 @@ export class Socket extends Emitter< * * @private */ - private setTransport(transport: Transport) { + protected setTransport(transport: Transport) { debug("setting transport %s", transport.name); if (this.transport) { @@ -506,153 +536,18 @@ export class Socket extends Emitter< .on("close", (reason) => this.onClose("transport close", reason)); } - /** - * Probes a transport. - * - * @param {String} name - transport name - * @private - */ - private probe(name: string) { - debug('probing transport "%s"', name); - let transport = this.createTransport(name); - let failed = false; - - Socket.priorWebsocketSuccess = false; - - const onTransportOpen = () => { - if (failed) return; - - debug('probe transport "%s" opened', name); - transport.send([{ type: "ping", data: "probe" }]); - transport.once("packet", (msg) => { - if (failed) return; - if ("pong" === msg.type && "probe" === msg.data) { - debug('probe transport "%s" pong', name); - this.upgrading = true; - this.emitReserved("upgrading", transport); - if (!transport) return; - Socket.priorWebsocketSuccess = "websocket" === transport.name; - - debug('pausing current transport "%s"', this.transport.name); - this.transport.pause(() => { - if (failed) return; - if ("closed" === this.readyState) return; - debug("changing transport and sending upgrade packet"); - - cleanup(); - - this.setTransport(transport); - transport.send([{ type: "upgrade" }]); - this.emitReserved("upgrade", transport); - transport = null; - this.upgrading = false; - this.flush(); - }); - } else { - debug('probe transport "%s" failed', name); - const err = new Error("probe error"); - // @ts-ignore - err.transport = transport.name; - this.emitReserved("upgradeError", err); - } - }); - }; - - function freezeTransport() { - if (failed) return; - - // Any callback called by transport should be ignored since now - failed = true; - - cleanup(); - - transport.close(); - transport = null; - } - - // Handle any error that happens while probing - const onerror = (err) => { - const error = new Error("probe error: " + err); - // @ts-ignore - error.transport = transport.name; - - freezeTransport(); - - debug('probe transport "%s" failed because of error: %s', name, err); - - this.emitReserved("upgradeError", error); - }; - - function onTransportClose() { - onerror("transport closed"); - } - - // When the socket is closed while we're probing - function onclose() { - onerror("socket closed"); - } - - // When the socket is upgraded while we're probing - function onupgrade(to) { - if (transport && to.name !== transport.name) { - debug('"%s" works - aborting "%s"', to.name, transport.name); - freezeTransport(); - } - } - - // Remove all listeners on the transport and on self - const cleanup = () => { - transport.removeListener("open", onTransportOpen); - transport.removeListener("error", onerror); - transport.removeListener("close", onTransportClose); - this.off("close", onclose); - this.off("upgrading", onupgrade); - }; - - transport.once("open", onTransportOpen); - transport.once("error", onerror); - transport.once("close", onTransportClose); - - this.once("close", onclose); - this.once("upgrading", onupgrade); - - if ( - this.upgrades.indexOf("webtransport") !== -1 && - name !== "webtransport" - ) { - // favor WebTransport - this.setTimeoutFn(() => { - if (!failed) { - transport.open(); - } - }, 200); - } else { - transport.open(); - } - } - /** * Called when connection is deemed open. * * @private */ - private onOpen() { + protected onOpen() { debug("socket open"); this.readyState = "open"; - Socket.priorWebsocketSuccess = "websocket" === this.transport.name; + SocketWithoutUpgrade.priorWebsocketSuccess = + "websocket" === this.transport.name; this.emitReserved("open"); this.flush(); - - // we check for `readyState` in case an `open` - // listener already closed the socket - if ("open" === this.readyState && this.opts.upgrade) { - debug("starting upgrade probes"); - let i = 0; - const l = this.upgrades.length; - for (; i < l; i++) { - this.probe(this.upgrades[i]); - } - } } /** @@ -708,11 +603,10 @@ export class Socket extends Emitter< * @param {Object} data - handshake obj * @private */ - private onHandshake(data: HandshakeData) { + protected onHandshake(data: HandshakeData) { this.emitReserved("handshake", data); this.id = data.sid; this.transport.query.sid = data.sid; - this.upgrades = this.filterUpgrades(data.upgrades); this.pingInterval = data.pingInterval; this.pingTimeout = data.pingTimeout; this.maxPayload = data.maxPayload; @@ -762,7 +656,7 @@ export class Socket extends Emitter< * * @private */ - private flush() { + protected flush() { if ( "closed" !== this.readyState && this.transport.writable && @@ -928,7 +822,7 @@ export class Socket extends Emitter< */ private onError(err: Error) { debug("socket error %j", err); - Socket.priorWebsocketSuccess = false; + SocketWithoutUpgrade.priorWebsocketSuccess = false; if ( this.opts.tryAllTransports && @@ -993,6 +887,177 @@ export class Socket extends Emitter< this.prevBufferLen = 0; } } +} + +/** + * This class provides a WebSocket-like interface to connect to an Engine.IO server. The connection will be established + * with one of the available low-level transports, like HTTP long-polling, WebSocket or WebTransport. + * + * This class comes with an upgrade mechanism, which means that once the connection is established with the first + * low-level transport, it will try to upgrade to a better transport. + * + * In order to allow tree-shaking, there are no transports included, that's why the `transports` option is mandatory. + * + * @example + * import { SocketWithUpgrade, WebSocket } from "engine.io-client"; + * + * const socket = new SocketWithUpgrade({ + * transports: [WebSocket] + * }); + * + * socket.on("open", () => { + * socket.send("hello"); + * }); + * + * @see SocketWithoutUpgrade + * @see Socket + */ +export class SocketWithUpgrade extends SocketWithoutUpgrade { + private upgrades: string[] = []; + + override onOpen() { + super.onOpen(); + + if ("open" === this.readyState && this.opts.upgrade) { + debug("starting upgrade probes"); + let i = 0; + const l = this.upgrades.length; + for (; i < l; i++) { + this.probe(this.upgrades[i]); + } + } + } + + /** + * Probes a transport. + * + * @param {String} name - transport name + * @private + */ + private probe(name: string) { + debug('probing transport "%s"', name); + let transport = this.createTransport(name); + let failed = false; + + SocketWithoutUpgrade.priorWebsocketSuccess = false; + + const onTransportOpen = () => { + if (failed) return; + + debug('probe transport "%s" opened', name); + transport.send([{ type: "ping", data: "probe" }]); + transport.once("packet", (msg) => { + if (failed) return; + if ("pong" === msg.type && "probe" === msg.data) { + debug('probe transport "%s" pong', name); + this.upgrading = true; + this.emitReserved("upgrading", transport); + if (!transport) return; + SocketWithoutUpgrade.priorWebsocketSuccess = + "websocket" === transport.name; + + debug('pausing current transport "%s"', this.transport.name); + this.transport.pause(() => { + if (failed) return; + if ("closed" === this.readyState) return; + debug("changing transport and sending upgrade packet"); + + cleanup(); + + this.setTransport(transport); + transport.send([{ type: "upgrade" }]); + this.emitReserved("upgrade", transport); + transport = null; + this.upgrading = false; + this.flush(); + }); + } else { + debug('probe transport "%s" failed', name); + const err = new Error("probe error"); + // @ts-ignore + err.transport = transport.name; + this.emitReserved("upgradeError", err); + } + }); + }; + + function freezeTransport() { + if (failed) return; + + // Any callback called by transport should be ignored since now + failed = true; + + cleanup(); + + transport.close(); + transport = null; + } + + // Handle any error that happens while probing + const onerror = (err) => { + const error = new Error("probe error: " + err); + // @ts-ignore + error.transport = transport.name; + + freezeTransport(); + + debug('probe transport "%s" failed because of error: %s', name, err); + + this.emitReserved("upgradeError", error); + }; + + function onTransportClose() { + onerror("transport closed"); + } + + // When the socket is closed while we're probing + function onclose() { + onerror("socket closed"); + } + + // When the socket is upgraded while we're probing + function onupgrade(to) { + if (transport && to.name !== transport.name) { + debug('"%s" works - aborting "%s"', to.name, transport.name); + freezeTransport(); + } + } + + // Remove all listeners on the transport and on self + const cleanup = () => { + transport.removeListener("open", onTransportOpen); + transport.removeListener("error", onerror); + transport.removeListener("close", onTransportClose); + this.off("close", onclose); + this.off("upgrading", onupgrade); + }; + + transport.once("open", onTransportOpen); + transport.once("error", onerror); + transport.once("close", onTransportClose); + + this.once("close", onclose); + this.once("upgrading", onupgrade); + + if ( + this.upgrades.indexOf("webtransport") !== -1 && + name !== "webtransport" + ) { + // favor WebTransport + this.setTimeoutFn(() => { + if (!failed) { + transport.open(); + } + }, 200); + } else { + transport.open(); + } + } + + override onHandshake(data: HandshakeData) { + this.upgrades = this.filterUpgrades(data.upgrades); + super.onHandshake(data); + } /** * Filters upgrades, returning only those matching client transports. @@ -1009,3 +1074,41 @@ export class Socket extends Emitter< return filteredUpgrades; } } + +/** + * This class provides a WebSocket-like interface to connect to an Engine.IO server. The connection will be established + * with one of the available low-level transports, like HTTP long-polling, WebSocket or WebTransport. + * + * This class comes with an upgrade mechanism, which means that once the connection is established with the first + * low-level transport, it will try to upgrade to a better transport. + * + * @example + * import { Socket } from "engine.io-client"; + * + * const socket = new Socket(); + * + * socket.on("open", () => { + * socket.send("hello"); + * }); + * + * @see SocketWithoutUpgrade + * @see SocketWithUpgrade + */ +export class Socket extends SocketWithUpgrade { + constructor(uri?: string, opts?: SocketOptions); + constructor(opts: SocketOptions); + constructor(uri?: string | SocketOptions, opts: SocketOptions = {}) { + const o = typeof uri === "object" ? uri : opts; + + if ( + !o.transports || + (o.transports && typeof o.transports[0] === "string") + ) { + o.transports = (o.transports || ["polling", "websocket", "webtransport"]) + .map((transportName) => DEFAULT_TRANSPORTS[transportName]) + .filter((t) => !!t); + } + + super(uri as string, o as BaseSocketOptions); + } +} diff --git a/lib/transport.ts b/lib/transport.ts index 09f8242b6..8c863ae69 100644 --- a/lib/transport.ts +++ b/lib/transport.ts @@ -64,6 +64,7 @@ export abstract class Transport extends Emitter< this.opts = opts; this.query = opts.query; this.socket = opts.socket; + this.supportsBinary = !opts.forceBase64; } /** diff --git a/lib/transports/index.ts b/lib/transports/index.ts index c4b49f881..d1f79c12d 100755 --- a/lib/transports/index.ts +++ b/lib/transports/index.ts @@ -1,5 +1,5 @@ -import { XHR } from "./polling-xhr.js"; -import { WS } from "./websocket.js"; +import { XHR } from "./polling-xhr.node.js"; +import { WS } from "./websocket.node.js"; import { WT } from "./webtransport.js"; export const transports = { diff --git a/lib/transports/polling-fetch.ts b/lib/transports/polling-fetch.ts index b9d1c2f0e..3857d2287 100644 --- a/lib/transports/polling-fetch.ts +++ b/lib/transports/polling-fetch.ts @@ -1,10 +1,13 @@ import { Polling } from "./polling.js"; -import { CookieJar, createCookieJar } from "./xmlhttprequest.js"; +import { CookieJar, createCookieJar } from "../globals.node.js"; /** - * HTTP long-polling based on `fetch()` + * HTTP long-polling based on the built-in `fetch()` method. + * + * Usage: browser, Node.js (since v18), Deno, Bun * * @see https://developer.mozilla.org/en-US/docs/Web/API/fetch + * @see https://caniuse.com/fetch */ export class Fetch extends Polling { private readonly cookieJar?: CookieJar; diff --git a/lib/transports/polling-xhr.node.ts b/lib/transports/polling-xhr.node.ts new file mode 100644 index 000000000..8c3f3b4ea --- /dev/null +++ b/lib/transports/polling-xhr.node.ts @@ -0,0 +1,22 @@ +import * as XMLHttpRequestModule from "xmlhttprequest-ssl"; +import { BaseXHR, Request, RequestOptions } from "./polling-xhr.js"; + +const XMLHttpRequest = XMLHttpRequestModule.default || XMLHttpRequestModule; + +/** + * HTTP long-polling based on the `XMLHttpRequest` object provided by the `xmlhttprequest-ssl` package. + * + * Usage: Node.js, Deno (compat), Bun (compat) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + */ +export class XHR extends BaseXHR { + request(opts: Record = {}) { + Object.assign(opts, { xd: this.xd, cookieJar: this.cookieJar }, this.opts); + return new Request( + (opts) => new XMLHttpRequest(opts), + this.uri(), + opts as RequestOptions + ); + } +} diff --git a/lib/transports/polling-xhr.ts b/lib/transports/polling-xhr.ts index 467309c74..acad1d490 100644 --- a/lib/transports/polling-xhr.ts +++ b/lib/transports/polling-xhr.ts @@ -1,37 +1,25 @@ import { Polling } from "./polling.js"; -import { - CookieJar, - createCookieJar, - XHR as XMLHttpRequest, -} from "./xmlhttprequest.js"; import { Emitter } from "@socket.io/component-emitter"; import type { SocketOptions } from "../socket.js"; import { installTimerFunctions, pick } from "../util.js"; -import { globalThisShim as globalThis } from "../globalThis.js"; +import { + globalThisShim as globalThis, + createCookieJar, +} from "../globals.node.js"; +import type { CookieJar } from "../globals.node.js"; import type { RawData } from "engine.io-parser"; +import { hasCORS } from "../contrib/has-cors.js"; import debugModule from "debug"; // debug() const debug = debugModule("engine.io-client:polling"); // debug() function empty() {} -const hasXHR2 = (function () { - const xhr = new XMLHttpRequest({ - xdomain: false, - }); - return null != xhr.responseType; -})(); - -/** - * HTTP long-polling based on `XMLHttpRequest` - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest - */ -export class XHR extends Polling { - private readonly xd: boolean; +export abstract class BaseXHR extends Polling { + protected readonly xd: boolean; + protected readonly cookieJar?: CookieJar; private pollXhr: any; - private cookieJar?: CookieJar; /** * XHR Polling constructor. @@ -56,11 +44,6 @@ export class XHR extends Polling { opts.hostname !== location.hostname) || port !== opts.port; } - /** - * XHR supports binary - */ - const forceBase64 = opts && opts.forceBase64; - this.supportsBinary = hasXHR2 && !forceBase64; if (this.opts.withCredentials) { this.cookieJar = createCookieJar(); @@ -70,13 +53,9 @@ export class XHR extends Polling { /** * Creates a request. * - * @param {String} method * @private */ - request(opts = {}) { - Object.assign(opts, { xd: this.xd, cookieJar: this.cookieJar }, this.opts); - return new Request(this.uri(), opts); - } + abstract request(opts?: Record); /** * Sends data. @@ -118,8 +97,19 @@ interface RequestReservedEvents { error: (err: number | Error, context: unknown) => void; // context should be typed as XMLHttpRequest, but this type is not available on non-browser platforms } -export class Request extends Emitter<{}, {}, RequestReservedEvents> { - private readonly opts: { xd; cookieJar: CookieJar } & SocketOptions; +export type RequestOptions = SocketOptions & { + method?: string; + data?: RawData; + xd: boolean; + cookieJar: CookieJar; +}; + +export class Request extends Emitter< + Record, + Record, + RequestReservedEvents +> { + private readonly opts: RequestOptions; private readonly method: string; private readonly uri: string; private readonly data: string | ArrayBuffer; @@ -137,7 +127,11 @@ export class Request extends Emitter<{}, {}, RequestReservedEvents> { * @param {Object} options * @package */ - constructor(uri, opts) { + constructor( + private readonly createRequest: (opts: RequestOptions) => XMLHttpRequest, + uri: string, + opts: RequestOptions + ) { super(); installTimerFunctions(this, opts); this.opts = opts; @@ -169,13 +163,14 @@ export class Request extends Emitter<{}, {}, RequestReservedEvents> { ); opts.xdomain = !!this.opts.xd; - const xhr = (this.xhr = new XMLHttpRequest(opts)); + const xhr = (this.xhr = this.createRequest(opts)); try { debug("xhr open %s: %s", this.method, this.uri); xhr.open(this.method, this.uri, true); try { if (this.opts.extraHeaders) { + // @ts-ignore xhr.setDisableHeaderCheck && xhr.setDisableHeaderCheck(true); for (let i in this.opts.extraHeaders) { if (this.opts.extraHeaders.hasOwnProperty(i)) { @@ -209,6 +204,7 @@ export class Request extends Emitter<{}, {}, RequestReservedEvents> { xhr.onreadystatechange = () => { if (xhr.readyState === 3) { this.opts.cookieJar?.parseCookies( + // @ts-ignore xhr.getResponseHeader("set-cookie") ); } @@ -325,3 +321,49 @@ function unloadHandler() { } } } + +const hasXHR2 = (function () { + const xhr = newRequest({ + xdomain: false, + }); + return xhr && xhr.responseType !== null; +})(); + +/** + * HTTP long-polling based on the built-in `XMLHttpRequest` object. + * + * Usage: browser + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + */ +export class XHR extends BaseXHR { + constructor(opts) { + super(opts); + const forceBase64 = opts && opts.forceBase64; + this.supportsBinary = hasXHR2 && !forceBase64; + } + + request(opts: Record = {}) { + Object.assign(opts, { xd: this.xd, cookieJar: this.cookieJar }, this.opts); + return new Request(newRequest, this.uri(), opts as RequestOptions); + } +} + +function newRequest(opts) { + const xdomain = opts.xdomain; + + // XMLHttpRequest can be disabled on IE + try { + if ("undefined" !== typeof XMLHttpRequest && (!xdomain || hasCORS)) { + return new XMLHttpRequest(); + } + } catch (e) {} + + if (!xdomain) { + try { + return new globalThis[["Active"].concat("Object").join("X")]( + "Microsoft.XMLHTTP" + ); + } catch (e) {} + } +} diff --git a/lib/transports/websocket-constructor.ts b/lib/transports/websocket-constructor.ts deleted file mode 100644 index 75b972788..000000000 --- a/lib/transports/websocket-constructor.ts +++ /dev/null @@ -1,6 +0,0 @@ -import ws from "ws"; - -export const WebSocket = ws; -export const usingBrowserWebSocket = false; -export const defaultBinaryType = "nodebuffer"; -export const nextTick = process.nextTick; diff --git a/lib/transports/websocket.node.ts b/lib/transports/websocket.node.ts new file mode 100644 index 000000000..1e968a0ab --- /dev/null +++ b/lib/transports/websocket.node.ts @@ -0,0 +1,39 @@ +import { WebSocket } from "ws"; +import type { Packet, RawData } from "engine.io-parser"; +import { BaseWS } from "./websocket.js"; + +/** + * WebSocket transport based on the `WebSocket` object provided by the `ws` package. + * + * Usage: Node.js, Deno (compat), Bun (compat) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket + * @see https://caniuse.com/mdn-api_websocket + */ +export class WS extends BaseWS { + createSocket( + uri: string, + protocols: string | string[] | undefined, + opts: Record + ) { + return new WebSocket(uri, protocols, opts); + } + + doWrite(packet: Packet, data: RawData) { + const opts: { compress?: boolean } = {}; + if (packet.options) { + opts.compress = packet.options.compress; + } + + if (this.opts.perMessageDeflate) { + const len = + // @ts-ignore + "string" === typeof data ? Buffer.byteLength(data) : data.length; + if (len < this.opts.perMessageDeflate.threshold) { + opts.compress = false; + } + } + + this.ws.send(data, opts); + } +} diff --git a/lib/transports/websocket.ts b/lib/transports/websocket.ts index d2b35e789..7e4a0a9ce 100644 --- a/lib/transports/websocket.ts +++ b/lib/transports/websocket.ts @@ -1,13 +1,10 @@ import { Transport } from "../transport.js"; import { yeast } from "../contrib/yeast.js"; import { pick } from "../util.js"; -import { - nextTick, - usingBrowserWebSocket, - WebSocket, -} from "./websocket-constructor.js"; -import debugModule from "debug"; // debug() import { encodePacket } from "engine.io-parser"; +import type { Packet, RawData } from "engine.io-parser"; +import { globalThisShim as globalThis, nextTick } from "../globals.node.js"; +import debugModule from "debug"; // debug() const debug = debugModule("engine.io-client:websocket"); // debug() @@ -17,24 +14,8 @@ const isReactNative = typeof navigator.product === "string" && navigator.product.toLowerCase() === "reactnative"; -/** - * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket - * @see https://caniuse.com/mdn-api_websocket - */ -export class WS extends Transport { - private ws: any; - - /** - * WebSocket transport constructor. - * - * @param {Object} opts - connection options - * @protected - */ - constructor(opts) { - super(opts); - - this.supportsBinary = !opts.forceBase64; - } +export abstract class BaseWS extends Transport { + protected ws: any; override get name() { return "websocket"; @@ -71,12 +52,7 @@ export class WS extends Transport { } try { - this.ws = - usingBrowserWebSocket && !isReactNative - ? protocols - ? new WebSocket(uri, protocols) - : new WebSocket(uri) - : new WebSocket(uri, protocols, opts); + this.ws = this.createSocket(uri, protocols, opts); } catch (err) { return this.emitReserved("error", err); } @@ -86,6 +62,12 @@ export class WS extends Transport { this.addEventListeners(); } + abstract createSocket( + uri: string, + protocols: string | string[] | undefined, + opts: Record + ); + /** * Adds event listeners to the socket * @@ -117,33 +99,11 @@ export class WS extends Transport { const lastPacket = i === packets.length - 1; encodePacket(packet, this.supportsBinary, (data) => { - // always create a new object (GH-437) - const opts: { compress?: boolean } = {}; - if (!usingBrowserWebSocket) { - if (packet.options) { - opts.compress = packet.options.compress; - } - - if (this.opts.perMessageDeflate) { - const len = - // @ts-ignore - "string" === typeof data ? Buffer.byteLength(data) : data.length; - if (len < this.opts.perMessageDeflate.threshold) { - opts.compress = false; - } - } - } - // Sometimes the websocket has already been closed but the browser didn't // have a chance of informing us about it yet, in that case send will // throw an error try { - if (usingBrowserWebSocket) { - // TypeError is thrown when passing the second argument on Safari - this.ws.send(data); - } else { - this.ws.send(data, opts); - } + this.doWrite(packet, data); } catch (e) { debug("websocket closed before onclose event"); } @@ -160,6 +120,8 @@ export class WS extends Transport { } } + abstract doWrite(packet: Packet, data: RawData); + override doClose() { if (typeof this.ws !== "undefined") { this.ws.close(); @@ -189,3 +151,31 @@ export class WS extends Transport { return this.createUri(schema, query); } } + +const WebSocketCtor = globalThis.WebSocket || globalThis.MozWebSocket; + +/** + * WebSocket transport based on the built-in `WebSocket` object. + * + * Usage: browser, Deno, Bun + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket + * @see https://caniuse.com/mdn-api_websocket + */ +export class WS extends BaseWS { + createSocket( + uri: string, + protocols: string | string[] | undefined, + opts: Record + ) { + return !isReactNative + ? protocols + ? new WebSocketCtor(uri, protocols) + : new WebSocketCtor(uri) + : new WebSocketCtor(uri, protocols, opts); + } + + doWrite(_packet: Packet, data: RawData) { + this.ws.send(data); + } +} diff --git a/lib/transports/webtransport.ts b/lib/transports/webtransport.ts index 78ae74934..742381973 100644 --- a/lib/transports/webtransport.ts +++ b/lib/transports/webtransport.ts @@ -1,5 +1,5 @@ import { Transport } from "../transport.js"; -import { nextTick } from "./websocket-constructor.js"; +import { nextTick } from "../globals.node.js"; import { Packet, createPacketDecoderStream, @@ -10,6 +10,10 @@ import debugModule from "debug"; // debug() const debug = debugModule("engine.io-client:webtransport"); // debug() /** + * WebTransport transport based on the built-in `WebTransport` object. + * + * Usage: browser, Node.js (with the `@fails-components/webtransport` package) + * * @see https://developer.mozilla.org/en-US/docs/Web/API/WebTransport * @see https://caniuse.com/webtransport */ diff --git a/lib/transports/xmlhttprequest.browser.ts b/lib/transports/xmlhttprequest.browser.ts deleted file mode 100644 index c660b729d..000000000 --- a/lib/transports/xmlhttprequest.browser.ts +++ /dev/null @@ -1,25 +0,0 @@ -// browser shim for xmlhttprequest module - -import { hasCORS } from "../contrib/has-cors.js"; -import { globalThisShim as globalThis } from "../globalThis.js"; - -export function XHR(opts) { - const xdomain = opts.xdomain; - - // XMLHttpRequest can be disabled on IE - try { - if ("undefined" !== typeof XMLHttpRequest && (!xdomain || hasCORS)) { - return new XMLHttpRequest(); - } - } catch (e) {} - - if (!xdomain) { - try { - return new globalThis[["Active"].concat("Object").join("X")]( - "Microsoft.XMLHTTP" - ); - } catch (e) {} - } -} - -export function createCookieJar() {} diff --git a/lib/util.ts b/lib/util.ts index 442d813e3..e918e2b7c 100644 --- a/lib/util.ts +++ b/lib/util.ts @@ -1,4 +1,4 @@ -import { globalThisShim as globalThis } from "./globalThis.js"; +import { globalThisShim as globalThis } from "./globals.node.js"; export function pick(obj, ...attr) { return attr.reduce((acc, k) => { diff --git a/package.json b/package.json index fd02316f3..f8d3bcb90 100644 --- a/package.json +++ b/package.json @@ -104,12 +104,12 @@ }, "browser": { "./test/node.js": false, - "./build/esm/transports/xmlhttprequest.js": "./build/esm/transports/xmlhttprequest.browser.js", - "./build/esm/transports/websocket-constructor.js": "./build/esm/transports/websocket-constructor.browser.js", - "./build/esm/globalThis.js": "./build/esm/globalThis.browser.js", - "./build/cjs/transports/xmlhttprequest.js": "./build/cjs/transports/xmlhttprequest.browser.js", - "./build/cjs/transports/websocket-constructor.js": "./build/cjs/transports/websocket-constructor.browser.js", - "./build/cjs/globalThis.js": "./build/cjs/globalThis.browser.js" + "./build/esm/transports/polling-xhr.node.js": "./build/esm/transports/polling-xhr.js", + "./build/esm/transports/websocket.node.js": "./build/esm/transports/websocket.js", + "./build/esm/globals.node.js": "./build/esm/globals.js", + "./build/cjs/transports/polling-xhr.node.js": "./build/cjs/transports/polling-xhr.js", + "./build/cjs/transports/websocket.node.js": "./build/cjs/transports/websocket.js", + "./build/cjs/globals.node.js": "./build/cjs/globals.js" }, "repository": { "type": "git", diff --git a/support/package.cjs.json b/support/package.cjs.json index 9cf26e4dd..310976791 100644 --- a/support/package.cjs.json +++ b/support/package.cjs.json @@ -3,8 +3,8 @@ "type": "commonjs", "browser": { "ws": false, - "./transports/xmlhttprequest.js": "./transports/xmlhttprequest.browser.js", - "./transports/websocket-constructor.js": "./transports/websocket-constructor.browser.js", - "./globalThis.js": "./globalThis.browser.js" + "./transports/polling-xhr.node.js": "./transports/polling-xhr.js", + "./transports/websocket.node.js": "./transports/websocket.js", + "./globals.node.js": "./globals.js" } } diff --git a/support/package.esm.json b/support/package.esm.json index 89498b3d4..696eadf7f 100644 --- a/support/package.esm.json +++ b/support/package.esm.json @@ -3,8 +3,8 @@ "type": "module", "browser": { "ws": false, - "./transports/xmlhttprequest.js": "./transports/xmlhttprequest.browser.js", - "./transports/websocket-constructor.js": "./transports/websocket-constructor.browser.js", - "./globalThis.js": "./globalThis.browser.js" + "./transports/polling-xhr.node.js": "./transports/polling-xhr.js", + "./transports/websocket.node.js": "./transports/websocket.js", + "./globals.node.js": "./globals.js" } } diff --git a/test/node.js b/test/node.js index 54e28279a..44ed075a5 100644 --- a/test/node.js +++ b/test/node.js @@ -3,7 +3,7 @@ const { exec } = require("child_process"); const { Socket } = require("../"); const { repeat } = require("./util"); const expect = require("expect.js"); -const { parse } = require("../build/cjs/transports/xmlhttprequest.js"); +const { parse } = require("../build/cjs/globals.node.js"); describe("node.js", () => { describe("autoRef option", () => { diff --git a/test/socket.js b/test/socket.js index d0e1e2745..ea4de6c08 100644 --- a/test/socket.js +++ b/test/socket.js @@ -1,5 +1,5 @@ const expect = require("expect.js"); -const { Socket } = require("../"); +const { Socket, NodeXHR, NodeWebSocket } = require("../"); const { isIE11, isAndroid, @@ -97,6 +97,30 @@ describe("Socket", function () { }); }); + it("should connect with a custom transport implementation (polling)", (done) => { + const socket = new Socket({ + transports: [NodeXHR], + }); + + socket.on("open", () => { + expect(socket.transport.name).to.eql("polling"); + socket.close(); + done(); + }); + }); + + it("should connect with a custom transport implementation (websocket)", (done) => { + const socket = new Socket({ + transports: [NodeWebSocket], + }); + + socket.on("open", () => { + expect(socket.transport.name).to.eql("websocket"); + socket.close(); + done(); + }); + }); + describe("fake timers", function () { before(function () { if (isIE11 || isAndroid || isEdge || isIPad) { diff --git a/test/xmlhttprequest.js b/test/xmlhttprequest.js index a9048a757..0d3ba897b 100644 --- a/test/xmlhttprequest.js +++ b/test/xmlhttprequest.js @@ -1,5 +1,5 @@ const expect = require("expect.js"); -const XMLHttpRequest = require("../build/cjs/transports/xmlhttprequest").XHR; +const { newRequest } = require("../build/cjs/transports/polling-xhr.node.js"); const env = require("./support/env"); describe("XMLHttpRequest", () => { @@ -7,7 +7,7 @@ describe("XMLHttpRequest", () => { describe("IE8_9", () => { context("when xdomain is false", () => { it("should have same properties as XMLHttpRequest does", () => { - const xhra = new XMLHttpRequest({ + const xhra = newRequest({ xdomain: false, xscheme: false, enablesXDR: false, @@ -15,7 +15,7 @@ describe("XMLHttpRequest", () => { expect(xhra).to.be.an("object"); expect(xhra).to.have.property("open"); expect(xhra).to.have.property("onreadystatechange"); - const xhrb = new XMLHttpRequest({ + const xhrb = newRequest({ xdomain: false, xscheme: false, enablesXDR: true, @@ -23,7 +23,7 @@ describe("XMLHttpRequest", () => { expect(xhrb).to.be.an("object"); expect(xhrb).to.have.property("open"); expect(xhrb).to.have.property("onreadystatechange"); - const xhrc = new XMLHttpRequest({ + const xhrc = newRequest({ xdomain: false, xscheme: true, enablesXDR: false, @@ -31,7 +31,7 @@ describe("XMLHttpRequest", () => { expect(xhrc).to.be.an("object"); expect(xhrc).to.have.property("open"); expect(xhrc).to.have.property("onreadystatechange"); - const xhrd = new XMLHttpRequest({ + const xhrd = newRequest({ xdomain: false, xscheme: true, enablesXDR: true, @@ -45,7 +45,7 @@ describe("XMLHttpRequest", () => { context("when xdomain is true", () => { context("when xscheme is false and enablesXDR is true", () => { it("should have same properties as XDomainRequest does", () => { - const xhr = new XMLHttpRequest({ + const xhr = newRequest({ xdomain: true, xscheme: false, enablesXDR: true, @@ -59,14 +59,14 @@ describe("XMLHttpRequest", () => { context("when xscheme is true", () => { it("should not have open in properties", () => { - const xhra = new XMLHttpRequest({ + const xhra = newRequest({ xdomain: true, xscheme: true, enablesXDR: false, }); expect(xhra).to.be.an("object"); expect(xhra).not.to.have.property("open"); - const xhrb = new XMLHttpRequest({ + const xhrb = newRequest({ xdomain: true, xscheme: true, enablesXDR: true, @@ -78,14 +78,14 @@ describe("XMLHttpRequest", () => { context("when enablesXDR is false", () => { it("should not have open in properties", () => { - const xhra = new XMLHttpRequest({ + const xhra = newRequest({ xdomain: true, xscheme: false, enablesXDR: false, }); expect(xhra).to.be.an("object"); expect(xhra).not.to.have.property("open"); - const xhrb = new XMLHttpRequest({ + const xhrb = newRequest({ xdomain: true, xscheme: true, enablesXDR: false, @@ -102,7 +102,7 @@ describe("XMLHttpRequest", () => { describe("IE10_11", () => { context("when enablesXDR is true and xscheme is false", () => { it("should have same properties as XMLHttpRequest does", () => { - const xhra = new XMLHttpRequest({ + const xhra = newRequest({ xdomain: false, xscheme: false, enablesXDR: true, @@ -110,7 +110,7 @@ describe("XMLHttpRequest", () => { expect(xhra).to.be.an("object"); expect(xhra).to.have.property("open"); expect(xhra).to.have.property("onreadystatechange"); - const xhrb = new XMLHttpRequest({ + const xhrb = newRequest({ xdomain: true, xscheme: false, enablesXDR: true,