orpc-fn 0.3.0 → 0.5.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.
Files changed (95) hide show
  1. package/README.md +146 -5
  2. package/dist/client.d.ts +29 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +49 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/create-fn.d.ts.map +1 -1
  7. package/dist/create-fn.js +2 -8
  8. package/dist/create-fn.js.map +1 -1
  9. package/dist/hono.d.ts +12 -1
  10. package/dist/hono.d.ts.map +1 -1
  11. package/dist/hono.js +15 -1
  12. package/dist/hono.js.map +1 -1
  13. package/dist/live/fn-live.d.ts +1 -6
  14. package/dist/live/fn-live.d.ts.map +1 -1
  15. package/dist/live/fn-live.js +2 -18
  16. package/dist/live/fn-live.js.map +1 -1
  17. package/dist/live/index.d.ts +3 -1
  18. package/dist/live/index.d.ts.map +1 -1
  19. package/dist/live/index.js +2 -0
  20. package/dist/live/index.js.map +1 -1
  21. package/dist/live/invalidation-stream.d.ts +52 -0
  22. package/dist/live/invalidation-stream.d.ts.map +1 -0
  23. package/dist/live/invalidation-stream.js +98 -0
  24. package/dist/live/invalidation-stream.js.map +1 -0
  25. package/dist/live/ioredis.d.ts +1 -0
  26. package/dist/live/ioredis.d.ts.map +1 -1
  27. package/dist/live/ioredis.js +1 -0
  28. package/dist/live/ioredis.js.map +1 -1
  29. package/dist/live/memory.d.ts +2 -1
  30. package/dist/live/memory.d.ts.map +1 -1
  31. package/dist/live/memory.js +6 -1
  32. package/dist/live/memory.js.map +1 -1
  33. package/dist/live/pub-sub.d.ts +5 -0
  34. package/dist/live/pub-sub.d.ts.map +1 -1
  35. package/dist/live/pub-sub.js +36 -15
  36. package/dist/live/pub-sub.js.map +1 -1
  37. package/dist/live/redis-bun.d.ts +3 -0
  38. package/dist/live/redis-bun.d.ts.map +1 -1
  39. package/dist/live/redis-bun.js +1 -0
  40. package/dist/live/redis-bun.js.map +1 -1
  41. package/dist/live/redis.d.ts +4 -1
  42. package/dist/live/redis.d.ts.map +1 -1
  43. package/dist/live/redis.js +14 -2
  44. package/dist/live/redis.js.map +1 -1
  45. package/dist/live/scoped-change-bus.d.ts +45 -0
  46. package/dist/live/scoped-change-bus.d.ts.map +1 -0
  47. package/dist/live/scoped-change-bus.js +38 -0
  48. package/dist/live/scoped-change-bus.js.map +1 -0
  49. package/dist/live/transport.d.ts +11 -0
  50. package/dist/live/transport.d.ts.map +1 -1
  51. package/dist/live/transport.js.map +1 -1
  52. package/dist/otel.d.ts +5 -0
  53. package/dist/otel.d.ts.map +1 -1
  54. package/dist/otel.js +7 -0
  55. package/dist/otel.js.map +1 -1
  56. package/dist/procedure-cli.d.ts +25 -0
  57. package/dist/procedure-cli.d.ts.map +1 -0
  58. package/dist/procedure-cli.js +67 -0
  59. package/dist/procedure-cli.js.map +1 -0
  60. package/dist/react.d.ts +116 -0
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +156 -0
  63. package/dist/react.js.map +1 -0
  64. package/dist/realtime.d.ts +114 -0
  65. package/dist/realtime.d.ts.map +1 -0
  66. package/dist/realtime.js +316 -0
  67. package/dist/realtime.js.map +1 -0
  68. package/dist/vue/testing.d.ts +67 -0
  69. package/dist/vue/testing.d.ts.map +1 -0
  70. package/dist/vue/testing.js +225 -0
  71. package/dist/vue/testing.js.map +1 -0
  72. package/dist/vue.d.ts +211 -0
  73. package/dist/vue.d.ts.map +1 -0
  74. package/dist/vue.js +312 -0
  75. package/dist/vue.js.map +1 -0
  76. package/package.json +51 -4
  77. package/src/client.ts +57 -0
  78. package/src/create-fn.ts +2 -7
  79. package/src/hono.ts +22 -2
  80. package/src/live/fn-live.ts +3 -22
  81. package/src/live/index.ts +12 -1
  82. package/src/live/invalidation-stream.ts +142 -0
  83. package/src/live/ioredis.ts +2 -0
  84. package/src/live/memory.ts +11 -2
  85. package/src/live/pub-sub.ts +48 -17
  86. package/src/live/redis-bun.ts +3 -0
  87. package/src/live/redis.ts +33 -6
  88. package/src/live/scoped-change-bus.ts +98 -0
  89. package/src/live/transport.ts +12 -0
  90. package/src/otel.ts +8 -0
  91. package/src/procedure-cli.ts +99 -0
  92. package/src/react.ts +355 -0
  93. package/src/realtime.ts +416 -0
  94. package/src/vue/testing.ts +296 -0
  95. package/src/vue.ts +783 -0
