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
package/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ClientContext, ORPCError, type ORPCErrorCode } from '@orpc/client'
2
2
  import { RPCLink, type RPCLinkOptions } from '@orpc/client/fetch'
3
3
  import { BatchLinkPlugin } from '@orpc/client/plugins'
4
+ import { StandardRPCJsonSerializer } from '@orpc/client/standard'
4
5
 
5
6
  type BatchOptions<T extends ClientContext> = ConstructorParameters<
6
7
  typeof BatchLinkPlugin<T>
@@ -87,3 +88,59 @@ export function hasOrpcErrorCode<TCode extends ORPCErrorCode>(
87
88
  ): error is ORPCError<TCode, unknown> {
88
89
  return error instanceof ORPCError && error.code === code
89
90
  }
91
+
92
+ /**
93
+ * The server's message, only when the backend marked it user-safe by putting
94
+ * a `userMessage` string on the error's `data`. Route error messages are
95
+ * developer text (English, internal model names), so they are not shown by
96
+ * default; they stay on the error for logs and error tracking.
97
+ *
98
+ * ```ts
99
+ * throw new ORPCError('BAD_REQUEST', {
100
+ * message: 'Deal has no pipeline stage', // logs, telemetry
101
+ * data: { userMessage: 'Choose a stage first.' } // the user reads this
102
+ * })
103
+ * ```
104
+ */
105
+ export function userSafeServerMessage(error: unknown): string | null {
106
+ if (!(error instanceof ORPCError)) return null
107
+ const data: unknown = error.data
108
+ if (typeof data !== 'object' || data === null) return null
109
+ const userMessage = (data as { userMessage?: unknown }).userMessage
110
+ return typeof userMessage === 'string' && userMessage.trim().length > 0
111
+ ? userMessage
112
+ : null
113
+ }
114
+
115
+ /** User-facing text for a caught error: the opted-in server message, else your localized fallback. */
116
+ export function userFacingErrorMessage(
117
+ error: unknown,
118
+ fallback: string
119
+ ): string {
120
+ return userSafeServerMessage(error) ?? fallback
121
+ }
122
+
123
+ const cacheSerializer = new StandardRPCJsonSerializer()
124
+
125
+ /**
126
+ * `serialize`/`deserialize` for a TanStack Query persister. API payloads carry
127
+ * `Date`s (and maybe `Map`s, `Set`s, `BigInt`s) that plain JSON returns as
128
+ * strings; oRPC's own serializer round-trips them.
129
+ *
130
+ * ```ts
131
+ * createAsyncStoragePersister({ storage: AsyncStorage, ...queryCacheSerializer })
132
+ * ```
133
+ */
134
+ export const queryCacheSerializer = {
135
+ serialize(value: unknown): string {
136
+ const [json, meta] = cacheSerializer.serialize(value)
137
+ return JSON.stringify({ json, meta })
138
+ },
139
+ deserialize<T>(text: string): T {
140
+ const { json, meta } = JSON.parse(text) as {
141
+ json: unknown
142
+ meta: Parameters<typeof cacheSerializer.deserialize>[1]
143
+ }
144
+ return cacheSerializer.deserialize(json, meta) as T
145
+ }
146
+ }
package/src/create-fn.ts CHANGED
@@ -542,7 +542,6 @@ export function createFn<
542
542
  fnLive: createFnLive({
543
543
  fn,
544
544
  createChannel: live.createChannel,
545
- createLogger,
546
545
  isGuard
547
546
  }),
548
547
  createPubSub: live.createPubSub,
package/src/hono.ts CHANGED
@@ -75,7 +75,10 @@ export type MountOrpcOptions<
75
75
  plugins?: Plugin<InferRouterInitialContext<R>>[]
76
76
  /** Rewrite request headers before they reach the context (see `normalizeExpoOrigin`). */
77
77
  normalizeHeaders?: (headers: Headers) => Headers
78
- /** Time spent before the handler ran, reported as `queue`. */
78
+ /**
79
+ * Time spent before the handler ran, reported as `queue`. Default: time
80
+ * since {@link markRequestArrival}, when the server marked the request.
81
+ */
79
82
  queueMs?: (request: Request) => number | undefined
