phonic
Version:
[](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=https%3A%2F%2Fgithub.com%2FPhonic-Co%2Fphonic-node) [ • 14.5 kB
JavaScript
import { WebSocket as NodeWebSocket } from "ws";
import { RUNTIME } from "../runtime/index.mjs";
import { toQueryString } from "../url/qs.mjs";
import * as Events from "./events.mjs";
const getGlobalWebSocket = () => {
// Use Node.js 'ws' library for server-side runtimes since native WebSocket doesn't support headers
if (RUNTIME.type === "node" || RUNTIME.type === "bun" || RUNTIME.type === "deno") {
return NodeWebSocket;
}
else if (typeof WebSocket !== "undefined") {
// @ts-ignore
return WebSocket;
}
return undefined;
};
/**
* Returns true if given argument looks like a WebSocket class
*/
const isWebSocket = (w) => typeof w !== "undefined" && !!w && w.CLOSING === 2;
const DEFAULT_OPTIONS = {
maxReconnectionDelay: 10000,
minReconnectionDelay: 1000 + Math.random() * 4000,
minUptime: 5000,
reconnectionDelayGrowFactor: 1.3,
connectionTimeout: 4000,
maxRetries: Infinity,
maxEnqueuedMessages: Infinity,
startClosed: false,
debug: false,
};
export class ReconnectingWebSocket {
constructor({ url, protocols, options, headers, queryParameters }) {
this._listeners = {
error: [],
message: [],
open: [],
close: [],
};
this._retryCount = -1;
this._shouldReconnect = true;
this._connectLock = false;
this._binaryType = "blob";
this._closeCalled = false;
this._messageQueue = [];
/**
* An event listener to be called when the WebSocket connection's readyState changes to CLOSED
*/
this.onclose = null;
/**
* An event listener to be called when an error occurs
*/
this.onerror = null;
/**
* An event listener to be called when a message is received from the server
*/
this.onmessage = null;
/**
* An event listener to be called when the WebSocket connection's readyState changes to OPEN;
* this indicates that the connection is ready to send and receive data
*/
this.onopen = null;
this._handleOpen = (event) => {
this._debug("open event");
const { minUptime = DEFAULT_OPTIONS.minUptime } = this._options;
clearTimeout(this._connectTimeout);
this._uptimeTimeout = setTimeout(() => this._acceptOpen(), minUptime);
this._ws.binaryType = this._binaryType;
// send enqueued messages (messages sent before websocket open event)
this._messageQueue.forEach((message) => { var _a; return (_a = this._ws) === null || _a === void 0 ? void 0 : _a.send(message); });
this._messageQueue = [];
if (this.onopen) {
this.onopen(event);
}
this._listeners.open.forEach((listener) => this._callEventListener(event, listener));
};
this._handleMessage = (event) => {
this._debug("message event");
if (this.onmessage) {
this.onmessage(event);
}
this._listeners.message.forEach((listener) => this._callEventListener(event, listener));
};
this._handleError = (event) => {
this._debug("error event", event.message);
this._disconnect(undefined, event.message === "TIMEOUT" ? "timeout" : undefined);
if (this.onerror) {
this.onerror(event);
}
this._debug("exec error listeners");
this._listeners.error.forEach((listener) => this._callEventListener(event, listener));
this._connect();
};
this._handleClose = (event) => {
this._debug("close event");
this._clearTimeouts();
if (event.code === 1000) {
this._shouldReconnect = false;
}
if (this._shouldReconnect) {
this._connect();
}
if (this.onclose) {
this.onclose(event);
}
this._listeners.close.forEach((listener) => this._callEventListener(event, listener));
};
this._url = url;
this._protocols = protocols;
this._options = options !== null && options !== void 0 ? options : DEFAULT_OPTIONS;
this._headers = headers;
this._queryParameters = queryParameters;
if (this._options.startClosed) {
this._shouldReconnect = false;
}
this._connect();
}
static get CONNECTING() {
return 0;
}
static get OPEN() {
return 1;
}
static get CLOSING() {
return 2;
}
static get CLOSED() {
return 3;
}
get CONNECTING() {
return ReconnectingWebSocket.CONNECTING;
}
get OPEN() {
return ReconnectingWebSocket.OPEN;
}
get CLOSING() {
return ReconnectingWebSocket.CLOSING;
}
get CLOSED() {
return ReconnectingWebSocket.CLOSED;
}
get binaryType() {
return this._ws ? this._ws.binaryType : this._binaryType;
}
set binaryType(value) {
this._binaryType = value;
if (this._ws) {
this._ws.binaryType = value;
}
}
/**
* Returns the number or connection retries
*/
get retryCount() {
return Math.max(this._retryCount, 0);
}
/**
* The number of bytes of data that have been queued using calls to send() but not yet
* transmitted to the network. This value resets to zero once all queued data has been sent.
* This value does not reset to zero when the connection is closed; if you keep calling send(),
* this will continue to climb. Read only
*/
get bufferedAmount() {
const bytes = this._messageQueue.reduce((acc, message) => {
if (typeof message === "string") {
acc += message.length; // not byte size
}
else if (message instanceof Blob) {
acc += message.size;
}
else {
acc += message.byteLength;
}
return acc;
}, 0);
return bytes + (this._ws ? this._ws.bufferedAmount : 0);
}
/**
* The extensions selected by the server. This is currently only the empty string or a list of
* extensions as negotiated by the connection
*/
get extensions() {
return this._ws ? this._ws.extensions : "";
}
/**
* A string indicating the name of the sub-protocol the server selected;
* this will be one of the strings specified in the protocols parameter when creating the
* WebSocket object
*/
get protocol() {
return this._ws ? this._ws.protocol : "";
}
/**
* The current state of the connection; this is one of the Ready state constants
*/
get readyState() {
if (this._ws) {
return this._ws.readyState;
}
return this._options.startClosed ? ReconnectingWebSocket.CLOSED : ReconnectingWebSocket.CONNECTING;
}
/**
* The URL as resolved by the constructor
*/
get url() {
return this._ws ? this._ws.url : "";
}
/**
* Closes the WebSocket connection or connection attempt, if any. If the connection is already
* CLOSED, this method does nothing
*/
close(code = 1000, reason) {
this._closeCalled = true;
this._shouldReconnect = false;
this._clearTimeouts();
if (!this._ws) {
this._debug("close enqueued: no ws instance");
return;
}
if (this._ws.readyState === this.CLOSED) {
this._debug("close: already closed");
return;
}
this._ws.close(code, reason);
}
/**
* Closes the WebSocket connection or connection attempt and connects again.
* Resets retry counter;
*/
reconnect(code, reason) {
this._shouldReconnect = true;
this._closeCalled = false;
this._retryCount = -1;
if (!this._ws || this._ws.readyState === this.CLOSED) {
this._connect();
}
else {
this._disconnect(code, reason);
this._connect();
}
}
/**
* Enqueue specified data to be transmitted to the server over the WebSocket connection
*/
send(data) {
if (this._ws && this._ws.readyState === this.OPEN) {
this._debug("send", data);
this._ws.send(data);
}
else {
const { maxEnqueuedMessages = DEFAULT_OPTIONS.maxEnqueuedMessages } = this._options;
if (this._messageQueue.length < maxEnqueuedMessages) {
this._debug("enqueue", data);
this._messageQueue.push(data);
}
}
}
/**
* Register an event handler of a specific event type
*/
addEventListener(type, listener) {
if (this._listeners[type]) {
// @ts-ignore
this._listeners[type].push(listener);
}
}
dispatchEvent(event) {
const listeners = this._listeners[event.type];
if (listeners) {
for (const listener of listeners) {
this._callEventListener(event, listener);
}
}
return true;
}
/**
* Removes an event listener
*/
removeEventListener(type, listener) {
if (this._listeners[type]) {
// @ts-ignore
this._listeners[type] = this._listeners[type].filter(
// @ts-ignore
(l) => l !== listener);
}
}
_debug(...args) {
if (this._options.debug) {
// not using spread because compiled version uses Symbols
// tslint:disable-next-line
// biome-ignore lint/suspicious/noConsole: allow console
console.log.apply(console, ["RWS>", ...args]);
}
}
_getNextDelay() {
const { reconnectionDelayGrowFactor = DEFAULT_OPTIONS.reconnectionDelayGrowFactor, minReconnectionDelay = DEFAULT_OPTIONS.minReconnectionDelay, maxReconnectionDelay = DEFAULT_OPTIONS.maxReconnectionDelay, } = this._options;
let delay = 0;
if (this._retryCount > 0) {
delay = minReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, this._retryCount - 1);
if (delay > maxReconnectionDelay) {
delay = maxReconnectionDelay;
}
}
this._debug("next delay", delay);
return delay;
}
_wait() {
return new Promise((resolve) => {
setTimeout(resolve, this._getNextDelay());
});
}
_getNextUrl(urlProvider) {
if (typeof urlProvider === "string") {
return Promise.resolve(urlProvider);
}
if (typeof urlProvider === "function") {
const url = urlProvider();
if (typeof url === "string") {
return Promise.resolve(url);
}
// @ts-ignore redundant check
if (url.then) {
return url;
}
}
throw Error("Invalid URL");
}
_connect() {
if (this._connectLock || !this._shouldReconnect) {
return;
}
this._connectLock = true;
const { maxRetries = DEFAULT_OPTIONS.maxRetries, connectionTimeout = DEFAULT_OPTIONS.connectionTimeout, WebSocket = getGlobalWebSocket(), } = this._options;
if (this._retryCount >= maxRetries) {
this._debug("max retries reached", this._retryCount, ">=", maxRetries);
return;
}
this._retryCount++;
this._debug("connect", this._retryCount);
this._removeListeners();
if (!isWebSocket(WebSocket)) {
throw Error("No valid WebSocket class provided");
}
this._wait()
.then(() => this._getNextUrl(this._url))
.then((url) => {
if (this._closeCalled) {
return;
}
const options = {};
if (this._headers) {
options.headers = this._headers;
}
if (this._queryParameters && Object.keys(this._queryParameters).length > 0) {
const queryString = toQueryString(this._queryParameters, { arrayFormat: "repeat" });
if (queryString) {
url = `${url}?${queryString}`;
}
}
this._ws = new WebSocket(url, this._protocols, options);
this._ws.binaryType = this._binaryType;
this._connectLock = false;
this._addListeners();
this._connectTimeout = setTimeout(() => this._handleTimeout(), connectionTimeout);
});
}
_handleTimeout() {
this._debug("timeout event");
this._handleError(new Events.ErrorEvent(Error("TIMEOUT"), this));
}
_disconnect(code = 1000, reason) {
this._clearTimeouts();
if (!this._ws) {
return;
}
this._removeListeners();
try {
this._ws.close(code, reason);
this._handleClose(new Events.CloseEvent(code, reason, this));
}
catch (error) {
// ignore
}
}
_acceptOpen() {
this._debug("accept open");
this._retryCount = 0;
}
_callEventListener(event, listener) {
if ("handleEvent" in listener) {
// @ts-ignore
listener.handleEvent(event);
}
else {
// @ts-ignore
listener(event);
}
}
_removeListeners() {
if (!this._ws) {
return;
}
this._debug("removeListeners");
this._ws.removeEventListener("open", this._handleOpen);
this._ws.removeEventListener("close", this._handleClose);
this._ws.removeEventListener("message", this._handleMessage);
// @ts-ignore
this._ws.removeEventListener("error", this._handleError);
}
_addListeners() {
if (!this._ws) {
return;
}
this._debug("addListeners");
this._ws.addEventListener("open", this._handleOpen);
this._ws.addEventListener("close", this._handleClose);
this._ws.addEventListener("message", this._handleMessage);
// @ts-ignore
this._ws.addEventListener("error", this._handleError);
}
_clearTimeouts() {
clearTimeout(this._connectTimeout);
clearTimeout(this._uptimeTimeout);
}
}