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.
Files changed (93) hide show
  1. package/README.md +146 -4
  2. package/dist/cli.js +37 -48
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client.d.ts +29 -0
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +49 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/create-fn.d.ts.map +1 -1
  9. package/dist/create-fn.js +0 -1
  10. package/dist/create-fn.js.map +1 -1
  11. package/dist/hono.d.ts +12 -1
  12. package/dist/hono.d.ts.map +1 -1
  13. package/dist/hono.js +15 -1
  14. package/dist/hono.js.map +1 -1
  15. package/dist/live/fn-live.d.ts +1 -6
  16. package/dist/live/fn-live.d.ts.map +1 -1
  17. package/dist/live/fn-live.js +2 -18
  18. package/dist/live/fn-live.js.map +1 -1
  19. package/dist/live/index.d.ts +3 -1
  20. package/dist/live/index.d.ts.map +1 -1
  21. package/dist/live/index.js +2 -0
  22. package/dist/live/index.js.map +1 -1
  23. package/dist/live/invalidation-stream.d.ts +52 -0
  24. package/dist/live/invalidation-stream.d.ts.map +1 -0
  25. package/dist/live/invalidation-stream.js +98 -0
  26. package/dist/live/invalidation-stream.js.map +1 -0
  27. package/dist/live/ioredis.d.ts +1 -0
  28. package/dist/live/ioredis.d.ts.map +1 -1
  29. package/dist/live/ioredis.js +1 -0
  30. package/dist/live/ioredis.js.map +1 -1
  31. package/dist/live/memory.d.ts +2 -1
  32. package/dist/live/memory.d.ts.map +1 -1
  33. package/dist/live/memory.js +6 -1
  34. package/dist/live/memory.js.map +1 -1
  35. package/dist/live/pub-sub.d.ts +5 -0
  36. package/dist/live/pub-sub.d.ts.map +1 -1
  37. package/dist/live/pub-sub.js +16 -3
  38. package/dist/live/pub-sub.js.map +1 -1
  39. package/dist/live/redis-bun.d.ts +3 -0
  40. package/dist/live/redis-bun.d.ts.map +1 -1
  41. package/dist/live/redis-bun.js +1 -0
  42. package/dist/live/redis-bun.js.map +1 -1
  43. package/dist/live/redis.d.ts +4 -1
  44. package/dist/live/redis.d.ts.map +1 -1
  45. package/dist/live/redis.js +14 -2
  46. package/dist/live/redis.js.map +1 -1
  47. package/dist/live/scoped-change-bus.d.ts +45 -0
  48. package/dist/live/scoped-change-bus.d.ts.map +1 -0
  49. package/dist/live/scoped-change-bus.js +38 -0
  50. package/dist/live/scoped-change-bus.js.map +1 -0
  51. package/dist/live/transport.d.ts +11 -0
  52. package/dist/live/transport.d.ts.map +1 -1
  53. package/dist/live/transport.js.map +1 -1
  54. package/dist/procedure-cli.d.ts +25 -0
  55. package/dist/procedure-cli.d.ts.map +1 -0
  56. package/dist/procedure-cli.js +67 -0
  57. package/dist/procedure-cli.js.map +1 -0
  58. package/dist/react.d.ts +116 -0
  59. package/dist/react.d.ts.map +1 -0
  60. package/dist/react.js +156 -0
  61. package/dist/react.js.map +1 -0
  62. package/dist/realtime.d.ts +114 -0
  63. package/dist/realtime.d.ts.map +1 -0
  64. package/dist/realtime.js +316 -0
  65. package/dist/realtime.js.map +1 -0
  66. package/dist/vue/testing.d.ts +67 -0
  67. package/dist/vue/testing.d.ts.map +1 -0
  68. package/dist/vue/testing.js +225 -0
  69. package/dist/vue/testing.js.map +1 -0
  70. package/dist/vue.d.ts +211 -0
  71. package/dist/vue.d.ts.map +1 -0
  72. package/dist/vue.js +312 -0
  73. package/dist/vue.js.map +1 -0
  74. package/package.json +49 -3
  75. package/src/cli.ts +47 -57
  76. package/src/client.ts +57 -0
  77. package/src/create-fn.ts +0 -1
  78. package/src/hono.ts +22 -2
  79. package/src/live/fn-live.ts +3 -22
  80. package/src/live/index.ts +12 -1
  81. package/src/live/invalidation-stream.ts +142 -0
  82. package/src/live/ioredis.ts +2 -0
  83. package/src/live/memory.ts +11 -2
  84. package/src/live/pub-sub.ts +23 -2
  85. package/src/live/redis-bun.ts +3 -0
  86. package/src/live/redis.ts +33 -6
  87. package/src/live/scoped-change-bus.ts +98 -0
  88. package/src/live/transport.ts +12 -0
  89. package/src/procedure-cli.ts +99 -0
  90. package/src/react.ts +355 -0
  91. package/src/realtime.ts +416 -0
  92. package/src/vue/testing.ts +296 -0
  93. package/src/vue.ts +783 -0
@@ -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
+ }