80
83
  /**
81
84
  * Unexpected procedure errors (oRPC turns thrown errors into responses, so
@@ -86,6 +89,23 @@ export type MountOrpcOptions<
86
89
  isExpectedError?: (error: unknown) => boolean
87
90
  }
88
91
 
92
+ const arrivals = new WeakMap<Request, number>()
93
+
94
+ /**
95
+ * Marks a request at the server's front door (`Bun.serve({ fetch })`), before
96
+ * routing and middleware. `mountOrpc` reports the time until the procedure
97
+ * runs as `queue_ms`: in-process queueing no handler timer can see.
98
+ */
99
+ export function markRequestArrival(request: Request): void {
100
+ arrivals.set(request, performance.now())
101
+ }
102
+
103
+ /** Milliseconds since {@link markRequestArrival}, or undefined when unmarked. */
104
+ export function msSinceRequestArrival(request: Request): number | undefined {
105
+ const arrivedAt = arrivals.get(request)
106
+ return arrivedAt === undefined ? undefined : performance.now() - arrivedAt
107
+ }
108
+
89
109
  /**
90
110
  * Expo native sends `expo-origin` but not `origin`; copy it so cookie-based
91
111
  * auth (e.g. Better Auth) sees the origin.
@@ -175,7 +195,7 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
175
195
  ) => {
176
196
  app.use(`${prefix}/*`, async (c, next) => {
177
197
  const timing: RequestTiming = {}
178
- const queueMs = options.queueMs?.(c.req.raw)
198
+ const queueMs = (options.queueMs ?? msSinceRequestArrival)(c.req.raw)
179
199
  if (queueMs !== undefined)
180
200
  timing.queue_ms = Math.round(queueMs * 100) / 100
181
201
  const started = performance.now()
@@ -4,7 +4,7 @@ import type { ZodType, z } from 'zod'
4
4
  import { RAW_INPUT } from '../timing.js'
5
5
  import { LIVE_GAP, positiveInteger } from './lifecycle.js'
6
6
  import type { FnLogger } from '../logger.js'
7
- import { errorMessageOf, type SpanLike } from '../otel.js'
7
+ import { errorMessageOf } from '../otel.js'
8
8
  import type {
9
9
  FnContext,
10
10
  FnDefinition,
@@ -100,8 +100,6 @@ export type FnLiveConfig<
100
100
  }
101
101
  ) => MaybePromise<boolean>
102
102
  authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
103
- /** Log publish failures instead of throwing them into the publishing route. */
104
- safePublish?: boolean
105
103
  /** Batch events arriving within this window into one snapshot update. */
106
104
  coalesceMs?: number
107
105
  /** Maximum events collected in one coalescing batch (default 1000). */
@@ -441,7 +439,6 @@ type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
441
439
  reauthorize?: AuthFn<Record<string, unknown>, unknown>
442
440
  transformerFn?: (params: RuntimeParams & Record<string, unknown>) => unknown
443
441
  shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
444
- safePublish?: boolean
445
442
  coalesceMs?: number
446
443
  maxBatchSize?: number
447
444
  summary?: string
