orpc-fn 0.4.0 → 0.5.1
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/README.md +146 -4
- package/dist/cli.js +37 -48
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -1
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +0 -1
- package/dist/create-fn.js.map +1 -1
- package/dist/hono.d.ts +12 -1
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +15 -1
- package/dist/hono.js.map +1 -1
- package/dist/live/fn-live.d.ts +1 -6
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +2 -18
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +3 -1
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js +2 -0
- package/dist/live/index.js.map +1 -1
- package/dist/live/invalidation-stream.d.ts +52 -0
- package/dist/live/invalidation-stream.d.ts.map +1 -0
- package/dist/live/invalidation-stream.js +98 -0
- package/dist/live/invalidation-stream.js.map +1 -0
- package/dist/live/ioredis.d.ts +1 -0
- package/dist/live/ioredis.d.ts.map +1 -1
- package/dist/live/ioredis.js +1 -0
- package/dist/live/ioredis.js.map +1 -1
- package/dist/live/memory.d.ts +2 -1
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +6 -1
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +5 -0
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +16 -3
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +3 -0
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +1 -0
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/redis.d.ts +4 -1
- package/dist/live/redis.d.ts.map +1 -1
- package/dist/live/redis.js +14 -2
- package/dist/live/redis.js.map +1 -1
- package/dist/live/scoped-change-bus.d.ts +45 -0
- package/dist/live/scoped-change-bus.d.ts.map +1 -0
- package/dist/live/scoped-change-bus.js +38 -0
- package/dist/live/scoped-change-bus.js.map +1 -0
- package/dist/live/transport.d.ts +11 -0
- package/dist/live/transport.d.ts.map +1 -1
- package/dist/live/transport.js.map +1 -1
- package/dist/procedure-cli.d.ts +25 -0
- package/dist/procedure-cli.d.ts.map +1 -0
- package/dist/procedure-cli.js +67 -0
- package/dist/procedure-cli.js.map +1 -0
- package/dist/react.d.ts +116 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +156 -0
- package/dist/react.js.map +1 -0
- package/dist/realtime.d.ts +114 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +316 -0
- package/dist/realtime.js.map +1 -0
- package/dist/vue/testing.d.ts +67 -0
- package/dist/vue/testing.d.ts.map +1 -0
- package/dist/vue/testing.js +225 -0
- package/dist/vue/testing.js.map +1 -0
- package/dist/vue.d.ts +211 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +312 -0
- package/dist/vue.js.map +1 -0
- package/package.json +49 -3
- package/src/cli.ts +47 -57
- package/src/client.ts +57 -0
- package/src/create-fn.ts +0 -1
- package/src/hono.ts +22 -2
- package/src/live/fn-live.ts +3 -22
- package/src/live/index.ts +12 -1
- package/src/live/invalidation-stream.ts +142 -0
- package/src/live/ioredis.ts +2 -0
- package/src/live/memory.ts +11 -2
- package/src/live/pub-sub.ts +23 -2
- package/src/live/redis-bun.ts +3 -0
- package/src/live/redis.ts +33 -6
- package/src/live/scoped-change-bus.ts +98 -0
- package/src/live/transport.ts +12 -0
- package/src/procedure-cli.ts +99 -0
- package/src/react.ts +355 -0
- package/src/realtime.ts +416 -0
- package/src/vue/testing.ts +296 -0
- package/src/vue.ts +783 -0
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Framework-free subscription and invalidation logic shared by `orpc-fn/vue`
|
|
2
|
+
// and `orpc-fn/react`. Not a public subpath: each framework wraps it.
|
|
3
|
+
import type { FnLogger } from './logger.js'
|
|
4
|
+
|
|
5
|
+
export type LiveConnectionStatus =
|
|
6
|
+
| 'idle'
|
|
7
|
+
| 'connecting'
|
|
8
|
+
| 'connected'
|
|
9
|
+
| 'error'
|
|
10
|
+
| 'closed'
|
|
11
|
+
|
|
12
|
+
const BASE_RECONNECT_DELAY_MS = 1000
|
|
13
|
+
const MAX_RECONNECT_DELAY_MS = 15_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Backoff for attempt N (1-based): exponential from 1s capped at 15s, plus
|
|
17
|
+
* 0-25% jitter so a fleet of clients doesn't thundering-herd a recovering
|
|
18
|
+
* server. The delay is capped, never the attempts: a dead network keeps
|
|
19
|
+
* retrying at the ceiling instead of killing the stream for good.
|
|
20
|
+
*/
|
|
21
|
+
export function nextReconnectDelayMs(
|
|
22
|
+
errorCount: number,
|
|
23
|
+
random: () => number = Math.random
|
|
24
|
+
): number {
|
|
25
|
+
const attempt = Math.max(1, errorCount)
|
|
26
|
+
const base = Math.min(
|
|
27
|
+
BASE_RECONNECT_DELAY_MS * 2 ** (attempt - 1),
|
|
28
|
+
MAX_RECONNECT_DELAY_MS
|
|
29
|
+
)
|
|
30
|
+
return Math.round(base + base * 0.25 * random())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SubscriptionRunnerOptions<TEvent> = {
|
|
34
|
+
open: (signal: AbortSignal) => Promise<AsyncIterable<TEvent>>
|
|
35
|
+
onEvent: (event: TEvent, connectionId: number) => void
|
|
36
|
+
onStatus: (status: LiveConnectionStatus) => void
|
|
37
|
+
onConnect?: ((connectionId: number) => void) | undefined
|
|
38
|
+
onError?: ((error: Error) => void) | undefined
|
|
39
|
+
onClose?: (() => void) | undefined
|
|
40
|
+
/** May a stream run at all right now (e.g. the app is in the foreground)? */
|
|
41
|
+
canConnect: () => boolean
|
|
42
|
+
/** Reconnect with backoff after an unexpected end? Checked when scheduling and firing. */
|
|
43
|
+
shouldReconnect: () => boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SubscriptionRunner = ReturnType<typeof createSubscriptionRunner>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One oRPC event-iterator stream with generation-guarded connects, abortable
|
|
50
|
+
* teardown and backoff reconnects. Every connect claims a new connection id;
|
|
51
|
+
* a superseded attempt closes its own iterator and never reports events.
|
|
52
|
+
*/
|
|
53
|
+
export function createSubscriptionRunner<TEvent>(
|
|
54
|
+
options: SubscriptionRunnerOptions<TEvent>
|
|
55
|
+
) {
|
|
56
|
+
let status: LiveConnectionStatus = 'idle'
|
|
57
|
+
let abortController: AbortController | null = null
|
|
58
|
+
let iterator: AsyncIterator<TEvent> | null = null
|
|
59
|
+
let connectPromise: Promise<void> | null = null
|
|
60
|
+
let activeConnectionId = 0
|
|
61
|
+
/** Set by deliberate teardown so stream-end does not schedule a retry. */
|
|
62
|
+
let intentionalDisconnect = false
|
|
63
|
+
let errorCount = 0
|
|
64
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
65
|
+
let disposed = false
|
|
66
|
+
|
|
67
|
+
const setStatus = (next: LiveConnectionStatus) => {
|
|
68
|
+
status = next
|
|
69
|
+
options.onStatus(next)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function clearReconnectTimer() {
|
|
73
|
+
if (reconnectTimer === null) return
|
|
74
|
+
clearTimeout(reconnectTimer)
|
|
75
|
+
reconnectTimer = null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scheduleReconnect() {
|
|
79
|
+
if (intentionalDisconnect || disposed || reconnectTimer !== null) return
|
|
80
|
+
if (!options.shouldReconnect()) return
|
|
81
|
+
reconnectTimer = setTimeout(() => {
|
|
82
|
+
reconnectTimer = null
|
|
83
|
+
if (disposed || intentionalDisconnect || !options.shouldReconnect())
|
|
84
|
+
return
|
|
85
|
+
void connect()
|
|
86
|
+
}, nextReconnectDelayMs(errorCount))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleUnexpectedEnd(error: Error | null) {
|
|
90
|
+
errorCount += 1
|
|
91
|
+
// Close the socket of a failed stream; on React Native an unaborted
|
|
92
|
+
// expo/fetch request keeps holding a connection from the device's pool.
|
|
93
|
+
abortController?.abort()
|
|
94
|
+
abortController = null
|
|
95
|
+
iterator = null
|
|
96
|
+
if (error) {
|
|
97
|
+
setStatus('error')
|
|
98
|
+
options.onError?.(error)
|
|
99
|
+
} else {
|
|
100
|
+
setStatus('closed')
|
|
101
|
+
options.onClose?.()
|
|
102
|
+
}
|
|
103
|
+
scheduleReconnect()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function connect(): Promise<void> {
|
|
107
|
+
if (disposed || !options.canConnect()) return
|
|
108
|
+
if (connectPromise) return connectPromise
|
|
109
|
+
if (status === 'connected' || reconnectTimer !== null) return
|
|
110
|
+
|
|
111
|
+
intentionalDisconnect = false
|
|
112
|
+
const thisConnect = (async () => {
|
|
113
|
+
setStatus('connecting')
|
|
114
|
+
// Claim the generation and controller before awaiting, so a
|
|
115
|
+
// disconnect while opening supersedes this attempt.
|
|
116
|
+
const controller = new AbortController()
|
|
117
|
+
abortController = controller
|
|
118
|
+
const connectionId = ++activeConnectionId
|
|
119
|
+
try {
|
|
120
|
+
const iterable = await options.open(controller.signal)
|
|
121
|
+
const localIterator = iterable[Symbol.asyncIterator]()
|
|
122
|
+
if (connectionId !== activeConnectionId || controller.signal.aborted) {
|
|
123
|
+
await closeIterator(localIterator)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
iterator = localIterator
|
|
127
|
+
setStatus('connected')
|
|
128
|
+
options.onConnect?.(connectionId)
|
|
129
|
+
void consume(localIterator, connectionId)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (connectionId !== activeConnectionId || controller.signal.aborted)
|
|
132
|
+
return
|
|
133
|
+
handleUnexpectedEnd(toError(error))
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
connectPromise = thisConnect
|
|
137
|
+
try {
|
|
138
|
+
await thisConnect
|
|
139
|
+
} finally {
|
|
140
|
+
if (connectPromise === thisConnect) connectPromise = null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function consume(
|
|
145
|
+
localIterator: AsyncIterator<TEvent>,
|
|
146
|
+
connectionId: number
|
|
147
|
+
) {
|
|
148
|
+
try {
|
|
149
|
+
while (status === 'connected' && connectionId === activeConnectionId) {
|
|
150
|
+
const result = await localIterator.next()
|
|
151
|
+
if (status !== 'connected' || connectionId !== activeConnectionId)
|
|
152
|
+
return
|
|
153
|
+
if (result.done) {
|
|
154
|
+
// Proxy or idle end after a live connect: recoverable.
|
|
155
|
+
if (!intentionalDisconnect) handleUnexpectedEnd(null)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
// Reset backoff only once the stream delivers. A server that accepts
|
|
159
|
+
// and then dies before yielding must keep escalating.
|
|
160
|
+
errorCount = 0
|
|
161
|
+
options.onEvent(result.value, connectionId)
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (connectionId !== activeConnectionId) return
|
|
165
|
+
if (abortController?.signal.aborted || intentionalDisconnect) {
|
|
166
|
+
setStatus('closed')
|
|
167
|
+
options.onClose?.()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
handleUnexpectedEnd(toError(error))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function disconnect(): Promise<void> {
|
|
175
|
+
intentionalDisconnect = true
|
|
176
|
+
clearReconnectTimer()
|
|
177
|
+
if (status === 'idle') return
|
|
178
|
+
|
|
179
|
+
activeConnectionId++
|
|
180
|
+
abortController?.abort()
|
|
181
|
+
// A fresh connect() may start while the superseded attempt winds down.
|
|
182
|
+
connectPromise = null
|
|
183
|
+
const localIterator = iterator
|
|
184
|
+
iterator = null
|
|
185
|
+
abortController = null
|
|
186
|
+
if (status !== 'closed') {
|
|
187
|
+
setStatus('closed')
|
|
188
|
+
options.onClose?.()
|
|
189
|
+
}
|
|
190
|
+
await closeIterator(localIterator)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
connect,
|
|
195
|
+
disconnect,
|
|
196
|
+
/** Connect with a fresh retry budget (input change, foreground, re-enable). */
|
|
197
|
+
start() {
|
|
198
|
+
errorCount = 0
|
|
199
|
+
intentionalDisconnect = false
|
|
200
|
+
return connect()
|
|
201
|
+
},
|
|
202
|
+
/** Connected, connecting, or waiting to reconnect. */
|
|
203
|
+
isLive: () =>
|
|
204
|
+
status === 'connected' ||
|
|
205
|
+
status === 'connecting' ||
|
|
206
|
+
reconnectTimer !== null,
|
|
207
|
+
getStatus: () => status,
|
|
208
|
+
dispose() {
|
|
209
|
+
disposed = true
|
|
210
|
+
return disconnect()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function closeIterator<T>(iterator: AsyncIterator<T> | null) {
|
|
216
|
+
try {
|
|
217
|
+
await iterator?.return?.(undefined)
|
|
218
|
+
} catch {
|
|
219
|
+
// Cleanup errors are not the caller's concern.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const toError = (error: unknown) =>
|
|
224
|
+
error instanceof Error ? error : new Error(String(error))
|
|
225
|
+
|
|
226
|
+
type QueryKeyLike = readonly unknown[]
|
|
227
|
+
|
|
228
|
+
/** The part of a TanStack `QueryClient` the coordinator uses. */
|
|
229
|
+
type InvalidatingQueryClient = {
|
|
230
|
+
invalidateQueries(filters: {
|
|
231
|
+
queryKey: QueryKeyLike
|
|
232
|
+
refetchType?: 'active' | 'inactive' | 'all' | 'none'
|
|
233
|
+
predicate?: (query: {
|
|
234
|
+
meta?: Record<string, unknown> | undefined
|
|
235
|
+
}) => boolean
|
|
236
|
+
}): Promise<unknown>
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type InvalidationDomain = {
|
|
240
|
+
/**
|
|
241
|
+
* Batch a burst of events into one invalidation (ms). The window runs from
|
|
242
|
+
* the first event, so a steady stream cannot postpone the refresh forever.
|
|
243
|
+
*/
|
|
244
|
+
debounceMs?: number
|
|
245
|
+
queryKeys: readonly QueryKeyLike[]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export type InvalidationStreamOptions<TEvent extends { domain: string }> = {
|
|
249
|
+
/** Query keys to invalidate per event `domain`. Unmapped domains are ignored. */
|
|
250
|
+
domains: { [K in TEvent['domain']]?: InvalidationDomain }
|
|
251
|
+
/**
|
|
252
|
+
* Passed to `invalidateQueries`. Default: TanStack's `'active'`, which marks
|
|
253
|
+
* inactive queries stale so they refetch on their next use. `'all'` also
|
|
254
|
+
* refetches every cached query of the domain now, on each event.
|
|
255
|
+
*/
|
|
256
|
+
refetchType?: 'active' | 'inactive' | 'all' | 'none'
|
|
257
|
+
/** Every event, after its domain is invalidated (alerts, chimes). */
|
|
258
|
+
onEvent?: (event: TEvent) => void
|
|
259
|
+
/** Receives one `invalidation_stream.disconnected` warning per minute at most. */
|
|
260
|
+
logger?: Pick<FnLogger, 'warn'>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* How long a reconnected stream must stay up, having delivered nothing, before
|
|
265
|
+
* it counts as working. Waiting for an event alone would leave a quiet
|
|
266
|
+
* reconnect stale until the next change; not waiting at all lets a stream that
|
|
267
|
+
* dies the instant it opens refetch everything once per retry.
|
|
268
|
+
*/
|
|
269
|
+
const SETTLE_MS = 1_500
|
|
270
|
+
const DISCONNECT_LOG_INTERVAL_MS = 60_000
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Turns a stream of `{ domain }` change events into debounced
|
|
274
|
+
* `invalidateQueries` calls. Live queries (`meta.live`) are skipped: their own
|
|
275
|
+
* stream keeps them current. Events published while the stream was down are
|
|
276
|
+
* lost, so a reconnect that proves healthy invalidates every mapped domain.
|
|
277
|
+
*/
|
|
278
|
+
export function createInvalidationCoordinator<
|
|
279
|
+
TEvent extends { domain: string }
|
|
280
|
+
>(
|
|
281
|
+
queryClient: InvalidatingQueryClient,
|
|
282
|
+
options: InvalidationStreamOptions<TEvent>
|
|
283
|
+
) {
|
|
284
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
285
|
+
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
|
286
|
+
let catchUpPending = false
|
|
287
|
+
let lastLostLogAt = Number.NEGATIVE_INFINITY
|
|
288
|
+
|
|
289
|
+
const domainConfig = (domain: string): InvalidationDomain | undefined =>
|
|
290
|
+
Object.hasOwn(options.domains, domain)
|
|
291
|
+
? (options.domains as Record<string, InvalidationDomain | undefined>)[
|
|
292
|
+
domain
|
|
293
|
+
]
|
|
294
|
+
: undefined
|
|
295
|
+
|
|
296
|
+
function invalidate(config: InvalidationDomain) {
|
|
297
|
+
for (const queryKey of config.queryKeys) {
|
|
298
|
+
void queryClient.invalidateQueries({
|
|
299
|
+
queryKey,
|
|
300
|
+
...(options.refetchType ? { refetchType: options.refetchType } : {}),
|
|
301
|
+
predicate: (query) => query.meta?.live !== true
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function invalidateAll() {
|
|
307
|
+
for (const config of Object.values(options.domains)) {
|
|
308
|
+
if (config) invalidate(config as InvalidationDomain)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function clearSettleTimer() {
|
|
313
|
+
if (settleTimer === null) return
|
|
314
|
+
clearTimeout(settleTimer)
|
|
315
|
+
settleTimer = null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function markHealthy() {
|
|
319
|
+
clearSettleTimer()
|
|
320
|
+
if (!catchUpPending) return
|
|
321
|
+
catchUpPending = false
|
|
322
|
+
invalidateAll()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
event(event: TEvent) {
|
|
327
|
+
// A delivered event proves the connection; no need to wait it out.
|
|
328
|
+
markHealthy()
|
|
329
|
+
|
|
330
|
+
// The stream is typed, but a JavaScript caller or a newer server may send anything.
|
|
331
|
+
const domain: unknown = (event as { domain?: unknown } | null)?.domain
|
|
332
|
+
if (typeof domain !== 'string') return
|
|
333
|
+
|
|
334
|
+
const config = domainConfig(domain)
|
|
335
|
+
const delay = config?.debounceMs ?? 0
|
|
336
|
+
if (config && delay <= 0) {
|
|
337
|
+
invalidate(config)
|
|
338
|
+
} else if (config && !timers.has(domain)) {
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
timers.delete(domain)
|
|
341
|
+
invalidate(config)
|
|
342
|
+
}, delay)
|
|
343
|
+
timers.set(domain, timer)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
options.onEvent?.(event)
|
|
347
|
+
},
|
|
348
|
+
connect(connectionId: number) {
|
|
349
|
+
clearSettleTimer()
|
|
350
|
+
catchUpPending ||= connectionId > 1
|
|
351
|
+
settleTimer = setTimeout(markHealthy, SETTLE_MS)
|
|
352
|
+
},
|
|
353
|
+
lost() {
|
|
354
|
+
// Reconnecting is the subscription's job; this connection can no longer settle.
|
|
355
|
+
clearSettleTimer()
|
|
356
|
+
const now = Date.now()
|
|
357
|
+
if (now - lastLostLogAt < DISCONNECT_LOG_INTERVAL_MS) return
|
|
358
|
+
lastLostLogAt = now
|
|
359
|
+
options.logger?.warn('invalidation_stream.disconnected')
|
|
360
|
+
},
|
|
361
|
+
dispose() {
|
|
362
|
+
clearSettleTimer()
|
|
363
|
+
for (const timer of timers.values()) clearTimeout(timer)
|
|
364
|
+
timers.clear()
|
|
365
|
+
catchUpPending = false
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Which connection and query key the open live stream belongs to. An event
|
|
372
|
+
* counts only while both still match: the input changes synchronously but the
|
|
373
|
+
* stream is torn down asynchronously, so a frame in flight from the previous
|
|
374
|
+
* subscription would otherwise land in the new key (and, with
|
|
375
|
+
* `staleTime: Infinity`, stay there).
|
|
376
|
+
*/
|
|
377
|
+
export function createStreamScope() {
|
|
378
|
+
let openConnectionId: number | null = null
|
|
379
|
+
let openKeyHash: string | null = null
|
|
380
|
+
return {
|
|
381
|
+
open(connectionId: number, keyHash: string) {
|
|
382
|
+
openConnectionId = connectionId
|
|
383
|
+
openKeyHash = keyHash
|
|
384
|
+
},
|
|
385
|
+
owns: (connectionId: number, keyHash: string) =>
|
|
386
|
+
connectionId === openConnectionId && keyHash === openKeyHash
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Wrap a live query's HTTP `queryFn`: report the raw seed, convert it with
|
|
392
|
+
* `select`, and reconcile it with what the stream already wrote. The cache is
|
|
393
|
+
* read after the request resolves, because stream events can arrive while it
|
|
394
|
+
* is in flight.
|
|
395
|
+
*/
|
|
396
|
+
export function seedQueryFn<TRouteData, TData, TInput>(
|
|
397
|
+
queryFn: (context: unknown) => Promise<TRouteData>,
|
|
398
|
+
options: {
|
|
399
|
+
input: TInput
|
|
400
|
+
readCache: () => TData | undefined
|
|
401
|
+
select?: ((data: TRouteData) => TData) | undefined
|
|
402
|
+
onSeed?: ((data: TRouteData, input: TInput) => void) | undefined
|
|
403
|
+
mergeSeed?:
|
|
404
|
+
| ((fetched: TData, cached: TData | undefined) => TData)
|
|
405
|
+
| undefined
|
|
406
|
+
}
|
|
407
|
+
) {
|
|
408
|
+
const { select, onSeed, mergeSeed } = options
|
|
409
|
+
return async (context: unknown): Promise<TData> => {
|
|
410
|
+
const fetched = await queryFn(context)
|
|
411
|
+
onSeed?.(fetched, options.input)
|
|
412
|
+
// Without `select` the route output is the cache type.
|
|
413
|
+
const data = select ? select(fetched) : (fetched as unknown as TData)
|
|
414
|
+
return mergeSeed ? mergeSeed(data, options.readCache()) : data
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
|
2
|
+
import { createApp, effectScope } from 'vue'
|
|
3
|
+
import { provideOrpc } from '../vue.js'
|
|
4
|
+
|
|
5
|
+
type Method = (...args: never[]) => unknown
|
|
6
|
+
type MockFactory = (implementation: Method) => Method
|
|
7
|
+
|
|
8
|
+
export type OrpcMock<T> = T & {
|
|
9
|
+
/** Reset every method mock created so far (calls `mockReset` when the factory provides it). */
|
|
10
|
+
$reset: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CreateOrpcMockOptions = {
|
|
14
|
+
/** Wraps each method, e.g. `mock` from `bun:test` or `vi.fn`, so calls can be asserted. */
|
|
15
|
+
fn?: MockFactory
|
|
16
|
+
/** Namespace `.key()` results (default: `[namespace]`). */
|
|
17
|
+
keys?: Record<string, readonly unknown[]>
|
|
18
|
+
/** Explicit namespace shapes merged over the auto-mock. */
|
|
19
|
+
overrides?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const emptyAsyncIterable = () => ({
|
|
23
|
+
async *[Symbol.asyncIterator]() {}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A deep stand-in for oRPC utils or a client: `o.order.key()` is `['order']`,
|
|
31
|
+
* `o.order.get.call()` resolves an empty stream, `.key()`/`.queryKey()` on a
|
|
32
|
+
* procedure return `[]`, and any other method returns `{}`. The same path
|
|
33
|
+
* always returns the same mock, so calls can be asserted. Type it as your
|
|
34
|
+
* utils: `createOrpcMock<typeof orpc>({ fn: mock })`.
|
|
35
|
+
*/
|
|
36
|
+
export function createOrpcMock<T = Record<string, unknown>>(
|
|
37
|
+
options: CreateOrpcMockOptions = {}
|
|
38
|
+
): OrpcMock<T> {
|
|
39
|
+
const { fn = (implementation) => implementation, keys, overrides } = options
|
|
40
|
+
const created: Method[] = []
|
|
41
|
+
const method = (implementation: Method) => {
|
|
42
|
+
const wrapped = fn(implementation)
|
|
43
|
+
created.push(wrapped)
|
|
44
|
+
return wrapped
|
|
45
|
+
}
|
|
46
|
+
const reset = () => {
|
|
47
|
+
for (const wrapped of created) {
|
|
48
|
+
;(wrapped as { mockReset?: () => void }).mockReset?.()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const procedures = new Map<string, Record<string, unknown>>()
|
|
53
|
+
const procedure = (path: string) => {
|
|
54
|
+
let found = procedures.get(path)
|
|
55
|
+
if (!found) {
|
|
56
|
+
const methods = new Map<string, Method>()
|
|
57
|
+
found = new Proxy(
|
|
58
|
+
{},
|
|
59
|
+
{
|
|
60
|
+
get(_target, name) {
|
|
61
|
+
if (typeof name === 'symbol') return undefined
|
|
62
|
+
let existing = methods.get(name)
|
|
63
|
+
if (!existing) {
|
|
64
|
+
existing = method(
|
|
65
|
+
name === 'call'
|
|
66
|
+
? async () => emptyAsyncIterable()
|
|
67
|
+
: name === 'key' || name === 'queryKey'
|
|
68
|
+
? () => []
|
|
69
|
+
: () => ({})
|
|
70
|
+
)
|
|
71
|
+
methods.set(name, existing)
|
|
72
|
+
}
|
|
73
|
+
return existing
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
procedures.set(path, found)
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const namespaces = new Map<string, Record<string, unknown>>()
|
|
83
|
+
const namespace = (name: string) => {
|
|
84
|
+
let found = namespaces.get(name)
|
|
85
|
+
if (!found) {
|
|
86
|
+
const override = overrides?.[name]
|
|
87
|
+
const target = isObject(override) ? { ...override } : {}
|
|
88
|
+
found = new Proxy(target, {
|
|
89
|
+
get(shape, key) {
|
|
90
|
+
if (typeof key === 'symbol') return undefined
|
|
91
|
+
if (key === 'key') return () => keys?.[name] ?? [name]
|
|
92
|
+
if (key in shape) return shape[key]
|
|
93
|
+
return procedure(`${name}.${key}`)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
namespaces.set(name, found)
|
|
97
|
+
}
|
|
98
|
+
return found
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The proxy answers every path at runtime; `T` is the shape tests read it as.
|
|
102
|
+
return new Proxy({ $reset: reset } as OrpcMock<T>, {
|
|
103
|
+
get(_target, key) {
|
|
104
|
+
if (key === '$reset') return reset
|
|
105
|
+
if (typeof key === 'symbol') return undefined
|
|
106
|
+
const override = overrides?.[key]
|
|
107
|
+
if (override !== undefined && !isObject(override)) return override
|
|
108
|
+
return namespace(key)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type TestConnection<T> = {
|
|
114
|
+
input: unknown
|
|
115
|
+
signal: AbortSignal | undefined
|
|
116
|
+
readonly closed: boolean
|
|
117
|
+
/** Deliver an event. Resolves once the consumer asked for the next one, or the stream closed. */
|
|
118
|
+
push: (event: T) => Promise<void>
|
|
119
|
+
/** End the stream normally (`done`). */
|
|
120
|
+
end: () => void
|
|
121
|
+
/** Fail the stream. */
|
|
122
|
+
fail: (error: Error) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type TestStream<T> = {
|
|
126
|
+
/** Pass as the subscribe function; works for any `(input, …, { signal })` shape. */
|
|
127
|
+
subscribe: (...args: unknown[]) => Promise<AsyncIterable<T>>
|
|
128
|
+
/** Every connection opened so far, oldest first. */
|
|
129
|
+
connections: TestConnection<T>[]
|
|
130
|
+
/** Resolves when at least `count` connections have been opened. */
|
|
131
|
+
opened: (count?: number) => Promise<TestConnection<T>>
|
|
132
|
+
/** Push into the newest connection. */
|
|
133
|
+
push: (event: T) => Promise<void>
|
|
134
|
+
end: () => void
|
|
135
|
+
fail: (error: Error) => void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A controllable stand-in for an oRPC event iterator, for driving
|
|
140
|
+
* subscriptions and live queries in tests without timers.
|
|
141
|
+
*/
|
|
142
|
+
export function createTestStream<T>(): TestStream<T> {
|
|
143
|
+
const connections: TestConnection<T>[] = []
|
|
144
|
+
const openWaiters: { count: number; resolve: () => void }[] = []
|
|
145
|
+
|
|
146
|
+
function open(args: unknown[]): TestConnection<T> {
|
|
147
|
+
const options = [...args]
|
|
148
|
+
.reverse()
|
|
149
|
+
.find(
|
|
150
|
+
(arg): arg is { signal?: AbortSignal } =>
|
|
151
|
+
isObject(arg) && 'signal' in arg
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
type Queued = {
|
|
155
|
+
result: IteratorResult<T, undefined>
|
|
156
|
+
delivered: () => void
|
|
157
|
+
}
|
|
158
|
+
const queue: Queued[] = []
|
|
159
|
+
let failure: Error | null = null
|
|
160
|
+
let closed = false
|
|
161
|
+
let pending: {
|
|
162
|
+
resolve: (result: IteratorResult<T, undefined>) => void
|
|
163
|
+
reject: (error: Error) => void
|
|
164
|
+
} | null = null
|
|
165
|
+
// The event the consumer holds; it is processed once it asks for the next.
|
|
166
|
+
let handedOut: (() => void) | null = null
|
|
167
|
+
|
|
168
|
+
const settle = () => {
|
|
169
|
+
if (!pending) return
|
|
170
|
+
const current = pending
|
|
171
|
+
if (failure) {
|
|
172
|
+
pending = null
|
|
173
|
+
current.reject(failure)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const next = queue.shift()
|
|
177
|
+
if (!next) return
|
|
178
|
+
pending = null
|
|
179
|
+
handedOut = next.delivered
|
|
180
|
+
current.resolve(next.result)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const close = () => {
|
|
184
|
+
closed = true
|
|
185
|
+
handedOut?.()
|
|
186
|
+
handedOut = null
|
|
187
|
+
for (const queued of queue) queued.delivered()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// oRPC rejects a pending `next()` with an AbortError when the call is aborted.
|
|
191
|
+
options?.signal?.addEventListener('abort', () => {
|
|
192
|
+
const abort = new Error('This operation was aborted')
|
|
193
|
+
abort.name = 'AbortError'
|
|
194
|
+
failure = abort
|
|
195
|
+
settle()
|
|
196
|
+
close()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const connection: TestConnection<T> = {
|
|
200
|
+
input: args[0],
|
|
201
|
+
signal: options?.signal,
|
|
202
|
+
get closed() {
|
|
203
|
+
return closed
|
|
204
|
+
},
|
|
205
|
+
push(event) {
|
|
206
|
+
if (closed) return Promise.resolve()
|
|
207
|
+
return new Promise<void>((delivered) => {
|
|
208
|
+
queue.push({ result: { done: false, value: event }, delivered })
|
|
209
|
+
settle()
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
end() {
|
|
213
|
+
const done = { done: true as const, value: undefined }
|
|
214
|
+
queue.push({ result: done, delivered: () => {} })
|
|
215
|
+
settle()
|
|
216
|
+
closed = true
|
|
217
|
+
},
|
|
218
|
+
fail(error) {
|
|
219
|
+
failure = error
|
|
220
|
+
settle()
|
|
221
|
+
close()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const iterator: AsyncIterator<T, undefined> = {
|
|
226
|
+
next() {
|
|
227
|
+
handedOut?.()
|
|
228
|
+
handedOut = null
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
pending = { resolve, reject }
|
|
231
|
+
settle()
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
async return() {
|
|
235
|
+
close()
|
|
236
|
+
pending?.resolve({ done: true, value: undefined })
|
|
237
|
+
pending = null
|
|
238
|
+
return { done: true, value: undefined }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
connections.push(connection)
|
|
243
|
+
for (const waiter of [...openWaiters]) {
|
|
244
|
+
if (connections.length < waiter.count) continue
|
|
245
|
+
openWaiters.splice(openWaiters.indexOf(waiter), 1)
|
|
246
|
+
waiter.resolve()
|
|
247
|
+
}
|
|
248
|
+
return Object.assign(connection, {
|
|
249
|
+
[Symbol.asyncIterator]: () => iterator
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const latest = () => {
|
|
254
|
+
const connection = connections.at(-1)
|
|
255
|
+
if (!connection) throw new Error('createTestStream: no connection opened')
|
|
256
|
+
return connection
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
subscribe: async (...args) =>
|
|
261
|
+
open(args) as TestConnection<T> & AsyncIterable<T>,
|
|
262
|
+
connections,
|
|
263
|
+
opened: (count = 1) =>
|
|
264
|
+
new Promise((resolve) => {
|
|
265
|
+
const done = () => resolve(connections[count - 1] as TestConnection<T>)
|
|
266
|
+
if (connections.length >= count) done()
|
|
267
|
+
else openWaiters.push({ count, resolve: done })
|
|
268
|
+
}),
|
|
269
|
+
push: (event) => latest().push(event),
|
|
270
|
+
end: () => latest().end(),
|
|
271
|
+
fail: (error) => latest().fail(error)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Run composables inside a real Vue app with a `QueryClient` and the provided
|
|
277
|
+
* oRPC client, without a DOM. `stop()` disposes the scope, closing streams.
|
|
278
|
+
*/
|
|
279
|
+
export function withOrpcApp<T>(
|
|
280
|
+
setup: () => T,
|
|
281
|
+
options: {
|
|
282
|
+
utils?: unknown
|
|
283
|
+
client?: unknown
|
|
284
|
+
queryClient?: QueryClient
|
|
285
|
+
} = {}
|
|
286
|
+
) {
|
|
287
|
+
const queryClient =
|
|
288
|
+
options.queryClient ??
|
|
289
|
+
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
290
|
+
const app = createApp({ render: () => null })
|
|
291
|
+
app.use(VueQueryPlugin, { queryClient })
|
|
292
|
+
provideOrpc(app, { client: options.client, utils: options.utils })
|
|
293
|
+
const scope = effectScope()
|
|
294
|
+
const result = app.runWithContext(() => scope.run(setup)) as T
|
|
295
|
+
return { result, queryClient, app, stop: () => scope.stop() }
|
|
296
|
+
}
|