| import WebSocketFactory, { WebSocketLike } from './lib/websocket-factory' |
|
|
| import { |
| CHANNEL_EVENTS, |
| CONNECTION_STATE, |
| DEFAULT_VERSION, |
| DEFAULT_TIMEOUT, |
| DEFAULT_VSN, |
| VSN_1_0_0, |
| VSN_2_0_0, |
| } from './lib/constants' |
|
|
| import Serializer from './lib/serializer' |
| import { httpEndpointURL } from './lib/transformers' |
| import RealtimeChannel from './RealtimeChannel' |
| import type { RealtimeChannelOptions } from './RealtimeChannel' |
| import SocketAdapter from './phoenix/socketAdapter' |
| import type { |
| Message, |
| SocketOptions, |
| HeartbeatCallback, |
| Encode, |
| Decode, |
| Timer, |
| Vsn, |
| } from './phoenix/types' |
|
|
| type Fetch = typeof fetch |
|
|
| export type LogLevel = 'info' | 'warn' | 'error' | (string & {}) |
|
|
| export type RealtimeMessage = { |
| topic: string |
| event: string |
| payload: any |
| ref: string |
| join_ref?: string |
| } |
|
|
| export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error' | (string & {}) |
| export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected' | (string & {}) |
| export type HeartbeatTimer = ReturnType<typeof setTimeout> | undefined |
|
|
| |
| const CONNECTION_TIMEOUTS = { |
| HEARTBEAT_INTERVAL: 25000, |
| RECONNECT_DELAY: 10, |
| HEARTBEAT_TIMEOUT_FALLBACK: 100, |
| } as const |
|
|
| const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000] as const |
| const DEFAULT_RECONNECT_FALLBACK = 10000 |
|
|
| |
| |
| |
| |
| export interface WebSocketLikeConstructor { |
| new (address: string | URL, subprotocols?: string | string[] | undefined): WebSocketLike |
| |
| [key: string]: any |
| } |
|
|
| export type RealtimeClientOptions = { |
| transport?: WebSocketLikeConstructor |
| timeout?: number |
| heartbeatIntervalMs?: number |
| heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void |
| vsn?: string |
| logger?: (kind: string, msg: string, data?: any) => void |
| encode?: Encode<void> |
| decode?: Decode<void> |
| reconnectAfterMs?: (tries: number) => number |
| headers?: { [key: string]: string } |
| params?: { [key: string]: any } |
| |
| log_level?: LogLevel |
| logLevel?: LogLevel |
| fetch?: Fetch |
| worker?: boolean |
| workerUrl?: string |
| accessToken?: () => Promise<string | null> |
| disconnectOnEmptyChannelsAfterMs?: number |
| |
| |
| |
| |
| |
| |
| sessionStorage?: Storage |
| } |
|
|
| function createMemorySessionStorage(): Storage { |
| const store = new Map<string, string>() |
| return { |
| get length() { |
| return store.size |
| }, |
| clear() { |
| store.clear() |
| }, |
| getItem(key: string) { |
| return store.has(key) ? (store.get(key) as string) : null |
| }, |
| key(index: number) { |
| return Array.from(store.keys())[index] ?? null |
| }, |
| removeItem(key: string) { |
| store.delete(key) |
| }, |
| setItem(key: string, value: string) { |
| store.set(key, String(value)) |
| }, |
| } |
| } |
|
|
| function resolveSessionStorage(): Storage { |
| try { |
| if (typeof globalThis !== 'undefined' && globalThis.sessionStorage) { |
| return globalThis.sessionStorage |
| } |
| } catch { |
| |
| } |
| return createMemorySessionStorage() |
| } |
|
|
| const WORKER_SCRIPT = ` |
| addEventListener("message", (e) => { |
| if (e.data.event === "start") { |
| setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval); |
| } |
| });` |
|
|
| export default class RealtimeClient { |
| |
| socketAdapter: SocketAdapter |
| channels: RealtimeChannel[] = new Array() |
|
|
| accessTokenValue: string | null = null |
| accessToken: (() => Promise<string | null>) | null = null |
| apiKey: string | null = null |
|
|
| httpEndpoint: string = '' |
| |
| headers?: { [key: string]: string } = {} |
| params?: { [key: string]: string } = {} |
|
|
| ref: number = 0 |
|
|
| logLevel?: LogLevel |
|
|
| fetch: Fetch |
| worker?: boolean |
| workerUrl?: string |
| workerRef?: Worker |
|
|
| serializer: Serializer = new Serializer() |
|
|
| get endPoint() { |
| return this.socketAdapter.endPoint |
| } |
|
|
| get timeout() { |
| return this.socketAdapter.timeout |
| } |
|
|
| get transport() { |
| return this.socketAdapter.transport |
| } |
|
|
| get heartbeatCallback() { |
| return this.socketAdapter.heartbeatCallback |
| } |
|
|
| get heartbeatIntervalMs() { |
| return this.socketAdapter.heartbeatIntervalMs |
| } |
|
|
| get heartbeatTimer() { |
| if (this.worker) { |
| return this._workerHeartbeatTimer |
| } |
| return this.socketAdapter.heartbeatTimer |
| } |
|
|
| get pendingHeartbeatRef() { |
| if (this.worker) { |
| return this._pendingWorkerHeartbeatRef |
| } |
| return this.socketAdapter.pendingHeartbeatRef |
| } |
|
|
| get reconnectTimer(): Timer { |
| return this.socketAdapter.reconnectTimer |
| } |
|
|
| get vsn(): Vsn { |
| return this.socketAdapter.vsn |
| } |
|
|
| get encode() { |
| return this.socketAdapter.encode |
| } |
|
|
| get decode() { |
| return this.socketAdapter.decode |
| } |
|
|
| get reconnectAfterMs() { |
| return this.socketAdapter.reconnectAfterMs |
| } |
|
|
| get sendBuffer() { |
| return this.socketAdapter.sendBuffer |
| } |
|
|
| get stateChangeCallbacks(): { |
| open: [string, Function][] |
| close: [string, Function][] |
| error: [string, Function][] |
| message: [string, Function][] |
| } { |
| return this.socketAdapter.stateChangeCallbacks |
| } |
|
|
| private _manuallySetToken: boolean = false |
| private _authPromise: Promise<void> | null = null |
| private _workerHeartbeatTimer: HeartbeatTimer = undefined |
| private _pendingWorkerHeartbeatRef: string | null = null |
| private _pendingDisconnectTimer: ReturnType<typeof setTimeout> | null = null |
| private _disconnectOnEmptyChannelsAfterMs: number = 0 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| constructor(endPoint: string, options?: RealtimeClientOptions) { |
| |
| if (!options?.params?.apikey) { |
| throw new Error('API key is required to connect to Realtime') |
| } |
| this.apiKey = options.params.apikey |
|
|
| const socketAdapterOptions = this._initializeOptions(options) |
|
|
| this.socketAdapter = new SocketAdapter(endPoint, socketAdapterOptions) |
| this.httpEndpoint = httpEndpointURL(endPoint) |
|
|
| this.fetch = this._resolveFetch(options?.fetch) |
| } |
|
|
| |
| |
| |
| |
| |
| connect(): void { |
| |
| if (this.isConnecting() || this.isDisconnecting() || this.isConnected()) { |
| return |
| } |
|
|
| |
| |
| |
| if (this.accessToken && !this._authPromise) { |
| this._setAuthSafely('connect') |
| } |
|
|
| this._setupConnectionHandlers() |
|
|
| try { |
| this.socketAdapter.connect() |
| } catch (error) { |
| const errorMessage = (error as Error).message |
| throw new Error(`WebSocket not available: ${errorMessage}`) |
| } |
|
|
| this._handleNodeJsRaceCondition() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| endpointURL(): string { |
| return this.socketAdapter.endPointURL() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async disconnect(code?: number, reason?: string) { |
| this._cancelPendingDisconnect() |
| if (this.isDisconnecting()) { |
| return 'ok' |
| } |
| return await this.socketAdapter.disconnect( |
| () => { |
| clearInterval(this._workerHeartbeatTimer) |
| this._terminateWorker() |
| }, |
| code, |
| reason |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| getChannels(): RealtimeChannel[] { |
| return this.channels |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> { |
| const status = await channel.unsubscribe() |
|
|
| if (status === 'ok') { |
| channel.teardown() |
| } |
|
|
| return status |
| } |
|
|
| |
| |
| |
| |
| |
| async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> { |
| const promises = this.channels.map(async (channel) => { |
| const result = await channel.unsubscribe() |
| channel.teardown() |
| return result |
| }) |
|
|
| const result = await Promise.all(promises) |
| await this.disconnect() |
| return result |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| log(kind: string, msg: string, data?: any) { |
| this.socketAdapter.log(kind, msg, data) |
| } |
|
|
| |
| |
| |
| |
| |
| connectionState() { |
| return this.socketAdapter.connectionState() || CONNECTION_STATE.closed |
| } |
|
|
| |
| |
| |
| |
| |
| isConnected(): boolean { |
| return this.socketAdapter.isConnected() |
| } |
|
|
| |
| |
| |
| |
| |
| isConnecting(): boolean { |
| return this.socketAdapter.isConnecting() |
| } |
|
|
| |
| |
| |
| |
| |
| isDisconnecting(): boolean { |
| return this.socketAdapter.isDisconnecting() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| channel(topic: string, params: RealtimeChannelOptions = { config: {} }): RealtimeChannel { |
| const realtimeTopic = `realtime:${topic}` |
| const exists = this.getChannels().find((c: RealtimeChannel) => c.topic === realtimeTopic) |
|
|
| if (!exists) { |
| const chan = new RealtimeChannel(`realtime:${topic}`, params, this) |
| this._cancelPendingDisconnect() |
| this.channels.push(chan) |
|
|
| return chan |
| } else { |
| return exists |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| push(data: RealtimeMessage): void { |
| this.socketAdapter.push(data) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async setAuth(token: string | null = null): Promise<void> { |
| this._authPromise = this._performAuth(token) |
| try { |
| await this._authPromise |
| } finally { |
| this._authPromise = null |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| _isManualToken(): boolean { |
| return this._manuallySetToken |
| } |
|
|
| |
| |
| |
| |
| |
| async sendHeartbeat() { |
| this.socketAdapter.sendHeartbeat() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| onHeartbeat(callback: HeartbeatCallback) { |
| this.socketAdapter.heartbeatCallback = this._wrapHeartbeatCallback(callback) |
| } |
|
|
| |
| |
| |
| |
| |
| _resolveFetch = (customFetch?: Fetch): Fetch => { |
| if (customFetch) { |
| return (...args) => customFetch(...args) |
| } |
| return (...args) => fetch(...args) |
| } |
|
|
| |
| |
| |
| |
| |
| _makeRef(): string { |
| return this.socketAdapter.makeRef() |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _remove(channel: RealtimeChannel) { |
| this.channels = this.channels.filter((c) => c.topic !== channel.topic) |
| if (this.channels.length === 0) { |
| this.log('transport', 'no channels remaining, scheduling disconnect') |
| this._schedulePendingDisconnect() |
| } |
| } |
|
|
| |
| private _schedulePendingDisconnect() { |
| this._cancelPendingDisconnect() |
| if (this._disconnectOnEmptyChannelsAfterMs === 0) { |
| this.log('transport', 'disconnecting immediately - no channels') |
| this.disconnect() |
| return |
| } |
| this._pendingDisconnectTimer = setTimeout(() => { |
| this._pendingDisconnectTimer = null |
| if (this.channels.length === 0) { |
| this.log('transport', 'deferred disconnect fired - no channels, disconnecting') |
| this.disconnect() |
| } |
| }, this._disconnectOnEmptyChannelsAfterMs) |
| this.log( |
| 'transport', |
| `deferred disconnect scheduled in ${this._disconnectOnEmptyChannelsAfterMs}ms` |
| ) |
| } |
|
|
| |
| private _cancelPendingDisconnect() { |
| if (this._pendingDisconnectTimer !== null) { |
| this.log('transport', 'pending disconnect cancelled - channel activity detected') |
| clearTimeout(this._pendingDisconnectTimer) |
| this._pendingDisconnectTimer = null |
| } |
| } |
|
|
| |
| |
| |
| |
| private async _performAuth(token: string | null = null): Promise<void> { |
| let tokenToSend: string | null |
| let isManualToken = false |
|
|
| if (token) { |
| tokenToSend = token |
| |
| isManualToken = true |
| } else if (this.accessToken) { |
| |
| try { |
| tokenToSend = await this.accessToken() |
| } catch (e) { |
| this.log('error', 'Error fetching access token from callback', e) |
| |
| tokenToSend = this.accessTokenValue |
| } |
| } else { |
| tokenToSend = this.accessTokenValue |
| } |
|
|
| |
| if (isManualToken) { |
| this._manuallySetToken = true |
| } else if (this.accessToken) { |
| |
| this._manuallySetToken = false |
| } |
|
|
| if (this.accessTokenValue != tokenToSend) { |
| this.accessTokenValue = tokenToSend |
| this.channels.forEach((channel) => { |
| const payload = { |
| access_token: tokenToSend, |
| version: DEFAULT_VERSION, |
| } |
|
|
| tokenToSend && channel.updateJoinPayload(payload) |
|
|
| if (channel.joinedOnce && channel.channelAdapter.isJoined()) { |
| channel.channelAdapter.push(CHANNEL_EVENTS.access_token, { |
| access_token: tokenToSend, |
| }) |
| } |
| }) |
| } |
| } |
|
|
| |
| |
| |
| |
| private async _waitForAuthIfNeeded(): Promise<void> { |
| if (this._authPromise) { |
| await this._authPromise |
| } |
| } |
|
|
| |
| |
| |
| |
| private _setAuthSafely(context = 'general'): void { |
| |
| if (!this._isManualToken()) { |
| this.setAuth().catch((e) => { |
| this.log('error', `Error setting auth in ${context}`, e) |
| }) |
| } |
| } |
|
|
| |
| private _setupConnectionHandlers(): void { |
| this.socketAdapter.onOpen(() => { |
| const authPromise = |
| this._authPromise || |
| (this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve()) |
|
|
| authPromise.catch((e) => { |
| this.log('error', 'error waiting for auth on connect', e) |
| }) |
|
|
| if (this.worker && !this.workerRef) { |
| this._startWorkerHeartbeat() |
| } |
| }) |
| this.socketAdapter.onClose(() => { |
| if (this.worker && this.workerRef) { |
| this._terminateWorker() |
| } |
| }) |
| this.socketAdapter.onMessage((message: Message<any>) => { |
| if (message.ref && message.ref === this._pendingWorkerHeartbeatRef) { |
| this._pendingWorkerHeartbeatRef = null |
| } |
| }) |
| } |
|
|
| |
| private _handleNodeJsRaceCondition() { |
| if (this.socketAdapter.isConnected()) { |
| |
| this.socketAdapter.getSocket().onConnOpen() |
| } |
| } |
|
|
| |
| private _wrapHeartbeatCallback(heartbeatCallback?: HeartbeatCallback): HeartbeatCallback { |
| return (status, latency) => { |
| if (status === 'disconnected') return |
| if (status == 'sent') this._setAuthSafely() |
| if (heartbeatCallback) heartbeatCallback(status, latency) |
| } |
| } |
|
|
| |
| private _startWorkerHeartbeat() { |
| if (this.workerUrl) { |
| this.log('worker', `starting worker for from ${this.workerUrl}`) |
| } else { |
| this.log('worker', `starting default worker`) |
| } |
| const objectUrl = this._workerObjectUrl(this.workerUrl!) |
| this.workerRef = new Worker(objectUrl) |
| this.workerRef.onerror = (error) => { |
| this.log('worker', 'worker error', (error as ErrorEvent).message) |
| this._terminateWorker() |
| this.disconnect() |
| } |
| this.workerRef.onmessage = (event) => { |
| if (event.data.event === 'keepAlive') { |
| this.sendHeartbeat() |
| } |
| } |
| this.workerRef.postMessage({ |
| event: 'start', |
| interval: this.heartbeatIntervalMs, |
| }) |
| } |
|
|
| |
| |
| |
| |
| private _terminateWorker(): void { |
| if (this.workerRef) { |
| this.log('worker', 'terminating worker') |
| this.workerRef.terminate() |
| this.workerRef = undefined |
| } |
| } |
|
|
| |
| private _workerObjectUrl(url: string | undefined): string { |
| let result_url: string |
| if (url) { |
| result_url = url |
| } else { |
| const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' }) |
| result_url = URL.createObjectURL(blob) |
| } |
| return result_url |
| } |
|
|
| |
| |
| |
| |
| private _initializeOptions(options?: RealtimeClientOptions): SocketOptions { |
| this.worker = options?.worker ?? false |
| this.accessToken = options?.accessToken ?? null |
|
|
| const result: SocketOptions = {} |
| result.timeout = options?.timeout ?? DEFAULT_TIMEOUT |
| result.heartbeatIntervalMs = |
| options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL |
|
|
| this._disconnectOnEmptyChannelsAfterMs = |
| options?.disconnectOnEmptyChannelsAfterMs ?? |
| 2 * (options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL) |
|
|
| |
| result.transport = options?.transport ?? WebSocketFactory.getWebSocketConstructor() |
| result.params = options?.params |
| result.logger = options?.logger |
| result.heartbeatCallback = this._wrapHeartbeatCallback(options?.heartbeatCallback) |
| result.sessionStorage = options?.sessionStorage ?? resolveSessionStorage() |
| result.reconnectAfterMs = |
| options?.reconnectAfterMs ?? |
| ((tries: number) => { |
| return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK |
| }) |
|
|
| let defaultEncode: Encode<void> |
| let defaultDecode: Decode<void> |
|
|
| const vsn = options?.vsn ?? DEFAULT_VSN |
|
|
| switch (vsn) { |
| case VSN_1_0_0: |
| defaultEncode = (payload, callback) => { |
| return callback(JSON.stringify(payload)) |
| } |
| defaultDecode = (payload, callback) => { |
| return callback(JSON.parse(payload as string)) |
| } |
| break |
| case VSN_2_0_0: |
| defaultEncode = this.serializer.encode.bind(this.serializer) |
| defaultDecode = this.serializer.decode.bind(this.serializer) |
| break |
| default: |
| throw new Error(`Unsupported serializer version: ${result.vsn}`) |
| } |
|
|
| result.vsn = vsn |
| result.encode = options?.encode ?? defaultEncode |
| result.decode = options?.decode ?? defaultDecode |
|
|
| result.beforeReconnect = this._reconnectAuth.bind(this) |
|
|
| if (options?.logLevel || options?.log_level) { |
| this.logLevel = options.logLevel || options.log_level |
| result.params = { ...result.params, log_level: this.logLevel as string } |
| } |
|
|
| |
| if (this.worker) { |
| if (typeof window !== 'undefined' && !window.Worker) { |
| throw new Error('Web Worker is not supported') |
| } |
| this.workerUrl = options?.workerUrl |
| result.autoSendHeartbeat = !this.worker |
| } |
|
|
| return result |
| } |
|
|
| |
| private async _reconnectAuth() { |
| await this._waitForAuthIfNeeded() |
| if (!this.isConnected()) { |
| this.connect() |
| } |
| } |
| } |
|
|