orpc-fn 0.4.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 (90) hide show
  1. package/README.md +146 -4
  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 +0 -1
  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 +16 -3
  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/procedure-cli.d.ts +25 -0
  53. package/dist/procedure-cli.d.ts.map +1 -0
  54. package/dist/procedure-cli.js +67 -0
  55. package/dist/procedure-cli.js.map +1 -0
  56. package/dist/react.d.ts +116 -0
  57. package/dist/react.d.ts.map +1 -0
  58. package/dist/react.js +156 -0
  59. package/dist/react.js.map +1 -0
  60. package/dist/realtime.d.ts +114 -0
  61. package/dist/realtime.d.ts.map +1 -0
  62. package/dist/realtime.js +316 -0
  63. package/dist/realtime.js.map +1 -0
  64. package/dist/vue/testing.d.ts +67 -0
  65. package/dist/vue/testing.d.ts.map +1 -0
  66. package/dist/vue/testing.js +225 -0
  67. package/dist/vue/testing.js.map +1 -0
  68. package/dist/vue.d.ts +211 -0
  69. package/dist/vue.d.ts.map +1 -0
  70. package/dist/vue.js +312 -0
  71. package/dist/vue.js.map +1 -0
  72. package/package.json +51 -3
  73. package/src/client.ts +57 -0
  74. package/src/create-fn.ts +0 -1
  75. package/src/hono.ts +22 -2
  76. package/src/live/fn-live.ts +3 -22
  77. package/src/live/index.ts +12 -1
  78. package/src/live/invalidation-stream.ts +142 -0
  79. package/src/live/ioredis.ts +2 -0
  80. package/src/live/memory.ts +11 -2
  81. package/src/live/pub-sub.ts +23 -2
  82. package/src/live/redis-bun.ts +3 -0
  83. package/src/live/redis.ts +33 -6
  84. package/src/live/scoped-change-bus.ts +98 -0
  85. package/src/live/transport.ts +12 -0
  86. package/src/procedure-cli.ts +99 -0
  87. package/src/react.ts +355 -0
  88. package/src/realtime.ts +416 -0
  89. package/src/vue/testing.ts +296 -0
  90. package/src/vue.ts +783 -0
@@ -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`
@@ -0,0 +1,99 @@
1
+ import { type AnyProcedure, call, isProcedure, ORPCError } from '@orpc/server'
2
+ import {
3
+ CliError,
4
+ type Command,
5
+ type CommandGroup,
6
+ defineCommand
7
+ } from 'hanzio/cli'
8
+ import { z } from 'zod'
9
+ import { type FnMeta, readFnMeta } from './meta.js'
10
+
11
+ export type ProcedureCommandsOptions<C> = {
12
+ /**
13
+ * Which procedures become commands. Default: those declared with
14
+ * `meta: { cli: true }` (add `cli?: boolean` to the app's meta type).
15
+ */
16
+ filter?: (meta: FnMeta) => boolean
17
+ /**
18
+ * The oRPC context a command calls its procedure with, from the CLI's
19
+ * context (`defineCli({ context })`). Default: that context as-is. It gets
20
+ * the procedure's meta, so it can refuse early with a `CliError`, e.g. a
21
+ * protected procedure run without a user.
22
+ */
23
+ context?: (cliContext: C, meta: FnMeta) => unknown
24
+ }
25
+
26
+ const optedIn = (meta: FnMeta) => (meta.meta as { cli?: unknown }).cli === true
27
+
28
+ const kebab = (key: string) =>
29
+ key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
30
+
31
+ /**
32
+ * The opted-in `fn()` procedures of a router as hanzio/cli commands, nested
33
+ * like the router: `supportLearning.syncRun` is `support-learning sync-run`.
34
+ * Flags come from the procedure's input schema, help from its summary. A
35
+ * command calls the procedure through oRPC, so middleware, guards, tracing
36
+ * and errors run as they do over HTTP.
37
+ */
38
+ export function procedureCommands<C = unknown>(
39
+ router: object,
40
+ options: ProcedureCommandsOptions<C> = {}
41
+ ): CommandGroup<C> {
42
+ const filter = options.filter ?? optedIn
43
+ const commands: Record<string, Command<C> | CommandGroup<C>> = {}
44
+
45
+ for (const [key, value] of Object.entries(router)) {
46
+ if (isProcedure(value)) {
47
+ const meta = readFnMeta(value)
48
+ if (filter(meta)) commands[kebab(key)] = toCommand(value, meta, options)
49
+ } else if (value && typeof value === 'object') {
50
+ const group = procedureCommands(value, options)
51
+ if (Object.keys(group).length > 0) commands[kebab(key)] = group
52
+ }
53
+ }
54
+
55
+ return commands
56
+ }
57
+
58
+ function toCommand<C>(
59
+ procedure: AnyProcedure,
60
+ meta: FnMeta,
61
+ options: ProcedureCommandsOptions<C>
62
+ ): Command<C> {
63
+ const name = meta.name ?? 'procedure'
64
+ if (meta.stream) {
65
+ throw new TypeError(`orpc-fn: ${name} streams and cannot be a CLI command`)
66
+ }
67
+ const input = meta.inputSchema
68
+ if (input !== undefined && !(input instanceof z.ZodObject)) {
69
+ throw new TypeError(
70
+ `orpc-fn: ${name} must take a z.object() input to be a CLI command`
71
+ )
72
+ }
73
+
74
+ return defineCommand<z.ZodObject, unknown, C>({
75
+ description: meta.description,
76
+ input: input ?? z.object({}),
77
+ run: async (parsed, ctx) => {
78
+ const context = options.context
79
+ ? await options.context(ctx.context, meta)
80
+ : ctx.context
81
+ try {
82
+ // The flags were validated against this schema; the procedure parses
83
+ // them again, like any nested call with a parsed value.
84
+ return await call(procedure, input ? parsed : undefined, {
85
+ context: context as Record<never, never>
86
+ })
87
+ } catch (error) {
88
+ // A refusal the caller drives (auth, permission, not found) reads as
89
+ // a message, not a crash with a stack trace.
90
+ if (error instanceof ORPCError && error.status < 500) {
91
+ throw new CliError(`${error.code}: ${error.message}`, {
92
+ cause: error
93
+ })
94
+ }
95
+ throw error
96
+ }
97
+ }
98
+ })
99
+ }