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
This commit is contained in:
Damien Arrachequesne
2024-05-31 16:56:25 +02:00
parent 579b243e89
commit f4d898ee96
23 changed files with 538 additions and 335 deletions

View File

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

View File

@@ -1 +0,0 @@
export const globalThisShim = global;

View File

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

View File

@@ -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() {}

View File

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

View File

@@ -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<SocketOptions, "transports"> & {
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<never, never>,
Record<never, never>,
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<SocketOptions>;
private readonly secure: boolean;
private readonly hostname: string;
private readonly port: string | number;
private readonly transports: string[];
private readonly transportsByName: Record<string, TransportCtor>;
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);
}
}

View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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<string, any> = {}) {
Object.assign(opts, { xd: this.xd, cookieJar: this.cookieJar }, this.opts);
return new Request(
(opts) => new XMLHttpRequest(opts),
this.uri(),
opts as RequestOptions
);
}
}

View File

@@ -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<string, any>);
/**
* 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<never, never>,
Record<never, never>,
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<string, any> = {}) {
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) {}
}
}

View File

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

View File

@@ -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<string, any>
) {
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);
}
}

View File

@@ -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<string, any>
);
/**
* 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<string, any>
) {
return !isReactNative
? protocols
? new WebSocketCtor(uri, protocols)
: new WebSocketCtor(uri)
: new WebSocketCtor(uri, protocols, opts);
}
doWrite(_packet: Packet, data: RawData) {
this.ws.send(data);
}
}

View File

@@ -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
*/

View File

@@ -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() {}

View File

@@ -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) => {

View File

@@ -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",

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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) {

View File

@@ -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,