Skip to content

Commit b5fc9bd

Browse files
committed
feat: add initial utils package
1 parent fadd955 commit b5fc9bd

File tree

11 files changed

+612
-0
lines changed

11 files changed

+612
-0
lines changed

packages/devtools-utils/CHANGELOG.md

Whitespace-only changes.

packages/devtools-utils/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# @tanstack/devtools-utils
2+
3+
This package is still under active development and might have breaking changes in the future. Please use it with caution.
4+
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// @ts-check
2+
3+
import rootConfig from '../../eslint.config.js'
4+
5+
export default [
6+
...rootConfig,
7+
{
8+
rules: {},
9+
},
10+
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@tanstack/devtools-utils",
3+
"version": "0.0.0",
4+
"description": "TanStack Devtools utilities for creating your own devtools.",
5+
"author": "Tanner Linsley",
6+
"license": "MIT",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/TanStack/devtools.git",
10+
"directory": "packages/devtools"
11+
},
12+
"homepage": "https://tanstack.com/devtools",
13+
"funding": {
14+
"type": "github",
15+
"url": "https://github.com/sponsors/tannerlinsley"
16+
},
17+
"keywords": [
18+
"devtools"
19+
],
20+
"type": "module",
21+
"types": "dist/esm/index.d.ts",
22+
"main": "dist/cjs/index.cjs",
23+
"module": "dist/esm/index.js",
24+
"exports": {
25+
".": {
26+
"import": {
27+
"types": "./dist/esm/index.d.ts",
28+
"default": "./dist/esm/index.js"
29+
},
30+
"require": {
31+
"types": "./dist/cjs/index.d.cts",
32+
"default": "./dist/cjs/index.cjs"
33+
}
34+
},
35+
"./package.json": "./package.json"
36+
},
37+
"sideEffects": false,
38+
"engines": {
39+
"node": ">=18"
40+
},
41+
"files": [
42+
"dist/",
43+
"src"
44+
],
45+
"scripts": {
46+
"clean": "premove ./build ./dist",
47+
"lint:fix": "eslint ./src --fix",
48+
"test:eslint": "eslint ./src",
49+
"test:lib": "vitest",
50+
"test:lib:dev": "pnpm test:lib --watch",
51+
"test:types": "tsc",
52+
"test:build": "publint --strict",
53+
"build": "vite build"
54+
}
55+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { EventClient } from './plugin'
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
interface TanStackDevtoolsEvent<TEventName extends string, TPayload = any> {
2+
type: TEventName
3+
payload: TPayload
4+
pluginId?: string // Optional pluginId to filter events by plugin
5+
}
6+
declare global {
7+
// eslint-disable-next-line no-var
8+
var __TANSTACK_EVENT_TARGET__: EventTarget | null
9+
}
10+
11+
type AllDevtoolsEvents<TEventMap extends Record<string, any>> = {
12+
[Key in keyof TEventMap]: TanStackDevtoolsEvent<Key & string, TEventMap[Key]>
13+
}[keyof TEventMap]
14+
15+
export class EventClient<
16+
TEventMap extends Record<string, any>,
17+
TPluginId extends string = TEventMap extends Record<infer P, any>
18+
? P extends `${infer Id}:${string}`
19+
? Id
20+
: never
21+
: never,
22+
> {
23+
#pluginId: TPluginId
24+
#eventTarget: () => EventTarget
25+
#debug: boolean
26+
#queuedEvents: Array<TanStackDevtoolsEvent<string, any>>
27+
#connected: boolean
28+
#connectIntervalId: number | null
29+
#connectEveryMs: number
30+
#retryCount = 0
31+
#maxRetries = 5
32+
#onConnected = () => {
33+
this.debugLog('Connected to event bus')
34+
this.#connected = true
35+
this.debugLog('Emitting queued events', this.#queuedEvents)
36+
this.#queuedEvents.forEach((event) => this.emitEventToBus(event))
37+
this.#queuedEvents = []
38+
this.stopConnectLoop()
39+
this.#eventTarget().removeEventListener(
40+
'tanstack-connect-success',
41+
this.#onConnected,
42+
)
43+
}
44+
#connectFunction = () => {
45+
this.#eventTarget().addEventListener(
46+
'tanstack-connect-success',
47+
this.#onConnected,
48+
)
49+
if (this.#retryCount < this.#maxRetries) {
50+
this.#retryCount++
51+
this.#eventTarget().dispatchEvent(new CustomEvent('tanstack-connect'))
52+
return
53+
}
54+
55+
this.#eventTarget().removeEventListener(
56+
'tanstack-connect',
57+
this.#connectFunction,
58+
)
59+
this.debugLog('Max retries reached, giving up on connection')
60+
this.stopConnectLoop()
61+
}
62+
63+
constructor({
64+
pluginId,
65+
debug = false,
66+
}: {
67+
pluginId: TPluginId
68+
debug?: boolean
69+
}) {
70+
this.#pluginId = pluginId
71+
this.#eventTarget = this.getGlobalTarget
72+
this.#debug = debug
73+
this.debugLog(' Initializing event subscription for plugin', this.#pluginId)
74+
this.#queuedEvents = []
75+
this.#connected = false
76+
this.#connectIntervalId = null
77+
this.#connectEveryMs = 500
78+
79+
if (typeof CustomEvent !== 'undefined') {
80+
this.#connectFunction()
81+
this.startConnectLoop()
82+
}
83+
}
84+
85+
private startConnectLoop() {
86+
if (this.#connectIntervalId !== null || this.#connected) return
87+
this.debugLog(`Starting connect loop (every ${this.#connectEveryMs}ms)`)
88+
89+
this.#connectIntervalId = setInterval(
90+
this.#connectFunction,
91+
this.#connectEveryMs,
92+
) as unknown as number
93+
}
94+
95+
private stopConnectLoop() {
96+
if (this.#connectIntervalId === null) {
97+
return
98+
}
99+
clearInterval(this.#connectIntervalId)
100+
this.#connectIntervalId = null
101+
this.debugLog('Stopped connect loop')
102+
}
103+
104+
private debugLog(...args: Array<any>) {
105+
if (this.#debug) {
106+
console.log(`🌴 [tanstack-devtools:${this.#pluginId}-plugin]`, ...args)
107+
}
108+
}
109+
private getGlobalTarget() {
110+
// server one is the global event target
111+
if (
112+
typeof globalThis !== 'undefined' &&
113+
globalThis.__TANSTACK_EVENT_TARGET__
114+
) {
115+
this.debugLog('Using global event target')
116+
return globalThis.__TANSTACK_EVENT_TARGET__
117+
}
118+
// CLient event target is the browser window object
119+
if (
120+
typeof window !== 'undefined' &&
121+
typeof window.addEventListener !== 'undefined'
122+
) {
123+
this.debugLog('Using window as event target')
124+
125+
return window
126+
}
127+
// Protect against non-web environments like react-native
128+
const eventTarget =
129+
typeof EventTarget !== 'undefined' ? new EventTarget() : undefined
130+
131+
// For non-web environments like react-native
132+
if (
133+
typeof eventTarget === 'undefined' ||
134+
typeof eventTarget.addEventListener === 'undefined'
135+
) {
136+
this.debugLog(
137+
'No event mechanism available, running in non-web environment',
138+
)
139+
return {
140+
addEventListener: () => {},
141+
removeEventListener: () => {},
142+
dispatchEvent: () => false,
143+
}
144+
}
145+
146+
this.debugLog('Using new EventTarget as fallback')
147+
return eventTarget
148+
}
149+
150+
getPluginId() {
151+
return this.#pluginId
152+
}
153+
154+
private emitEventToBus(event: TanStackDevtoolsEvent<string, any>) {
155+
this.debugLog('Emitting event to client bus', event)
156+
this.#eventTarget().dispatchEvent(
157+
new CustomEvent('tanstack-dispatch-event', { detail: event }),
158+
)
159+
}
160+
161+
emit<
162+
TSuffix extends Extract<
163+
keyof TEventMap,
164+
`${TPluginId & string}:${string}`
165+
> extends `${TPluginId & string}:${infer S}`
166+
? S
167+
: never,
168+
>(
169+
eventSuffix: TSuffix,
170+
payload: TEventMap[`${TPluginId & string}:${TSuffix}`],
171+
) {
172+
// wait to connect to the bus
173+
if (!this.#connected) {
174+
this.debugLog('Bus not available, will be pushed as soon as connected')
175+
return this.#queuedEvents.push({
176+
type: `${this.#pluginId}:${eventSuffix}`,
177+
payload,
178+
pluginId: this.#pluginId,
179+
})
180+
}
181+
// emit right now
182+
return this.emitEventToBus({
183+
type: `${this.#pluginId}:${eventSuffix}`,
184+
payload,
185+
pluginId: this.#pluginId,
186+
})
187+
}
188+
189+
on<
190+
TSuffix extends Extract<
191+
keyof TEventMap,
192+
`${TPluginId & string}:${string}`
193+
> extends `${TPluginId & string}:${infer S}`
194+
? S
195+
: never,
196+
>(
197+
eventSuffix: TSuffix,
198+
cb: (
199+
event: TanStackDevtoolsEvent<
200+
`${TPluginId & string}:${TSuffix}`,
201+
TEventMap[`${TPluginId & string}:${TSuffix}`]
202+
>,
203+
) => void,
204+
) {
205+
const eventName = `${this.#pluginId}:${eventSuffix}` as const
206+
const handler = (e: Event) => {
207+
this.debugLog('Received event from bus', (e as CustomEvent).detail)
208+
cb((e as CustomEvent).detail)
209+
}
210+
this.#eventTarget().addEventListener(eventName, handler)
211+
this.debugLog('Registered event to bus', eventName)
212+
return () => {
213+
this.#eventTarget().removeEventListener(eventName, handler)
214+
}
215+
}
216+
217+
onAll(cb: (event: TanStackDevtoolsEvent<string, any>) => void) {
218+
const handler = (e: Event) => {
219+
const event = (e as CustomEvent).detail
220+
221+
cb(event)
222+
}
223+
this.#eventTarget().addEventListener('tanstack-devtools-global', handler)
224+
return () =>
225+
this.#eventTarget().removeEventListener(
226+
'tanstack-devtools-global',
227+
handler,
228+
)
229+
}
230+
onAllPluginEvents(cb: (event: AllDevtoolsEvents<TEventMap>) => void) {
231+
const handler = (e: Event) => {
232+
const event = (e as CustomEvent).detail
233+
if (this.#pluginId && event.pluginId !== this.#pluginId) {
234+
return
235+
}
236+
cb(event)
237+
}
238+
this.#eventTarget().addEventListener('tanstack-devtools-global', handler)
239+
return () =>
240+
this.#eventTarget().removeEventListener(
241+
'tanstack-devtools-global',
242+
handler,
243+
)
244+
}
245+
}

0 commit comments

Comments
 (0)