831 lines
28 KiB
TypeScript
831 lines
28 KiB
TypeScript
import type {
|
|
WsClientConfig,
|
|
WsSendOptions,
|
|
NotifyFn,
|
|
TranslateFn,
|
|
ReactiveRef,
|
|
CreateRefFn,
|
|
} from './types'
|
|
import { plainRef } from './types'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface WsSocket extends WebSocket {
|
|
socket_id?: string
|
|
}
|
|
|
|
/** Links a `sendTimeout` deadline to the send it bounds. */
|
|
interface SendControl {
|
|
timedOut: boolean
|
|
/** Drops the queued payload and removes the response listeners. */
|
|
cancel: (() => void) | null
|
|
}
|
|
|
|
/** A send written to a socket, tracked for `rejectOnDisconnect`. */
|
|
interface InflightSend {
|
|
socket: WsSocket | null
|
|
fail: ((err: { message: string; code: string }) => void) | null
|
|
lost: { message: string; code: string } | null
|
|
}
|
|
|
|
export interface WsChannel {
|
|
name: string
|
|
is_established: boolean
|
|
establish(): Promise<WsChannel>
|
|
send(event: string, data?: any): Promise<any>
|
|
unsubscribe(): Promise<boolean>
|
|
}
|
|
|
|
export interface WsClient {
|
|
socket: WsSocket | null
|
|
channels: WsChannel[]
|
|
|
|
is_opened: ReactiveRef<boolean>
|
|
is_setup: ReactiveRef<boolean>
|
|
is_connecting_socket: ReactiveRef<boolean>
|
|
is_after_lost_connection: ReactiveRef<boolean>
|
|
|
|
heartbeat: ReturnType<typeof setInterval> | null
|
|
last_reconnect_try: number
|
|
send_queue: any[]
|
|
|
|
connect(force_reset?: boolean): Promise<WsSocket | void>
|
|
ensureConnected(): Promise<void>
|
|
channel(channel_name?: string | null): Promise<WsChannel | undefined>
|
|
/** The 4th argument is a progress callback, or `{ progress, timeout }`. */
|
|
send<T = any>(
|
|
event: string,
|
|
data?: object,
|
|
channel_name?: string | null,
|
|
progress?: ((data: any) => void) | WsSendOptions,
|
|
): Promise<T>
|
|
unsubscribe(channel_name?: string | null): Promise<boolean>
|
|
|
|
/**
|
|
* Listen for a WS event. Returns an unsubscribe function.
|
|
* Works with any framework's cleanup (React useEffect, Vue onUnmounted, etc.)
|
|
*/
|
|
listen(event: string, channel_name: string | null | undefined, callback: (data: any) => void): () => void
|
|
|
|
/** Resolve once when the given event fires. */
|
|
listenOnce(event: string, channel_name?: string | null): Promise<any>
|
|
|
|
/** Signal that app initialization is complete. Unblocks gated send() calls. */
|
|
setAppReady(): void
|
|
|
|
/** Force channels to re-subscribe with the current auth token on next send(). */
|
|
resetConnection(): void
|
|
|
|
/** Update configuration at runtime. */
|
|
configure(partial: Partial<WsClientConfig>): void
|
|
|
|
/** Close the connection, clear intervals, and reset all state. */
|
|
destroy(): void
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const _isProtocolEvent = (event: string): boolean =>
|
|
/[.:](?:subscribe|unsubscribe|ping|pong)$/.test(event)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSR stub — safe no-op returned when isServer is true
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createSsrStub(createRef: CreateRefFn): WsClient {
|
|
return {
|
|
socket: null,
|
|
channels: [],
|
|
is_opened: createRef(false),
|
|
is_setup: createRef(false),
|
|
is_connecting_socket: createRef(false),
|
|
is_after_lost_connection: createRef(false),
|
|
heartbeat: null,
|
|
last_reconnect_try: 0,
|
|
send_queue: [],
|
|
connect: () => Promise.resolve(),
|
|
ensureConnected: () => Promise.resolve(),
|
|
channel: () => Promise.resolve(undefined),
|
|
send: () => Promise.resolve(null as any),
|
|
unsubscribe: () => Promise.resolve(true),
|
|
listen: () => () => {},
|
|
listenOnce: () => Promise.resolve(null),
|
|
setAppReady: () => {},
|
|
resetConnection: () => {},
|
|
configure: () => {},
|
|
destroy: () => {},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Channel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class WebsocketChannel implements WsChannel {
|
|
name: string
|
|
is_established = false
|
|
_establishPromise: Promise<WsChannel> | null = null
|
|
private _ws: WsClientImpl
|
|
|
|
constructor(name: string, ws: WsClientImpl) {
|
|
this.name = name
|
|
this._ws = ws
|
|
ws.channels.push(this)
|
|
}
|
|
|
|
async establish(): Promise<WsChannel> {
|
|
if (this.is_established && this._ws.is_setup.value) return this
|
|
this._establishPromise ??= this._doEstablish()
|
|
return this._establishPromise
|
|
}
|
|
|
|
private async _doEstablish(): Promise<WsChannel> {
|
|
try {
|
|
await this._ws.ensureConnected()
|
|
const authtoken = this._ws._getAuthToken()
|
|
await this._ws.send(
|
|
'websocket.subscribe',
|
|
{ channel: this.name, authtoken: authtoken ?? undefined },
|
|
null,
|
|
)
|
|
this.is_established = true
|
|
this._ws.is_setup.value = true
|
|
return this
|
|
} catch (error) {
|
|
this.is_established = false
|
|
this._establishPromise = null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async send(event: string, data: any = {}): Promise<any> {
|
|
if (!_isProtocolEvent(event) && !this.is_established) await this.establish()
|
|
return this._ws.send(event, data, _isProtocolEvent(event) ? null : this.name)
|
|
}
|
|
|
|
async unsubscribe(): Promise<boolean> {
|
|
if (this.is_established) {
|
|
await this._ws
|
|
.send('websocket.unsubscribe', { channel: this.name }, this.name)
|
|
.catch(() => {})
|
|
}
|
|
this._ws.channels = this._ws.channels.filter((c) => c !== this)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main WS client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class WsClientImpl extends EventTarget implements WsClient {
|
|
private _config: WsClientConfig
|
|
private _notify: NotifyFn | undefined
|
|
private _translate: TranslateFn | undefined
|
|
|
|
socket: WsSocket | null = null
|
|
channels: WsChannel[] = []
|
|
|
|
is_opened: ReactiveRef<boolean>
|
|
is_setup: ReactiveRef<boolean>
|
|
is_connecting_socket: ReactiveRef<boolean>
|
|
is_after_lost_connection: ReactiveRef<boolean>
|
|
|
|
heartbeat: ReturnType<typeof setInterval> | null = null
|
|
last_reconnect_try = 0
|
|
send_queue: any[] = []
|
|
|
|
// App-readiness gate
|
|
_appReady = false
|
|
private _appReadyResolve: (() => void) | null = null
|
|
private _appReadyPromise: Promise<void>
|
|
|
|
// Connection-ready promise
|
|
private _connectedResolve: (() => void) | null = null
|
|
private _connectedPromise: Promise<void> | null = null
|
|
|
|
// Connect promise coalescing
|
|
private _connectPromise: Promise<WsSocket | void> | null = null
|
|
|
|
// A connect() that landed on the reconnect throttle is retried when the
|
|
// throttle window opens (instead of being dropped).
|
|
private _deferredConnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
// Auto-reconnect scheduled by a close; destroy() cancels it.
|
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// rejectOnDisconnect: sends written to a socket, keyed by that socket.
|
|
private _inflight = new Set<InflightSend>()
|
|
private _payloadSend = new WeakMap<object, InflightSend>()
|
|
// sendTimeout: queued payloads whose send already timed out; never written.
|
|
private _timedOutPayloads = new WeakSet<object>()
|
|
|
|
constructor(config: WsClientConfig, createRef: CreateRefFn) {
|
|
super()
|
|
this._config = {
|
|
defaultChannel: 'websocket',
|
|
heartbeatInterval: 20_000,
|
|
reconnectDelay: 3000,
|
|
reconnectThrottle: 3000,
|
|
autoReconnect: true,
|
|
showConnectionNotifications: true,
|
|
...config,
|
|
}
|
|
this._notify = config.notify
|
|
this._translate = config.translate
|
|
|
|
this.is_opened = createRef(false)
|
|
this.is_setup = createRef(false)
|
|
this.is_connecting_socket = createRef(false)
|
|
this.is_after_lost_connection = createRef(false)
|
|
|
|
this._appReadyPromise = new Promise<void>((r) => {
|
|
this._appReadyResolve = r
|
|
})
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Configuration
|
|
// -------------------------------------------------------------------------
|
|
|
|
configure(partial: Partial<WsClientConfig>): void {
|
|
Object.assign(this._config, partial)
|
|
if (partial.notify !== undefined) this._notify = partial.notify
|
|
if (partial.translate !== undefined) this._translate = partial.translate
|
|
}
|
|
|
|
/** @internal — called by channels to obtain the current auth token */
|
|
_getAuthToken(): string | null | undefined {
|
|
return this._config.getAuthToken?.()
|
|
}
|
|
|
|
private _isNativePlatform(): boolean {
|
|
const v = this._config.isNativePlatform
|
|
return typeof v === 'function' ? v() : (v ?? false)
|
|
}
|
|
|
|
private _getUrl(): string {
|
|
const u = this._config.url
|
|
return typeof u === 'function' ? u() : u
|
|
}
|
|
|
|
private _t(key: string, fallback: string): string {
|
|
if (this._translate) {
|
|
const result = this._translate(key)
|
|
if (result) return result
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
private _shouldNotify(): boolean {
|
|
return (
|
|
this._config.showConnectionNotifications !== false &&
|
|
!this._isNativePlatform() &&
|
|
!!this._notify
|
|
)
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Connection lifecycle
|
|
// -------------------------------------------------------------------------
|
|
|
|
ensureConnected(): Promise<void> {
|
|
if (this.socket?.socket_id && this.is_opened.value) return Promise.resolve()
|
|
// One waiter per outage. It is resolved by whichever connection attempt
|
|
// succeeds next — a failed attempt must not orphan it (that left every
|
|
// send queued behind a failed first connect hanging forever).
|
|
this._connectedPromise ??= new Promise<void>((r) => {
|
|
this._connectedResolve = r
|
|
})
|
|
this.connect().catch(() => {})
|
|
return this._connectedPromise
|
|
}
|
|
|
|
setAppReady(): void {
|
|
this._appReady = true
|
|
this._appReadyResolve?.()
|
|
}
|
|
|
|
resetConnection(): void {
|
|
for (const ch of this.channels) {
|
|
const c = ch as WebsocketChannel
|
|
c.is_established = false
|
|
c._establishPromise = null
|
|
}
|
|
this.is_setup.value = false
|
|
}
|
|
|
|
async connect(force_reset = false): Promise<WsSocket | void> {
|
|
if (force_reset && this.socket) {
|
|
try {
|
|
this.socket.close()
|
|
} catch {}
|
|
this.socket = null
|
|
this._connectPromise = null
|
|
}
|
|
|
|
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return this.socket
|
|
if (this._connectPromise) return this._connectPromise
|
|
|
|
const throttle = this._config.reconnectThrottle ?? 3000
|
|
const sinceLast = Date.now() - this.last_reconnect_try
|
|
if (this.last_reconnect_try && sinceLast < throttle) {
|
|
console.log('[ws] Reconnect too fast, deferring')
|
|
this._deferConnect(throttle - sinceLast)
|
|
return
|
|
}
|
|
|
|
this.last_reconnect_try = Date.now()
|
|
this._connectPromise = this._doConnect(force_reset)
|
|
return this._connectPromise
|
|
}
|
|
|
|
private _doConnect(force_reset: boolean): Promise<WsSocket | void> {
|
|
const hbInterval = this._config.heartbeatInterval ?? 20_000
|
|
|
|
if (force_reset || !this.heartbeat) {
|
|
if (this.heartbeat) clearInterval(this.heartbeat)
|
|
this.heartbeat = setInterval(() => {
|
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
this.socket.send('{"event":"websocket.ping","data":{}}')
|
|
}
|
|
}, hbInterval)
|
|
}
|
|
|
|
if (force_reset) this.channels = []
|
|
|
|
const url = this._getUrl()
|
|
console.debug('[blax-networking] Connecting to', url)
|
|
this.socket = new WebSocket(url) as WsSocket
|
|
this.is_connecting_socket.value = true
|
|
this._config.onConnectionStateChange?.('connecting')
|
|
|
|
return new Promise<WsSocket | void>((resolve, reject) => {
|
|
const socket = this.socket!
|
|
|
|
socket.addEventListener('error', () => {
|
|
// A replaced (connect(true)) or destroyed socket must not touch the
|
|
// live connection's state.
|
|
if (this.socket === socket) {
|
|
this.is_connecting_socket.value = false
|
|
this._connectPromise = null
|
|
}
|
|
reject(new Error('WebSocket error'))
|
|
})
|
|
|
|
socket.addEventListener('close', () => {
|
|
this._failInflight(socket)
|
|
|
|
// Stale socket (replaced by connect(true), or destroy()ed): its late
|
|
// close must not wipe the live socket or schedule a reconnect.
|
|
if (this.socket !== socket) {
|
|
reject(new Error('Socket closed'))
|
|
return
|
|
}
|
|
|
|
this.channels = []
|
|
this.is_connecting_socket.value = false
|
|
this.is_opened.value = false
|
|
this.is_setup.value = false
|
|
this.socket = null
|
|
this._connectPromise = null
|
|
// Keep _connectedPromise / _connectedResolve: callers waiting in
|
|
// ensureConnected() are resolved by the next successful connection.
|
|
this.is_after_lost_connection.value = true
|
|
|
|
if (this._shouldNotify()) {
|
|
const text = this._t('websocket.connectionlost', 'Connection lost. Reconnecting…')
|
|
this._notify!({ id: 'websocket-connection-state', type: 'info', text, timeout: 50_000 })
|
|
}
|
|
|
|
this._config.onConnectionStateChange?.('disconnected')
|
|
|
|
if (this._appReady && this._config.autoReconnect !== false) {
|
|
const delay = this._config.reconnectDelay ?? 3000
|
|
this._config.onConnectionStateChange?.('reconnecting')
|
|
if (this._reconnectTimer) clearTimeout(this._reconnectTimer)
|
|
this._reconnectTimer = setTimeout(() => {
|
|
this._reconnectTimer = null
|
|
this.connect().catch(() => {})
|
|
}, delay)
|
|
}
|
|
|
|
reject(new Error('Socket closed'))
|
|
})
|
|
|
|
socket.addEventListener('open', () => {
|
|
if (this.is_after_lost_connection.value && this._shouldNotify()) {
|
|
const text = this._t('websocket.connectionrestored', 'Connection restored')
|
|
this._notify!({ id: 'websocket-connection-state', type: 'success', text, timeout: 1000 })
|
|
}
|
|
|
|
this.is_opened.value = true
|
|
this._config.onConnectionStateChange?.('connected')
|
|
// Warmup ping
|
|
socket.send('{"event":"websocket.ping","data":{}}')
|
|
|
|
// Detect missing connection_established response (usually app key mismatch)
|
|
setTimeout(() => {
|
|
if (this.socket === socket && !socket.socket_id) {
|
|
const wsUrl = this._getUrl()
|
|
console.error(
|
|
'[blax-networking] WebSocket opened but server did not send ' +
|
|
'"websocket.connection_established" within 5s.\n' +
|
|
'Connected to: ' + wsUrl + '\n' +
|
|
'This usually means the app key in the URL does not match any app ' +
|
|
'configured on the backend (PUSHER_APP_KEY). Check that the path ' +
|
|
'segment after /app/ matches the server\'s PUSHER_APP_KEY.',
|
|
)
|
|
}
|
|
}, 5000)
|
|
})
|
|
|
|
socket.addEventListener('message', (raw) => {
|
|
const msg = JSON.parse(raw.data)
|
|
|
|
if (msg?.event === 'websocket.connection_established') {
|
|
const data = JSON.parse(msg.data)
|
|
if (data?.socket_id && this.socket === socket) {
|
|
this.socket.socket_id = data.socket_id
|
|
this.is_connecting_socket.value = false
|
|
resolve(this.socket)
|
|
const waiting = this._connectedResolve
|
|
this._connectedPromise = null
|
|
this._connectedResolve = null
|
|
waiting?.()
|
|
// Proactively establish the default channel
|
|
this.channel()
|
|
this._workSendQueue()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Parse stringified data payloads
|
|
if (msg?.data && typeof msg.data === 'string') {
|
|
try {
|
|
msg.data = JSON.parse(msg.data)
|
|
} catch {}
|
|
}
|
|
|
|
this.dispatchEvent(
|
|
new CustomEvent(msg.event, {
|
|
detail: { event: msg.event, data: msg.data, channel: msg.channel },
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Channel management
|
|
// -------------------------------------------------------------------------
|
|
|
|
async channel(channel_name: string | null = null): Promise<WsChannel | undefined> {
|
|
channel_name ??= this._config.defaultChannel ?? 'websocket'
|
|
const existing = this.channels.find((c) => c.name === channel_name)
|
|
return (existing ?? new WebsocketChannel(channel_name, this)).establish()
|
|
}
|
|
|
|
private async _workSendQueue(): Promise<void> {
|
|
if (!this.send_queue.length) return
|
|
const queue = this.send_queue
|
|
this.send_queue = []
|
|
for (const payload of queue) {
|
|
if (this._timedOutPayloads.has(payload)) continue
|
|
await this.channel(payload.channel)
|
|
if (this._timedOutPayloads.has(payload)) continue
|
|
this.socket?.send(JSON.stringify(payload))
|
|
const tracked = this._payloadSend.get(payload)
|
|
if (tracked) tracked.socket = this.socket
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Send / receive
|
|
// -------------------------------------------------------------------------
|
|
|
|
async send<T = any>(
|
|
event: string,
|
|
data: object = {},
|
|
channel_name: string | null = null,
|
|
progress?: ((data: any) => void) | WsSendOptions,
|
|
): Promise<T> {
|
|
const opts: WsSendOptions = typeof progress === 'function' ? { progress } : (progress ?? {})
|
|
const timeout = opts.timeout ?? this._config.sendTimeout ?? 0
|
|
if (!(timeout > 0) || _isProtocolEvent(event)) {
|
|
return this._send<T>(event, data, channel_name, opts.progress, true, null)
|
|
}
|
|
|
|
const ctl: SendControl = { timedOut: false, cancel: null }
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
const deadline = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
ctl.timedOut = true
|
|
ctl.cancel?.()
|
|
reject({
|
|
message: this._t('websocket.requesttimeout', 'The server did not answer in time.'),
|
|
code: 'timeout',
|
|
})
|
|
}, timeout)
|
|
})
|
|
|
|
return Promise.race([
|
|
this._send<T>(event, data, channel_name, opts.progress, true, ctl),
|
|
deadline,
|
|
]).finally(() => clearTimeout(timer))
|
|
}
|
|
|
|
private async _send<T = any>(
|
|
event: string,
|
|
data: object,
|
|
channel_name: string | null,
|
|
progress: ((data: any) => void) | undefined,
|
|
_retryOnSubscriptionLost: boolean,
|
|
ctl: SendControl | null,
|
|
): Promise<T> {
|
|
// Gate non-protocol events until app signals readiness
|
|
if (!this._appReady && !_isProtocolEvent(event)) {
|
|
await this._appReadyPromise
|
|
}
|
|
|
|
channel_name ??= this._config.defaultChannel ?? 'websocket'
|
|
if (!this.socket) await this.connect()
|
|
|
|
// Build unique event suffix so the server response can be matched to this call
|
|
let sendingevent: string
|
|
if (event === 'websocket.subscribe') {
|
|
sendingevent = 'websocket.subscribe'
|
|
channel_name = null
|
|
} else {
|
|
sendingevent = event + '[' + Math.random().toString(36).substring(7) + ']'
|
|
}
|
|
|
|
const payload = { event: sendingevent, data, channel: channel_name }
|
|
|
|
// rejectOnDisconnect: remember which socket this send went out on, so a
|
|
// close of that socket rejects it instead of leaving it pending forever.
|
|
const tracked: InflightSend | null =
|
|
this._config.rejectOnDisconnect === true && !_isProtocolEvent(event)
|
|
? { socket: null, fail: null, lost: null }
|
|
: null
|
|
if (tracked) {
|
|
this._inflight.add(tracked)
|
|
this._payloadSend.set(payload, tracked)
|
|
}
|
|
|
|
// sendTimeout: a payload still sitting in the send queue is never written.
|
|
const dropQueued = () => {
|
|
this._timedOutPayloads.add(payload)
|
|
if (tracked) this._inflight.delete(tracked)
|
|
}
|
|
if (ctl) ctl.cancel = dropQueued
|
|
|
|
try {
|
|
// Ensure the target channel is subscribed
|
|
if (channel_name && !_isProtocolEvent(event)) {
|
|
await this.channel(channel_name)
|
|
}
|
|
|
|
// The caller gave up while the channel was being established.
|
|
if (ctl?.timedOut) throw new Error('Send timed out before it was written')
|
|
|
|
// Send or queue
|
|
if (this.is_opened.value && this.socket?.readyState === WebSocket.OPEN) {
|
|
this.socket.send(JSON.stringify(payload))
|
|
if (tracked) tracked.socket = this.socket
|
|
} else {
|
|
this.send_queue.push(payload)
|
|
}
|
|
|
|
await this.connect()
|
|
if (!this.socket) throw new Error('Socket not connected')
|
|
} catch (error) {
|
|
if (tracked) this._inflight.delete(tracked)
|
|
throw error
|
|
}
|
|
|
|
const startTime =
|
|
typeof performance !== 'undefined' ? performance.now() : Date.now()
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
const cleanup = () => {
|
|
this.removeEventListener(sendingevent + ':progress', handler)
|
|
this.removeEventListener(sendingevent + ':error', handler)
|
|
this.removeEventListener(sendingevent + ':response', handler)
|
|
if (tracked) this._inflight.delete(tracked)
|
|
}
|
|
|
|
const handler = (m: any) => {
|
|
const msg = m.detail
|
|
const duration = Math.round(
|
|
(typeof performance !== 'undefined' ? performance.now() : Date.now()) -
|
|
startTime,
|
|
)
|
|
|
|
// Success
|
|
if (
|
|
(event === 'websocket.subscribe' &&
|
|
msg?.data?.channel === channel_name) ||
|
|
msg?.event?.includes(sendingevent + ':response')
|
|
) {
|
|
cleanup()
|
|
console.log(`[ws] ${sendingevent} ${duration}ms`)
|
|
resolve(msg.data)
|
|
return
|
|
}
|
|
|
|
// Error / timeout
|
|
if (
|
|
msg?.event?.includes(sendingevent + ':error') ||
|
|
msg?.event?.includes(sendingevent + ':timeout')
|
|
) {
|
|
cleanup()
|
|
console.log(`[ws] ${sendingevent} failed ${duration}ms`)
|
|
reject(msg.data)
|
|
return
|
|
}
|
|
|
|
// Progress
|
|
if (progress && msg?.event?.includes(sendingevent + ':progress')) {
|
|
progress(msg.data)
|
|
}
|
|
}
|
|
|
|
this.addEventListener(sendingevent + ':progress', handler)
|
|
this.addEventListener(sendingevent + ':error', handler)
|
|
this.addEventListener(sendingevent + ':response', handler)
|
|
|
|
// sendTimeout: stop listening once the caller has given up.
|
|
if (ctl) {
|
|
ctl.cancel = () => {
|
|
dropQueued()
|
|
cleanup()
|
|
}
|
|
if (ctl.timedOut) ctl.cancel()
|
|
}
|
|
|
|
if (tracked) {
|
|
tracked.fail = (err) => {
|
|
cleanup()
|
|
reject(err)
|
|
}
|
|
// The socket already closed between writing and waiting.
|
|
if (tracked.lost) tracked.fail(tracked.lost)
|
|
}
|
|
}).catch((error: any) => {
|
|
// If the server dropped our subscription, re-establish and retry once
|
|
if (
|
|
_retryOnSubscriptionLost &&
|
|
error?.message === 'Subscription not established'
|
|
) {
|
|
const ch = this.channels.find(
|
|
(c) => c.name === channel_name,
|
|
) as WebsocketChannel | undefined
|
|
if (ch) {
|
|
ch.is_established = false
|
|
ch._establishPromise = null
|
|
}
|
|
console.log('[ws] Re-establishing channel after subscription loss')
|
|
return this._send<T>(event, data, channel_name, progress, false, ctl)
|
|
}
|
|
throw error
|
|
}) as Promise<T>
|
|
}
|
|
|
|
/** Retry a throttled connect() once the throttle window has passed. */
|
|
private _deferConnect(ms: number): void {
|
|
if (this._deferredConnectTimer) return
|
|
this._deferredConnectTimer = setTimeout(() => {
|
|
this._deferredConnectTimer = null
|
|
if (!this.socket && !this._connectPromise) this.connect().catch(() => {})
|
|
}, Math.max(0, ms) + 5)
|
|
}
|
|
|
|
/** rejectOnDisconnect: reject every send that went out on `socket`. */
|
|
private _failInflight(socket: WsSocket): void {
|
|
for (const entry of [...this._inflight]) {
|
|
if (entry.socket !== socket) continue
|
|
this._inflight.delete(entry)
|
|
const err = {
|
|
message: this._t('websocket.requestlost', 'The connection was lost before the server answered.'),
|
|
code: 'connection_lost',
|
|
}
|
|
if (entry.fail) entry.fail(err)
|
|
else entry.lost = err
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Event listeners (framework-agnostic — return cleanup function)
|
|
// -------------------------------------------------------------------------
|
|
|
|
listen(
|
|
event: string,
|
|
channel_name: string | null | undefined,
|
|
callback: (data: any) => void,
|
|
): () => void {
|
|
channel_name ??= this._config.defaultChannel ?? 'websocket'
|
|
const handler = (m: any) => {
|
|
if (m.detail.channel === channel_name) callback(m.detail.data)
|
|
}
|
|
this.addEventListener(event, handler)
|
|
return () => this.removeEventListener(event, handler)
|
|
}
|
|
|
|
listenOnce(event: string, channel_name?: string | null): Promise<any> {
|
|
channel_name ??= this._config.defaultChannel ?? 'websocket'
|
|
return new Promise((resolve) => {
|
|
const handler = (m: any) => {
|
|
if (m.detail.channel === channel_name) {
|
|
resolve(m.detail.data)
|
|
this.removeEventListener(event, handler)
|
|
}
|
|
}
|
|
this.addEventListener(event, handler)
|
|
})
|
|
}
|
|
|
|
async unsubscribe(channel_name?: string | null): Promise<boolean> {
|
|
channel_name ??= this._config.defaultChannel ?? 'websocket'
|
|
const channel = this.channels.find((c) => c.name === channel_name)
|
|
if (channel) await channel.unsubscribe()
|
|
this.channels = this.channels.filter((c) => c.name !== channel_name)
|
|
return true
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// -------------------------------------------------------------------------
|
|
|
|
destroy(): void {
|
|
if (this.heartbeat) clearInterval(this.heartbeat)
|
|
this.heartbeat = null
|
|
if (this._deferredConnectTimer) clearTimeout(this._deferredConnectTimer)
|
|
this._deferredConnectTimer = null
|
|
if (this._reconnectTimer) clearTimeout(this._reconnectTimer)
|
|
this._reconnectTimer = null
|
|
if (this.socket) {
|
|
try {
|
|
this.socket.close()
|
|
} catch {}
|
|
this.socket = null
|
|
}
|
|
this.channels = []
|
|
this.send_queue = []
|
|
this.is_opened.value = false
|
|
this.is_setup.value = false
|
|
this.is_connecting_socket.value = false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a WebSocket client.
|
|
*
|
|
* @param config - Connection and behavior configuration.
|
|
* @param createRef - Reactive ref factory. Pass `ref` from Vue for reactive state,
|
|
* or omit for plain `{ value }` objects.
|
|
*/
|
|
export function createWsClient(
|
|
config: WsClientConfig,
|
|
createRef: CreateRefFn = plainRef,
|
|
): WsClient {
|
|
const isServer =
|
|
typeof config.isServer === 'function'
|
|
? config.isServer()
|
|
: (config.isServer ?? false)
|
|
if (isServer) return createSsrStub(createRef)
|
|
|
|
// Validate configuration and warn developers about common issues.
|
|
// IMPORTANT: only resolve a STRING url here. A function url is lazy by design —
|
|
// it may depend on a runtime context that does not exist yet at construction
|
|
// time (e.g. Nuxt's `useRuntimeConfig()`, which throws when called outside a
|
|
// plugin/setup/lifecycle). Calling it eagerly here crashed module-scope
|
|
// singletons built with `createVueWsClient({ url: () => useRuntimeConfig()... })`.
|
|
// Function urls are resolved lazily at connect time instead.
|
|
if (typeof config.url === 'string') {
|
|
const url = config.url
|
|
if (!url || url === 'wss:///app/' || url === 'ws:///app/') {
|
|
console.error(
|
|
'[blax-networking] WebSocket URL is empty or malformed: ' + JSON.stringify(url) + '\n' +
|
|
'Ensure WEBS_URL and PUSHER_APP_KEY are configured. ' +
|
|
'Expected format: wss://your-ws-host/app/{appKey}',
|
|
)
|
|
} else if (url.endsWith('/app/') || url.endsWith('/app')) {
|
|
console.error(
|
|
'[blax-networking] WebSocket URL is missing the app key: ' + url + '\n' +
|
|
'Ensure PUSHER_APP_KEY is set. The URL must end with /app/{appKey} ' +
|
|
'where {appKey} matches the PUSHER_APP_KEY on the backend.',
|
|
)
|
|
}
|
|
}
|
|
|
|
return new WsClientImpl(config, createRef)
|
|
}
|