package/src/react.ts ADDED
@@ -0,0 +1,355 @@
1
+ import {
2
+ focusManager,
3
+ hashKey,
4
+ keepPreviousData,
5
+ type QueryKey,
6
+ type UseQueryOptions,
7
+ type UseQueryResult,
8
+ useQuery,
9
+ useQueryClient
10
+ } from '@tanstack/react-query'
11
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
12
+ import {
13
+ createInvalidationCoordinator,
14
+ createStreamScope,
15
+ createSubscriptionRunner,
16
+ type InvalidationStreamOptions,
17
+ type LiveConnectionStatus,
18
+ type SubscriptionRunner,
19
+ seedQueryFn
20
+ } from './realtime.js'
21
+
22
+ export type { InvalidationDomain, LiveConnectionStatus } from './realtime.js'
23
+
24
+ /** An oRPC client procedure fits: `client.order.watch`. */
25
+ export type SubscribeFn<TInput, TEvent> = (
26
+ input: TInput,
27
+ options: { signal: AbortSignal }
28
+ ) => Promise<AsyncIterable<TEvent>>
29
+
30
+ export type UseOrpcSubscriptionOptions<TInput, TEvent> = {
31
+ /** Run the stream (default: true). */
32
+ enabled?: boolean | undefined
33
+ onEvent?: (
34
+ event: TEvent,
35
+ meta: { connectionId: number; input: TInput }
36
+ ) => void
37
+ /** Every successful connect; `connectionId > 1` is a reconnect. */
38
+ onConnect?: (connectionId: number) => void
39
+ onError?: ((error: Error) => void) | undefined
40
+ onClose?: () => void
41
+ /**
42
+ * Close the stream while the app is not focused (default: true). Focus is
43
+ * TanStack's `focusManager`: on React Native, wire it to `AppState` once.
44
+ */
45
+ pauseWhenUnfocused?: boolean | undefined
46
+ }
47
+
48
+ const subscribeFocus = (listener: () => void) =>
49
+ focusManager.subscribe(listener)
50
+ const getFocus = () => focusManager.isFocused()
51
+
52
+ /** Whether the app is focused, from TanStack's `focusManager`. */
53
+ function useIsFocused(): boolean {
54
+ return useSyncExternalStore(subscribeFocus, getFocus, getFocus)
55
+ }
56
+
57
+ /**
58
+ * An oRPC event iterator with backoff reconnects that closes while the app is
59
+ * backgrounded. A changed input (compared as JSON) reconnects with a fresh
60
+ * retry budget.
61
+ */
62
+ export function useOrpcSubscription<TInput, TEvent>(
63
+ input: TInput,
64
+ subscribe: SubscribeFn<TInput, TEvent>,
65
+ options: UseOrpcSubscriptionOptions<TInput, TEvent> = {}
66
+ ): { status: LiveConnectionStatus } {
67
+ const { enabled = true, pauseWhenUnfocused = true } = options
68
+ const focused = useIsFocused()
69
+ const shouldRun = enabled && (!pauseWhenUnfocused || focused)
70
+ const [status, setStatus] = useState<LiveConnectionStatus>('idle')
71
+
72
+ const latest = useRef({ input, subscribe, options, shouldRun })
73
+ latest.current = { input, subscribe, options, shouldRun }
74
+
75
+ const runnerRef = useRef<SubscriptionRunner | null>(null)
76
+ if (!runnerRef.current) {
77
+ let openedInput = input
78
+ runnerRef.current = createSubscriptionRunner<TEvent>({
79
+ open: (signal) => {
80
+ openedInput = latest.current.input
81
+ return latest.current.subscribe(openedInput, { signal })
82
+ },
83
+ canConnect: () => latest.current.shouldRun,
84
+ shouldReconnect: () => latest.current.shouldRun,
85
+ onStatus: setStatus,
86
+ onEvent: (event, connectionId) =>
87
+ latest.current.options.onEvent?.(event, {
88
+ connectionId,
89
+ input: openedInput
90
+ }),
91
+ onConnect: (id) => latest.current.options.onConnect?.(id),
92
+ onError: (error) => latest.current.options.onError?.(error),
93
+ onClose: () => latest.current.options.onClose?.()
94
+ })
95
+ }
96
+ const runner = runnerRef.current
97
+ const serializedInput = JSON.stringify(input)
98
+
99
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a changed input resubscribes
100
+ useEffect(() => {
101
+ if (!shouldRun) return
102
+ void runner.start()
103
+ return () => void runner.disconnect()
104
+ }, [runner, shouldRun, serializedInput])
105
+
106
+ return { status }
107
+ }
108
+
109
+ type ApplyEvent<TStreamEvent, TData> = (
110
+ event: TStreamEvent,
111
+ previous: TData | undefined
112
+ ) => TData | undefined
113
+
114
+ type LiveQueryOptions<
115
+ TInput,
116
+ TRouteData,
117
+ TStreamEvent,
118
+ TData,
119
+ TSubscriptionInput
120
+ > = {
121
+ /** Run the query and its stream (default: true). */
122
+ enabled?: boolean | undefined
123
+ /** Input for the stream when it differs from the query's. */
124
+ subscriptionInput?: TSubscriptionInput | undefined
125
+ /**
126
+ * Convert the route output once at fetch time into a differently typed
127
+ * cache; `applyEvent` then reduces stream events into that cache.
128
+ */
129
+ select?: (data: TRouteData) => TData
130
+ /** The raw route output of a seed, before `select` drops fields. */
131
+ onSeed?: (data: TRouteData, input: TInput) => void
132
+ /**
133
+ * Fold a stream event into the cached data. Required when the stream sends
134
+ * something other than the cached type (an `fnLive` patch route, or a
135
+ * `select`ed cache). Return `undefined` to skip the write.
136
+ */
137
+ applyEvent?: ApplyEvent<TStreamEvent, NoInfer<TData>>
138
+ onEvent?: (event: TStreamEvent, meta: { connectionId: number }) => void
139
+ onError?: ((error: Error) => void) | undefined
140
+ /**
141
+ * Reconcile a fetched seed with what the stream already wrote. The HTTP
142
+ * request can resolve last, so a plain overwrite would drop events that
143
+ * arrived while it was in flight. Default: take the fetched value.
144
+ */
145
+ mergeSeed?: (
146
+ fetched: NoInfer<TData>,
147
+ cached: NoInfer<TData> | undefined
148
+ ) => NoInfer<TData>
149
+ /** Show the previous key's data while a new key loads (default: true). */
150
+ keepPreviousData?: boolean
151
+ /** Close the stream while the app is not focused (default: true). */
152
+ pauseWhenUnfocused?: boolean | undefined
153
+ }
154
+
155
+ type LiveQueryOptionsFn<TInput, TData, TError> = (
156
+ input: TInput
157
+ ) => UseQueryOptions<TData, TError, TData, QueryKey>
158
+
159
+ type LiveQueryResult<TData, TError> = UseQueryResult<TData, TError> & {
160
+ liveStatus: LiveConnectionStatus
161
+ }
162
+
163
+ /**
164
+ * The client half of `fnLive`: the HTTP query seeds the cache, and the live
165
+ * route's stream writes each snapshot into the same cache entry. Marked
166
+ * `meta.live`, so invalidation streams leave it alone.
167
+ *
168
+ * ```tsx
169
+ * const order = useLiveQuery({ id }, (input) => orpc.order.get.queryOptions({ input }), client.order.watch)
170
+ * ```
171
+ *
172
+ * One signature, not overloads: TypeScript fixes an untyped callback's
173
+ * parameters at the first overload it tries, which made valid calls fail.
174
+ */
175
+ export function useLiveQuery<
176
+ TInput,
177
+ TRouteData,
178
+ TStreamEvent,
179
+ TData = TRouteData,
180
+ TError = Error,
181
+ TSubscriptionInput = TInput
182
+ >(
183
+ input: TInput,
184
+ queryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,
185
+ subscribe: SubscribeFn<NoInfer<TSubscriptionInput>, TStreamEvent>,
186
+ ...options: [TStreamEvent] extends [TData]
187
+ ? [
188
+ options?: LiveQueryOptions<
189
+ TInput,
190
+ TRouteData,
191
+ TStreamEvent,
192
+ TData,
193
+ TSubscriptionInput
194
+ >
195
+ ]
196
+ : [
197
+ options: LiveQueryOptions<
198
+ TInput,
199
+ TRouteData,
200
+ TStreamEvent,
201
+ TData,
202
+ TSubscriptionInput
203
+ > & { applyEvent: ApplyEvent<TStreamEvent, NoInfer<TData>> }
204
+ ]
205
+ ): LiveQueryResult<TData, TError>
206
+ export function useLiveQuery<
207
+ TInput,
208
+ TRouteData,
209
+ TStreamEvent,
210
+ TData,
211
+ TError,
212
+ TSubscriptionInput
213
+ >(
214
+ input: TInput,
215
+ queryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,
216
+ subscribe: SubscribeFn<TSubscriptionInput, TStreamEvent>,
217
+ options: LiveQueryOptions<
218
+ TInput,
219
+ TRouteData,
220
+ TStreamEvent,
221
+ TData,
222
+ TSubscriptionInput
223
+ > = {}
224
+ ): LiveQueryResult<TData, TError> {
225
+ const queryClient = useQueryClient()
226
+ const focused = useIsFocused()
227
+ const [scope] = useState(createStreamScope)
228
+
229
+ // oRPC query options are plain objects; `queryFn` is read through one shape.
230
+ const raw = queryOptionsFn(input) as unknown as {
231
+ queryKey: QueryKey
232
+ queryFn?: (context: unknown) => Promise<TRouteData>
233
+ enabled?: UseQueryOptions['enabled']
234
+ meta?: Record<string, unknown>
235
+ } & Record<string, unknown>
236
+ const keyHash = hashKey(raw.queryKey)
237
+ const latest = useRef({ raw, keyHash, options })
238
+ latest.current = { raw, keyHash, options }
239
+
240
+ const { status } = useOrpcSubscription(
241
+ (options.subscriptionInput === undefined
242
+ ? input
243
+ : options.subscriptionInput) as TSubscriptionInput,
244
+ subscribe,
245
+ {
246
+ enabled: options.enabled,
247
+ pauseWhenUnfocused: options.pauseWhenUnfocused,
248
+ onError: options.onError,
249
+ onConnect: (connectionId) =>
250
+ scope.open(connectionId, latest.current.keyHash),
251
+ onEvent: (event, { connectionId }) => {
252
+ const current = latest.current
253
+ // The consumer callback must not see an event for a scope no longer shown.
254
+ if (!scope.owns(connectionId, current.keyHash)) return
255
+ // Without `applyEvent` the stream event is the cache type (the first overload).
256
+ const applyEvent =
257
+ current.options.applyEvent ??
258
+ ((streamEvent: TStreamEvent) => streamEvent as unknown as TData)
259
+ const key = current.raw.queryKey
260
+ const next = applyEvent(event, queryClient.getQueryData<TData>(key))
261
+ if (next !== undefined) queryClient.setQueryData(key, next)
262
+ current.options.onEvent?.(event, { connectionId })
263
+ }
264
+ }
265
+ )
266
+
267
+ const wrapsSeed = options.select || options.mergeSeed
268
+ const queryFn =
269
+ raw.queryFn && wrapsSeed
270
+ ? seedQueryFn(raw.queryFn, {
271
+ input,
272
+ readCache: () => queryClient.getQueryData<TData>(raw.queryKey),
273
+ select: options.select,
274
+ onSeed: options.onSeed,
275
+ mergeSeed: options.mergeSeed
276
+ })
277
+ : raw.queryFn
278
+ const runs = options.enabled !== false && focused
279
+ const query = useQuery({
280
+ ...raw,
281
+ ...(queryFn ? { queryFn } : {}),
282
+ enabled: runs ? (raw.enabled ?? true) : false,
283
+ ...(options.keepPreviousData === false
284
+ ? {}
285
+ : { placeholderData: keepPreviousData }),
286
+ staleTime: Number.POSITIVE_INFINITY,
287
+ meta: { ...raw.meta, live: true }
288
+ } as UseQueryOptions<TData, TError, TData, QueryKey>)
289
+
290
+ return Object.assign(query, { liveStatus: status })
291
+ }
292
+
293
+ /**
294
+ * One app-wide stream of `{ domain }` change events that keeps list and count
295
+ * queries fresh: each event invalidates its domain's query keys (debounced),
296
+ * and a reconnect that proves healthy invalidates every domain to cover
297
+ * events lost while it was down. Mount once, in the signed-in shell.
298
+ *
299
+ * ```ts
300
+ * useInvalidationStream({}, client.realtime.subscribeChanges, {
301
+ * enabled: signedIn,
302
+ * domains: { order: { debounceMs: 100, queryKeys: [orpc.order.key()] } }
303
+ * })
304
+ * ```
305
+ */
306
+ export function useInvalidationStream<
307
+ TInput,
308
+ TEvent extends { domain: string }
309
+ >(
310
+ input: TInput,
311
+ subscribe: SubscribeFn<TInput, TEvent>,
312
+ options: InvalidationStreamOptions<TEvent> &
313
+ Pick<
314
+ UseOrpcSubscriptionOptions<TInput, TEvent>,
315
+ 'enabled' | 'pauseWhenUnfocused'
316
+ >
317
+ ): { status: LiveConnectionStatus } {
318
+ const queryClient = useQueryClient()
319
+ const latestOptions = useRef(options)
320
+ latestOptions.current = options
321
+ // `domains`, `refetchType` and `logger` are read on mount; `onEvent` stays current.
322
+ const [coordinator] = useState(() =>
323
+ createInvalidationCoordinator<TEvent>(queryClient, {
324
+ ...options,
325
+ onEvent: (event) => latestOptions.current.onEvent?.(event)
326
+ })
327
+ )
328
+ useEffect(() => coordinator.dispose, [coordinator])
329
+
330
+ return useOrpcSubscription<TInput, TEvent>(input, subscribe, {
331
+ enabled: options.enabled,
332
+ pauseWhenUnfocused: options.pauseWhenUnfocused,
333
+ onEvent: coordinator.event,
334
+ onConnect: coordinator.connect,
335
+ onError: coordinator.lost,
336
+ onClose: coordinator.lost
337
+ })
338
+ }
339
+
340
+ /**
341
+ * Clear the query cache when the signed-in owner of the cached data changes
342
+ * (another user, another organization), so the next one never sees it.
343
+ * `null` means signed out: signing in starts from the cache sign-out emptied.
344
+ * Pass the same owner as the persister's `buster` so a saved cache from
345
+ * someone else is discarded on the next launch too.
346
+ */
347
+ export function useQueryCacheOwner(owner: string | null): void {
348
+ const queryClient = useQueryClient()
349
+ const previous = useRef(owner)
350
+ useEffect(() => {
351
+ const before = previous.current
352
+ previous.current = owner
353
+ if (owner !== before && before !== null) queryClient.clear()
354
+ }, [owner, queryClient])
355
+ }