@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +270 -139
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +278 -147
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index2.d.ts +162 -104
- package/lib/types/index2.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +42 -44
- package/src/query-client.ts +3 -4
- package/src/tests/{query.test.ts → query.test.tsx} +254 -353
- package/src/tests/subscription.test.tsx +581 -0
- package/src/use-infinite-query.ts +2 -2
- package/src/use-is-fetching.ts +1 -1
- package/src/use-mutation.ts +2 -2
- package/src/use-queries.ts +2 -2
- package/src/use-query-error-reset-boundary.ts +3 -4
- package/src/use-query.ts +2 -2
- package/src/use-subscription.ts +226 -0
- package/src/use-suspense-query.ts +3 -3
|
@@ -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 {
|
|
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
|
|
14
|
+
import { InfiniteQueryObserver, QueryObserver } from '@tanstack/query-core'
|
|
15
15
|
import { useQueryClient } from './query-client'
|
|
16
16
|
|
|
17
17
|
// ─── Types ─────────────────────────────────────────────────────────────────
|