rsocket-browser
Version:
Browser-first RSocket client over WebSocket or WebTransport with reconnect and Resume.
187 lines (186 loc) • 5.87 kB
JavaScript
import { observeAbort } from "rsocket-core-ts";
//#region src/reconnect/browser.ts
/**
* Browser lifecycle signals used to make reconnect more resilient on mobile.
*/
/**
* Shared browser signal source.
*/
var browserReconnectSignals = {
isAvailable,
waitUntilAvailable,
onWake
};
/** Shared resolved promise for the common already-online reconnect path. */
var ONLINE_PROMISE = Promise.resolve();
/** Logical sockets multiplexed over one browser-global listener set. */
var wakeListeners = /* @__PURE__ */ new Set();
/** Native listener cleanups retained only while at least one socket is interested. */
var wakeCleanups;
/** Host methods used by the active native listener set. */
var wakeEnvironment;
/**
* Checks the browser network hint without treating missing APIs as offline.
*/
function isAvailable() {
return globalThis.navigator?.onLine !== false;
}
/**
* Waits until browser events suggest the network can be tried again.
*/
function waitUntilAvailable(signal) {
if (signal?.aborted) return Promise.reject(reconnectAbortError());
if (isAvailable()) return ONLINE_PROMISE;
return new Promise((resolve, reject) => {
let settled = false;
let unsubscribe;
let releaseAbort;
const cleanup = () => {
try {
unsubscribe?.();
} catch {}
const release = releaseAbort;
releaseAbort = void 0;
release?.();
};
const finish = (callback) => {
if (settled) return;
settled = true;
cleanup();
callback();
};
const tryResolve = () => {
if (isAvailable()) finish(resolve);
};
const onAbort = () => {
finish(() => reject(reconnectAbortError()));
};
try {
const releaseWake = onWake(tryResolve);
if (settled) releaseWake();
else unsubscribe = releaseWake;
const release = observeAbort(signal, onAbort);
if (settled) release();
else releaseAbort = release;
} catch (error) {
finish(() => reject(error));
return;
}
if (!settled) tryResolve();
});
}
/**
* Creates a standard abort error for reconnect waits.
*/
function reconnectAbortError() {
return new DOMException("Reconnect wait aborted", "AbortError");
}
/**
* Registers wake listeners on the browser globals that exist in this runtime.
*/
function onWake(listener) {
const environment = currentWakeEnvironment();
if (wakeEnvironment !== void 0 && !sameWakeEnvironment(wakeEnvironment, environment)) {
const staleCleanups = wakeCleanups;
wakeCleanups = void 0;
wakeEnvironment = void 0;
wakeListeners.clear();
if (staleCleanups !== void 0) releaseWakeListeners(staleCleanups);
}
const subscription = { listener };
wakeListeners.add(subscription);
if (wakeListeners.size === 1) try {
wakeCleanups = installWakeListeners(environment);
wakeEnvironment = environment;
} catch (error) {
wakeListeners.delete(subscription);
wakeCleanups = void 0;
wakeEnvironment = void 0;
throw error;
}
let active = true;
return () => {
if (!active) return;
active = false;
wakeListeners.delete(subscription);
if (wakeListeners.size !== 0) return;
const cleanups = wakeCleanups;
wakeCleanups = void 0;
wakeEnvironment = void 0;
if (cleanups !== void 0) releaseWakeListeners(cleanups);
};
}
/** Installs the single native listener set shared by every browser RSocket. */
function installWakeListeners(environment) {
const { globalTarget, documentTarget } = environment;
if (globalTarget === void 0 && documentTarget === void 0) return [];
const cleanups = [];
try {
for (const type of [
"online",
"pageshow",
"focus"
]) if (globalTarget !== void 0) cleanups.push(addListener(globalTarget, type, dispatchWake));
if (documentTarget !== void 0) cleanups.push(addListener(documentTarget, "visibilitychange", () => {
if (globalThis.document?.visibilityState !== "hidden") dispatchWake();
}));
} catch (error) {
releaseWakeListeners(cleanups);
throw error;
}
return cleanups;
}
/** Captures enough host identity to detect browser realm or test-environment replacement. */
function currentWakeEnvironment() {
const globalTarget = eventTarget(globalThis);
const documentTarget = eventTarget(globalThis.document);
return {
globalTarget,
documentTarget,
globalAdd: globalTarget?.addEventListener,
globalRemove: globalTarget?.removeEventListener,
documentAdd: documentTarget?.addEventListener,
documentRemove: documentTarget?.removeEventListener
};
}
/** Compares native listener operations without allocating an environment key. */
function sameWakeEnvironment(left, right) {
return left.globalTarget === right.globalTarget && left.documentTarget === right.documentTarget && left.globalAdd === right.globalAdd && left.globalRemove === right.globalRemove && left.documentAdd === right.documentAdd && left.documentRemove === right.documentRemove;
}
/** Notifies the current logical listeners while isolating application callbacks. */
function dispatchWake() {
const listeners = [...wakeListeners];
for (const subscription of listeners) try {
subscription.listener();
} catch {}
}
/** Attempts every registered wake-listener cleanup exactly once. */
function releaseWakeListeners(cleanups) {
let cleanup;
while ((cleanup = cleanups.pop()) !== void 0) try {
cleanup();
} catch {}
}
/**
* Narrows browser-like globals to the event target operations we need.
*/
function eventTarget(value) {
if (value !== void 0 && value !== null && typeof value.addEventListener === "function" && typeof value.removeEventListener === "function") return value;
}
/**
* Adds one listener and returns its cleanup callback.
*/
function addListener(target, type, listener) {
try {
target.addEventListener(type, listener);
} catch (error) {
try {
target.removeEventListener(type, listener);
} catch {}
throw error;
}
return () => target.removeEventListener(type, listener);
}
//#endregion
export { browserReconnectSignals };
//# sourceMappingURL=browser.js.map