@tanstack/devtools-event-client
Version:
TanStack Event Client is a lightweight event client for TanStack Devtools event bus.
313 lines (288 loc) • 9.01 kB
text/typescript
import type { AllDevtoolsEvents, TanStackDevtoolsEvent } from './types'
declare global {
var __TANSTACK_EVENT_TARGET__: EventTarget | null
}
export class EventClient<TEventMap extends Record<string, any>> {
#enabled = true
#pluginId: string
#eventTarget: () => EventTarget
#debug: boolean
#queuedEvents: Array<TanStackDevtoolsEvent<string, any>>
#connected: boolean
#connectIntervalId: number | null
#connectEveryMs: number
#retryCount = 0
#maxRetries = 5
#connecting = false
#failedToConnect = false
#internalEventTarget: EventTarget | null = null
#onConnected = () => {
this.debugLog('Connected to event bus')
this.#connected = true
this.#connecting = false
this.debugLog('Emitting queued events', this.#queuedEvents)
this.#queuedEvents.forEach((event) => this.emitEventToBus(event))
this.#queuedEvents = []
this.stopConnectLoop()
this.#eventTarget().removeEventListener(
'tanstack-connect-success',
this.#onConnected,
)
}
// fired off right away and then at intervals
#retryConnection = () => {
if (this.#retryCount < this.#maxRetries) {
this.#retryCount++
this.dispatchCustomEvent('tanstack-connect', {})
return
}
this.#eventTarget().removeEventListener(
'tanstack-connect',
this.#retryConnection,
)
this.#failedToConnect = true
this.debugLog('Max retries reached, giving up on connection')
this.stopConnectLoop()
}
// This is run to register connection handlers on first emit attempt
#connectFunction = () => {
if (this.#connecting) return
this.#connecting = true
this.#eventTarget().addEventListener(
'tanstack-connect-success',
this.#onConnected,
)
this.#retryConnection()
}
constructor({
pluginId,
debug = false,
enabled = true,
reconnectEveryMs = 300,
}: {
pluginId: string
debug?: boolean
reconnectEveryMs?: number
enabled?: boolean
}) {
this.#pluginId = pluginId
this.#enabled = enabled
this.#eventTarget = this.getGlobalTarget
this.#debug = debug
this.debugLog(' Initializing event subscription for plugin', this.#pluginId)
this.#queuedEvents = []
this.#connected = false
this.#failedToConnect = false
this.#connectIntervalId = null
this.#connectEveryMs = reconnectEveryMs
}
private startConnectLoop() {
// if connected, trying to connect, or the internalId is already set, do nothing
if (this.#connectIntervalId !== null || this.#connected) return
this.debugLog(`Starting connect loop (every ${this.#connectEveryMs}ms)`)
this.#connectIntervalId = setInterval(
this.#retryConnection,
this.#connectEveryMs,
) as unknown as number
}
private stopConnectLoop() {
this.#connecting = false
if (this.#connectIntervalId === null) {
return
}
clearInterval(this.#connectIntervalId)
this.#connectIntervalId = null
this.#queuedEvents = []
this.debugLog('Stopped connect loop')
}
private debugLog(...args: Array<any>) {
if (this.#debug) {
console.log(`🌴 [tanstack-devtools:${this.#pluginId}-plugin]`, ...args)
}
}
private getGlobalTarget() {
// server one is the global event target
if (
typeof globalThis !== 'undefined' &&
globalThis.__TANSTACK_EVENT_TARGET__
) {
this.debugLog('Using global event target')
return globalThis.__TANSTACK_EVENT_TARGET__
}
// CLient event target is the browser window object
if (
typeof window !== 'undefined' &&
typeof window.addEventListener !== 'undefined'
) {
this.debugLog('Using window as event target')
return window
}
// Protect against non-web environments like react-native
const eventTarget =
typeof EventTarget !== 'undefined' ? new EventTarget() : undefined
// For non-web environments like react-native
if (
typeof eventTarget === 'undefined' ||
typeof eventTarget.addEventListener === 'undefined'
) {
this.debugLog(
'No event mechanism available, running in non-web environment',
)
return {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}
}
this.debugLog('Using new EventTarget as fallback')
return eventTarget
}
getPluginId() {
return this.#pluginId
}
private dispatchCustomEventShim(eventName: string, detail: any) {
try {
const event = new Event(eventName, {
detail: detail,
} as any)
this.#eventTarget().dispatchEvent(event)
} catch (e) {
this.debugLog('Failed to dispatch shim event')
}
}
private dispatchCustomEvent(eventName: string, detail?: any) {
try {
this.#eventTarget().dispatchEvent(new CustomEvent(eventName, { detail }))
} catch (e) {
this.dispatchCustomEventShim(eventName, detail)
}
}
private emitEventToBus(event: TanStackDevtoolsEvent<string, any>) {
this.debugLog('Emitting event to client bus', event)
this.dispatchCustomEvent('tanstack-dispatch-event', event)
}
createEventPayload<TEvent extends keyof TEventMap & string>(
eventSuffix: TEvent,
payload: TEventMap[TEvent],
) {
return {
type: `${this.#pluginId}:${eventSuffix}`,
payload,
pluginId: this.#pluginId,
}
}
emit<TEvent extends keyof TEventMap & string>(
eventSuffix: TEvent,
payload: TEventMap[TEvent],
) {
if (!this.#enabled) {
this.debugLog(
'Event bus client is disabled, not emitting event',
eventSuffix,
payload,
)
return
}
if (this.#internalEventTarget) {
this.debugLog(
'Emitting event to internal event target',
eventSuffix,
payload,
)
this.#internalEventTarget.dispatchEvent(
new CustomEvent(`${this.#pluginId}:${eventSuffix}`, {
detail: this.createEventPayload(eventSuffix, payload),
}),
)
}
if (this.#failedToConnect) {
this.debugLog('Previously failed to connect, not emitting to bus')
return
}
// wait to connect to the bus
if (!this.#connected) {
this.debugLog('Bus not available, will be pushed as soon as connected')
this.#queuedEvents.push(this.createEventPayload(eventSuffix, payload))
// start connection to event bus
if (typeof CustomEvent !== 'undefined' && !this.#connecting) {
this.#connectFunction()
this.startConnectLoop()
}
return
}
// emit right now
return this.emitEventToBus(this.createEventPayload(eventSuffix, payload))
}
on<TEvent extends keyof TEventMap & string>(
eventSuffix: TEvent,
cb: (event: TanStackDevtoolsEvent<TEvent, TEventMap[TEvent]>) => void,
options?: {
withEventTarget?: boolean
},
) {
const withEventTarget = options?.withEventTarget ?? false
const eventName = `${this.#pluginId}:${eventSuffix}` as const
if (withEventTarget) {
if (!this.#internalEventTarget) {
this.#internalEventTarget = new EventTarget()
}
this.#internalEventTarget.addEventListener(eventName, (e) => {
cb((e as CustomEvent).detail)
})
}
if (!this.#enabled) {
this.debugLog(
'Event bus client is disabled, not registering event',
eventName,
)
return () => {}
}
const handler = (e: Event) => {
this.debugLog('Received event from bus', (e as CustomEvent).detail)
cb((e as CustomEvent).detail)
}
this.#eventTarget().addEventListener(eventName, handler)
this.debugLog('Registered event to bus', eventName)
return () => {
if (withEventTarget) {
this.#internalEventTarget?.removeEventListener(eventName, handler)
}
this.#eventTarget().removeEventListener(eventName, handler)
}
}
onAll(cb: (event: TanStackDevtoolsEvent<string, any>) => void) {
if (!this.#enabled) {
this.debugLog('Event bus client is disabled, not registering event')
return () => {}
}
const handler = (e: Event) => {
const event = (e as CustomEvent).detail
cb(event)
}
this.#eventTarget().addEventListener('tanstack-devtools-global', handler)
return () =>
this.#eventTarget().removeEventListener(
'tanstack-devtools-global',
handler,
)
}
onAllPluginEvents(cb: (event: AllDevtoolsEvents<TEventMap>) => void) {
if (!this.#enabled) {
this.debugLog('Event bus client is disabled, not registering event')
return () => {}
}
const handler = (e: Event) => {
const event = (e as CustomEvent).detail
if (this.#pluginId && event.pluginId !== this.#pluginId) {
return
}
cb(event)
}
this.#eventTarget().addEventListener('tanstack-devtools-global', handler)
return () =>
this.#eventTarget().removeEventListener(
'tanstack-devtools-global',
handler,
)
}
}