@pyreon/query 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ import { onUnmount } from '@pyreon/core'
2
+ import type { Signal } from '@pyreon/reactivity'
3
+ import { batch, effect, signal } from '@pyreon/reactivity'
4
+ import type { QueryClient } from '@tanstack/query-core'
5
+ import { useQueryClient } from './query-client'
6
+
7
+ // ─── Types ───────────────────────────────────────────────────────────────────
8
+
9
+ export type SubscriptionStatus =
10
+ | 'connecting'
11
+ | 'connected'
12
+ | 'disconnected'
13
+ | 'error'
14
+
15
+ export interface UseSubscriptionOptions {
16
+ /** WebSocket URL — can be a signal for reactive URLs */
17
+ url: string | (() => string)
18
+ /** WebSocket sub-protocols */
19
+ protocols?: string | string[]
20
+ /** Called when a message is received — use queryClient to invalidate or update cache */
21
+ onMessage: (event: MessageEvent, queryClient: QueryClient) => void
22
+ /** Called when the connection opens */
23
+ onOpen?: (event: Event) => void
24
+ /** Called when the connection closes */
25
+ onClose?: (event: CloseEvent) => void
26
+ /** Called when a connection error occurs */
27
+ onError?: (event: Event) => void
28
+ /** Whether to automatically reconnect — default: true */
29
+ reconnect?: boolean
30
+ /** Initial reconnect delay in ms — doubles on each retry, default: 1000 */
31
+ reconnectDelay?: number
32
+ /** Maximum reconnect attempts — default: 10, 0 = unlimited */
33
+ maxReconnectAttempts?: number
34
+ /** Whether the subscription is enabled — default: true */
35
+ enabled?: boolean | (() => boolean)
36
+ }
37
+
38
+ export interface UseSubscriptionResult {
39
+ /** Current connection status */
40
+ status: Signal<SubscriptionStatus>
41
+ /** Send data through the WebSocket */
42
+ send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void
43
+ /** Manually close the connection */
44
+ close: () => void
45
+ /** Manually reconnect */
46
+ reconnect: () => void
47
+ }
48
+
49
+ // ─── useSubscription ─────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Reactive WebSocket subscription that integrates with TanStack Query.
53
+ * Automatically manages connection lifecycle, reconnection, and cleanup.
54
+ *
55
+ * Use the `onMessage` callback to invalidate or update query cache
56
+ * when the server pushes data.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const sub = useSubscription({
61
+ * url: 'wss://api.example.com/ws',
62
+ * onMessage: (event, queryClient) => {
63
+ * const data = JSON.parse(event.data)
64
+ * if (data.type === 'order-updated') {
65
+ * queryClient.invalidateQueries({ queryKey: ['orders'] })
66
+ * }
67
+ * },
68
+ * })
69
+ * // sub.status() — 'connecting' | 'connected' | 'disconnected' | 'error'
70
+ * // sub.send(JSON.stringify({ type: 'subscribe', channel: 'orders' }))
71
+ * ```
72
+ */
73
+ export function useSubscription(
74
+ options: UseSubscriptionOptions,
75
+ ): UseSubscriptionResult {
76
+ const queryClient = useQueryClient()
77
+ const status = signal<SubscriptionStatus>('disconnected')
78
+
79
+ let ws: WebSocket | null = null
80
+ let reconnectAttempts = 0
81
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
82
+ let intentionalClose = false
83
+
84
+ const reconnectEnabled = options.reconnect !== false
85
+ const baseDelay = options.reconnectDelay ?? 1000
86
+ const maxAttempts = options.maxReconnectAttempts ?? 10
87
+
88
+ function getUrl(): string {
89
+ return typeof options.url === 'function' ? options.url() : options.url
90
+ }
91
+
92
+ function isEnabled(): boolean {
93
+ if (options.enabled === undefined) return true
94
+ return typeof options.enabled === 'function'
95
+ ? options.enabled()
96
+ : options.enabled
97
+ }
98
+
99
+ function connect(): void {
100
+ if (ws) {
101
+ ws.onopen = null
102
+ ws.onmessage = null
103
+ ws.onclose = null
104
+ ws.onerror = null
105
+ if (
106
+ ws.readyState === WebSocket.OPEN ||
107
+ ws.readyState === WebSocket.CONNECTING
108
+ ) {
109
+ ws.close()
110
+ }
111
+ }
112
+
113
+ if (!isEnabled()) {
114
+ status.set('disconnected')
115
+ return
116
+ }
117
+
118
+ status.set('connecting')
119
+
120
+ try {
121
+ ws = options.protocols
122
+ ? new WebSocket(getUrl(), options.protocols)
123
+ : new WebSocket(getUrl())
124
+ } catch {
125
+ status.set('error')
126
+ scheduleReconnect()
127
+ return
128
+ }
129
+
130
+ ws.onopen = (event) => {
131
+ batch(() => {
132
+ status.set('connected')
133
+ reconnectAttempts = 0
134
+ })
135
+ options.onOpen?.(event)
136
+ }
137
+
138
+ ws.onmessage = (event) => {
139
+ options.onMessage(event, queryClient)
140
+ }
141
+
142
+ ws.onclose = (event) => {
143
+ status.set('disconnected')
144
+ options.onClose?.(event)
145
+
146
+ if (!intentionalClose && reconnectEnabled) {
147
+ scheduleReconnect()
148
+ }
149
+ }
150
+
151
+ ws.onerror = (event) => {
152
+ status.set('error')
153
+ options.onError?.(event)
154
+ }
155
+ }
156
+
157
+ function scheduleReconnect(): void {
158
+ if (!reconnectEnabled) return
159
+ if (maxAttempts > 0 && reconnectAttempts >= maxAttempts) return
160
+
161
+ const delay = baseDelay * 2 ** reconnectAttempts
162
+ reconnectAttempts++
163
+
164
+ reconnectTimer = setTimeout(() => {
165
+ reconnectTimer = null
166
+ if (!intentionalClose && isEnabled()) {
167
+ connect()
168
+ }
169
+ }, delay)
170
+ }
171
+
172
+ function send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
173
+ if (ws?.readyState === WebSocket.OPEN) {
174
+ ws.send(data)
175
+ }
176
+ }
177
+
178
+ function close(): void {
179
+ intentionalClose = true
180
+ if (reconnectTimer !== null) {
181
+ clearTimeout(reconnectTimer)
182
+ reconnectTimer = null
183
+ }
184
+ if (ws) {
185
+ ws.onopen = null
186
+ ws.onmessage = null
187
+ ws.onclose = null
188
+ ws.onerror = null
189
+ if (
190
+ ws.readyState === WebSocket.OPEN ||
191
+ ws.readyState === WebSocket.CONNECTING
192
+ ) {
193
+ ws.close()
194
+ }
195
+ ws = null
196
+ }
197
+ status.set('disconnected')
198
+ }
199
+
200
+ function manualReconnect(): void {
201
+ intentionalClose = false
202
+ reconnectAttempts = 0
203
+ connect()
204
+ }
205
+
206
+ // Track reactive URL and enabled state
207
+ effect(() => {
208
+ // Read reactive values to subscribe to changes
209
+ if (typeof options.url === 'function') options.url()
210
+ if (typeof options.enabled === 'function') options.enabled()
211
+
212
+ intentionalClose = false
213
+ reconnectAttempts = 0
214
+ connect()
215
+ })
216
+
217
+ // Cleanup on unmount
218
+ onUnmount(() => close())
219
+
220
+ return {
221
+ status,
222
+ send,
223
+ close,
224
+ reconnect: manualReconnect,
225
+ }
226
+ }
@@ -1,7 +1,7 @@
1
+ import type { VNodeChild, VNodeChildAtom } from '@pyreon/core'
1
2
  import { onUnmount } from '@pyreon/core'
2
- import { signal, effect, batch } from '@pyreon/reactivity'
3
3
  import type { Signal } from '@pyreon/reactivity'
4
- import { QueryObserver, InfiniteQueryObserver } from '@tanstack/query-core'
4
+ import { batch, effect, signal } from '@pyreon/reactivity'
5
5
  import type {
6
6
  DefaultError,
7
7
  InfiniteData,
@@ -11,7 +11,7 @@ import type {
11
11
  QueryObserverOptions,
12
12
  QueryObserverResult,
13
13
  } from '@tanstack/query-core'
14
- import type { VNodeChild, VNodeChildAtom } from '@pyreon/core'
14
+ import { InfiniteQueryObserver, QueryObserver } from '@tanstack/query-core'
15
15
  import { useQueryClient } from './query-client'
16
16
 
17
17
  // ─── Types ─────────────────────────────────────────────────────────────────