@@ -456,11 +453,9 @@ export function createFnLive(runtime: {
456
453
  // biome-ignore lint/suspicious/noExplicitAny: runtime half of the typed `Fn`
457
454
  fn: (options: any) => AnyProcedure
458
455
  createChannel: (options: ChannelOptions) => Channel
459
- createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
460
456
  isGuard: (option: string) => boolean
461
457
  }) {
462
458
  const { fn, createChannel, isGuard } = runtime
463
- const liveLogger = runtime.createLogger('fn-live', undefined)
464
459
 
465
460
  return (
466
461
  options: {
@@ -495,22 +490,8 @@ export function createFnLive(runtime: {
495
490
  // `createChannel` reads only the channel keys of the live config.
496
491
  const channel = createChannel({ ...live, name: liveName })
497
492
 
498
- const publish = async (event: unknown) => {
499
- if (!live.safePublish) return channel.publish(event)
500
- try {
501
- await channel.publish(event)
502
- } catch (err) {
503
- // Live update failures must not roll back the route that emitted them,
504
- // but they should still be visible in logs.
505
- liveLogger.error('fnLive publish failed', {
506
- name: liveName,
507
- err: errorMessageOf(err)
508
- })
509
- }
510
- }
511
-
512
493
  const runHandler = (params: RuntimeParams) =>
513
- handler({ ...params, publish })
494
+ handler({ ...params, publish: channel.publish })
514
495
 
515
496
  const procedure = fn({
516
497
  ...routeConfig,
@@ -640,7 +621,7 @@ export function createFnLive(runtime: {
640
621
  return {
641
622
  procedure,
642
623
  subscribe,
643
- publish,
624
+ publish: channel.publish,
644
625
  getChannelName: channel.getChannelName,
645
626
  getSubscriptionChannelName: channel.getSubscriptionChannelName,
646
627
  getPublishChannelName: channel.getPublishChannelName
package/src/live/index.ts CHANGED
@@ -8,6 +8,10 @@ export {
8
8
  streamLiveSnapshots,
9
9
  throwInitialSnapshotError
10
10
  } from './fn-live.js'
11
+ export {
12
+ type InvalidationEvent,
13
+ invalidationStream
14
+ } from './invalidation-stream.js'
11
15
  export {
12
16
  type AuthFn,
13
17
  type ChannelDefinition,
@@ -25,9 +29,16 @@ export {
25
29
  type PubSubOptions,
26
30
  type PubSubRuntimeOptions
27
31
  } from './pub-sub.js'
32
+ export {
33
+ type ChangeBusChannel,
34
+ type ChangeBusScope,
35
+ defineScopedChangeBus,
36
+ type ScopedChangeBus
37
+ } from './scoped-change-bus.js'
28
38
  export {
29
39
  type BacklogOptions,
30
40
  backlogKey,
31
41
  type PubSubMessage,
32
- type PubSubTransport
42
+ type PubSubTransport,
43
+ type PubSubTransportStats
33
44
  } from './transport.js'
@@ -0,0 +1,142 @@
1
+ import {
2
+ type AnyProcedure,
3
+ call,
4
+ type Procedure,
5
+ type Schema
6
+ } from '@orpc/server'
7
+
8
+ /** A streaming procedure taking no input, such as a `createPubSub` subscribe route. */
9
+ /* biome-ignore-start lint/suspicious/noExplicitAny: matches any context, input, errors and meta */
10
+ type StreamProcedure<TEvent> = Procedure<
11
+ any,
12
+ any,
13
+ any,
14
+ Schema<any, AsyncIterable<TEvent>>,
15
+ any,
16
+ any
17
+ >
18
+ /* biome-ignore-end lint/suspicious/noExplicitAny: matches any context, input, errors and meta */
19
+
20
+ /** One event of an invalidation stream: the domain that changed, plus whatever its mapper added. */
21
+ export type InvalidationEvent<
22
+ TEvents extends Record<string, unknown>,
23
+ TExtras extends { [K in keyof TEvents]?: object }
24
+ > = {
25
+ [K in keyof TEvents & string]: { domain: K } & (K extends keyof TExtras
26
+ ? Partial<TExtras[K]>
27
+ : unknown)
28
+ }[keyof TEvents & string]
29
+
30
+ /**
31
+ * Fans several change streams into one stream of `{ domain }` events, so a
32
+ * client keeps one connection and invalidates queries per domain. Use it as
33
+ * the body of a streaming `fn`:
34
+ *
35
+ * ```ts
36
+ * fn({
37
+ * name: 'realtime.changes',
38
+ * stream: true,
39
+ * input: z.object({}),
40
+ * handler: ({ context, signal }) =>
41
+ * invalidationStream({
42
+ * context,
43
+ * signal,
44
+ * sources: { deal: dealChanges.subscribe, workItem: workItemChanges.subscribe },
45
+ * // Optional: forward fields a client acts on (alerts), not just the domain.
46
+ * events: { deal: (event) => (event.kind === 'won' ? { dealId: event.dealId } : undefined) }
47
+ * })
48
+ * })
49
+ * ```
50
+ *
51
+ * Each source is called with `{}`, the caller's context and a signal of its
52
+ * own. Events that queue up while the consumer is busy collapse when equal.
53
+ * When one source ends, the stream ends (a deploy drain); when one throws,
54
+ * the stream throws. Either way every source is closed and the client
55
+ * reconnects, which recreates them all.
56
+ */
57
+ export async function* invalidationStream<
58
+ TEvents extends Record<string, unknown>,
59
+ TExtras extends { [K in keyof TEvents]?: object } = Record<never, never>
60
+ >(options: {
61
+ context: unknown
62
+ signal?: AbortSignal | undefined
63
+ sources: { [K in keyof TEvents]: StreamProcedure<TEvents[K]> }
64
+ events?: {
65
+ [K in keyof TExtras]: (
66
+ value: NoInfer<TEvents[K & keyof TEvents]>
67
+ ) => TExtras[K] | undefined
68
+ }
69
+ }): AsyncGenerator<InvalidationEvent<TEvents, TExtras>, void, unknown> {
70
+ type Event = InvalidationEvent<TEvents, TExtras>
71
+ const controller = new AbortController()
72
+ const abort = () => controller.abort()
73
+ if (options.signal?.aborted) abort()
74
+ options.signal?.addEventListener('abort', abort, { once: true })
75
+
76
+ // Keyed by the serialized event: equal events collapse while queued.
77
+ const queue = new Map<string, Event>()
78
+ let wake: (() => void) | undefined
79
+ let ended = false
80
+ let failure: { error: unknown } | undefined
81
+ const stop = (error?: { error: unknown }) => {
82
+ if (ended) return
83
+ ended = true
84
+ failure = error
85
+ controller.abort()
86
+ wake?.()
87
+ }
88
+ controller.signal.addEventListener('abort', () => stop(), { once: true })
89
+
90
+ const mappers = (options.events ?? {}) as Record<
91
+ string,
92
+ ((value: unknown) => object | undefined) | undefined
93
+ >
94
+ const pump = async (domain: string, procedure: AnyProcedure) => {
95
+ let iterator: AsyncIterator<unknown> | undefined
96
+ try {
97
+ // oRPC's `call`, not the handler's bound call: closing a source needs
98
+ // a signal of its own, because return() waits behind a pending next().
99
+ const stream = (await call(
100
+ procedure,
101
+ {},
102
+ { context: options.context, signal: controller.signal }
103
+ )) as AsyncIterable<unknown>
104
+ iterator = stream[Symbol.asyncIterator]()
105
+ while (!ended) {
106
+ const result = await iterator.next()
107
+ if (result.done) return stop()
108
+ const event = { ...mappers[domain]?.(result.value), domain } as Event
109
+ queue.set(JSON.stringify(event), event)
110
+ wake?.()
111
+ }
112
+ } catch (error) {
113
+ stop({ error })
114
+ } finally {
115
+ await iterator?.return?.().catch(() => {})
116
+ }
117
+ }
118
+ const pumps = Object.entries(options.sources).map(([domain, procedure]) =>
119
+ pump(domain, procedure as AnyProcedure)
120
+ )
121
+
122
+ try {
123
+ while (true) {
124
+ const [next] = queue
125
+ if (next) {
126
+ queue.delete(next[0])
127
+ yield next[1]
128
+ continue
129
+ }
130
+ if (ended) break
131
+ await new Promise<void>((resolve) => {
132
+ wake = resolve
133
+ })
134
+ wake = undefined
135
+ }
136
+ if (failure) throw failure.error
137
+ } finally {
138
+ stop()
139
+ options.signal?.removeEventListener('abort', abort)
140
+ await Promise.allSettled(pumps)
141
+ }
142
+ }
@@ -74,6 +74,8 @@ export function ioredisTransport(
74
74
  ours.delete(channel)
75
75
  return subscriber.unsubscribe(channel)
76
76
  },
77
+ connected: () =>
78
+ subscriber.status === undefined || subscriber.status === 'ready',
77
79
  close() {
78
80
  // Detach first: a given subscriber outlives this transport's use.
79
81
  subscriber.off('ready', onReady)
@@ -1,4 +1,4 @@
1
- import type { PubSubTransport } from './transport.js'
1
+ import type { PubSubTransport, PubSubTransportStats } from './transport.js'
2
2
 
3
3
  /**
4
4
  * In-process transport for tests and single-process development. Delivery is
@@ -7,6 +7,7 @@ import type { PubSubTransport } from './transport.js'
7
7
  export function memoryTransport(): PubSubTransport & {
8
8
  /** Active listeners per channel, for assertions. */
9
9
  listenerCount(channel: string): number
10
+ stats(): PubSubTransportStats
10
11
  } {
11
12
  const listeners = new Map<string, Set<(payload: string) => void>>()
12
13
  const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
@@ -49,6 +50,14 @@ export function memoryTransport(): PubSubTransport & {
49
50
  if (set.size === 0) listeners.delete(channel)
50
51
  }
51
52
  },
52
- listenerCount: (channel) => listeners.get(channel)?.size ?? 0
53
+ listenerCount: (channel) => listeners.get(channel)?.size ?? 0,
54
+ stats: () => ({
55
+ channels: listeners.size,
56
+ listeners: [...listeners.values()].reduce(
57
+ (sum, set) => sum + set.size,
58
+ 0
59
+ ),
60
+ subscriberConnected: true
61
+ })
53
62
  }
54
63
  }
@@ -84,6 +84,11 @@ export type PublisherConfig = {
84
84
  * channel means no duplicate work.
85
85
  */
86
86
  mirrorChannel?: string
87
+ /**
88
+ * Log publish failures (validation or broker) instead of throwing them, so
89
+ * a publish after a committed write cannot fail the request. No retry.
90
+ */
91
+ safePublish?: boolean
87
92
  }
88
93
 
89
94
  /** Per-definition overrides of the factory queue bounds. */
@@ -612,10 +617,25 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
612
617
  }
613
618
  })
614
619
 
620
+ const guarded = async (
621
+ operation: 'publish' | 'publishMany',
622
+ items: readonly unknown[]
623
+ ) => {
624
+ if (!config.safePublish) return send(operation, items)
625
+ try {
626
+ await send(operation, items)
627
+ } catch (error) {
628
+ logger.error('pubsub.publish_failed', {
629
+ name,
630
+ ...errorAttributes(error)
631
+ })
632
+ }
633
+ }
634
+
615
635
  return {
616
- publish: (data: unknown) => send('publish', [data]),
636
+ publish: (data: unknown) => guarded('publish', [data]),
617
637
  publishMany: async (items: readonly unknown[]) => {
618
- if (items.length > 0) await send('publishMany', items)
638
+ if (items.length > 0) await guarded('publishMany', items)
619
639
  }
620
640
  }
621
641
  }
@@ -1205,6 +1225,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1205
1225
  backlogSize: _backlogSize,
1206
1226
  backlogTtl: _backlogTtl,
1207
1227
  mirrorChannel: _mirrorChannel,
1228
+ safePublish: _safePublish,
1208
1229
  maxQueueSize: _maxQueueSize,
1209
1230
  maxIngressSize: _maxIngressSize,
1210
1231
  maxReplaySize: _maxReplaySize,
@@ -9,6 +9,8 @@ export interface BunRedisClientLike {
9
9
  unsubscribe(channel: string, listener?: Listener): Promise<unknown>
10
10
  duplicate(): Promise<BunRedisClientLike>
11
11
  close(): void
12
+ /** Bun's `RedisClient.connected`; read by `stats()` when present. */
13
+ readonly connected?: boolean
12
14
  onconnect: ((this: never) => void) | null
13
15
  onclose: ((this: never, error: Error) => void) | null
14
16
  }
@@ -58,6 +60,7 @@ export function bunRedisTransport(
58
60
  ours.delete(channel)
59
61
  return subscriber.unsubscribe(channel, dispatch)
60
62
  },
63
+ connected: () => subscriber.connected !== false,
61
64
  close() {
62
65
  // No-ops, not null: Bun calls `onclose` without checking it.
63
66
  subscriber.onconnect = () => {}
package/src/live/redis.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { errorMessageOf } from '../otel.js'
2
- import { backlogKey, type PubSubTransport } from './transport.js'
2
+ import {
3
+ backlogKey,
4
+ type PubSubTransport,
5
+ type PubSubTransportStats
6
+ } from './transport.js'
3
7
 
4
8
  export type RedisSend = (command: string, args: string[]) => Promise<unknown>
5
9
 
@@ -77,6 +81,8 @@ export type SubscriberConnection = {
77
81
  resubscribe?(channel: string): Promise<unknown>
78
82
  /** Stop delivering and release the connection (if the driver created it). */
79
83
  close(): void | Promise<void>
84
+ /** The driver reports the connection open; omitted means open until lost. */
85
+ connected?(): boolean
80
86
  }
81
87
 
82
88
  /** Callbacks for one connection; they do nothing once it is replaced. */
@@ -115,7 +121,10 @@ export function createRedisTransport(driver: {
115
121
  connectSubscriber: (
116
122
  handlers: SubscriberHandlers
117
123
  ) => Promise<SubscriberConnection>
118
- }): PubSubTransport & { close(): Promise<void> } {
124
+ }): PubSubTransport & {
125
+ close(): Promise<void>
126
+ stats(): PubSubTransportStats
127
+ } {
119
128
  const publishScript = createLuaScript(driver.send, PUBLISH_LUA)
120
129
  const channels = new Map<string, ChannelState>()
121
130
  // An UNSUBSCRIBE still in flight would cancel a SUBSCRIBE sent after it.
@@ -124,6 +133,8 @@ export function createRedisTransport(driver: {
124
133
  connection: Promise<SubscriberConnection>
125
134
  retire: () => Promise<void>
126
135
  } | null = null
136
+ // The open connection of the current generation, once it resolves.
137
+ let open: SubscriberConnection | null = null
127
138
 
128
139
  const connect = () => {
129
140
  let retired = false
@@ -131,7 +142,10 @@ export function createRedisTransport(driver: {
131
142
  const retire = (error?: Error) => {
132
143
  if (retired) return closing ?? Promise.resolve()
133
144
  retired = true
134
- if (current?.connection === connection) current = null
145
+ if (current?.connection === connection) {
146
+ current = null
147
+ open = null
148
+ }
135
149
  closing = connection.then((open) => open.close()).catch(() => {})
136
150
  if (!error) return closing
137
151
  const lost = [...channels.values()].flatMap((state) => [...state.entries])
@@ -182,9 +196,14 @@ export function createRedisTransport(driver: {
182
196
  onLost: (error) => retire(error)
183
197
  }
184
198
  const connection = driver.connectSubscriber(handlers)
185
- connection.catch(() => {
186
- if (current?.connection === connection) current = null
187
- })
199
+ connection.then(
200
+ (resolved) => {
201
+ if (!retired) open = resolved
202
+ },
203
+ () => {
204
+ if (current?.connection === connection) current = null
205
+ }
206
+ )
188
207
  current = { connection, retire: () => retire() }
189
208
  return connection
190
209
  }
@@ -268,6 +287,14 @@ export function createRedisTransport(driver: {
268
287
  await done
269
288
  }
270
289
  },
290
+ stats: () => ({
291
+ channels: channels.size,
292
+ listeners: [...channels.values()].reduce(
293
+ (sum, state) => sum + state.entries.size,
294
+ 0
295
+ ),
296
+ subscriberConnected: open ? (open.connected?.() ?? true) : false
297
+ }),
271
298
  async close() {
272
299
  const closing = current?.retire()
273
300
  channels.clear()
@@ -0,0 +1,98 @@
1
+ import type { ZodType, z } from 'zod'
2
+ import type {
3
+ AnyFnContext,
4
+ FnDefinition,
5
+ FnRouteOptions,
6
+ GuardOptions,
7
+ ProcedureKey,
8
+ ProcedureOption
9
+ } from '../types.js'
10
+ import type {
11
+ CreatePubSub,
12
+ ObjectSchema,
13
+ PubSub,
14
+ PubSubOptions
15
+ } from './pub-sub.js'
16
+
17
+ /** Where a change bus partitions its channels, e.g. one per organization. */
18
+ export type ChangeBusScope<TContext, TEvent> = {
19
+ /** The subscriber's scope key, from its context. Throw to refuse the subscription. */
20
+ subscribe: (context: TContext) => string
21
+ /** The event's scope key, carried in the event itself. */
22
+ publish: (event: TEvent) => string
23
+ }
24
+
25
+ /** A bus's channel definition; pass it to `fnLive` to share the bus's events. */
26
+ export type ChangeBusChannel<TContext, TEvent> = {
27
+ subscribe: (params: { context: TContext }) => string
28
+ publish: (event: TEvent) => string
29
+ }
30
+
31
+ export type ScopedChangeBus<
32
+ TDef extends FnDefinition,
33
+ TInputSchema extends ObjectSchema,
34
+ TEventSchema extends ZodType,
35
+ TKey extends ProcedureKey<TDef>,
36
+ TScopeEvent
37
+ > = PubSub<TDef, TInputSchema, TEventSchema, TKey> & {
38
+ channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent>
39
+ }
40
+
41
+ /**
42
+ * Binds the app's `createPubSub` to one scope rule, for buses that tell every
43
+ * subscriber in a scope "something changed". Each bus then names only its
44
+ * channel prefix: events go to `${prefix}:${scope}`. Publishing is safe by
45
+ * default (`safePublish: true`), because buses publish after committed writes.
46
+ *
47
+ * ```ts
48
+ * const defineChangeBus = defineScopedChangeBus(createPubSub, {
49
+ * subscribe: (context) => activeOrganizationId(context),
50
+ * publish: (event: { organizationId: string }) => event.organizationId
51
+ * })
52
+ * export const dealChanges = defineChangeBus({
53
+ * name: 'deal.subscribe',
54
+ * prefix: 'deal:change',
55
+ * inputSchema: z.object({}),
56
+ * eventSchema: z.object({ organizationId: z.string(), dealId: z.string() })
57
+ * })
58
+ * // fnLive({ live: { channel: dealChanges.channel, ... } }) shares the channel.
59
+ * ```
60
+ */
61
+ export function defineScopedChangeBus<TDef extends FnDefinition, TScopeEvent>(
62
+ createPubSub: CreatePubSub<TDef>,
63
+ scope: ChangeBusScope<AnyFnContext<TDef>, TScopeEvent>
64
+ ) {
65
+ return <
66
+ TInputSchema extends ObjectSchema,
67
+ TEventSchema extends ZodType<TScopeEvent>,
68
+ TKey extends ProcedureKey<TDef> = TDef['default']
69
+ >(
70
+ options: Omit<
71
+ PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>>,
72
+ 'channel'
73
+ > &
74
+ GuardOptions<TDef['guards']> &
75
+ Pick<
76
+ FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
77
+ 'meta' | 'guardResolvers'
78
+ > &
79
+ ProcedureOption<TDef, TKey> & {
80
+ /** Channel prefix; the scope key is appended as `${prefix}:${scope}`. */
81
+ prefix: string
82
+ }
83
+ ): ScopedChangeBus<TDef, TInputSchema, TEventSchema, TKey, TScopeEvent> => {
84
+ const { prefix, ...pubsubOptions } = options
85
+ const channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent> = {
86
+ subscribe: ({ context }) => `${prefix}:${scope.subscribe(context)}`,
87
+ publish: (event) => `${prefix}:${scope.publish(event)}`
88
+ }
89
+ // The bus's context is one member of AnyFnContext, which the generic
90
+ // signature of createPubSub cannot see.
91
+ const bus = (createPubSub as (options: object) => object)({
92
+ safePublish: true,
93
+ ...pubsubOptions,
94
+ channel
95
+ }) as PubSub<TDef, TInputSchema, TEventSchema, TKey>
96
+ return { ...bus, channel }
97
+ }
98
+ }
@@ -1,6 +1,16 @@
1
1
  /** One payload for one channel. */
2
2
  export type PubSubMessage = { channel: string; payload: string }
3
3
 
4
+ /** What a transport holds open on this instance, for gauges and health checks. */
5
+ export type PubSubTransportStats = {
6
+ /** Broker channels subscribed (one per channel, however many listeners). */
7
+ channels: number
8
+ /** Local listeners across those channels. */
9
+ listeners: number
10
+ /** The subscriber connection is open; false before the first subscribe. */
11
+ subscriberConnected: boolean
12
+ }
13
+
4
14
  export type BacklogOptions = {
5
15
  /** Items kept per channel (oldest dropped). */
6
16
  size: number
@@ -39,6 +49,8 @@ export interface PubSubTransport {
39
49
  onLost?: (error: Error) => void,
40
50
  options?: { onReconnect?: () => void }
41
51
  ): Promise<() => Promise<void>>
52
+ /** Current subscription counts and connection state. */
53
+ stats?(): PubSubTransportStats
42
54
  }
43
55
 
44
56
  export const backlogKey = (channel: string) => `${channel}:backlog`