orpc-fn 0.1.0 → 0.2.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 (141) hide show
  1. package/README.md +48 -32
  2. package/dist/assemble-procedure.d.ts +13 -0
  3. package/dist/assemble-procedure.d.ts.map +1 -0
  4. package/dist/assemble-procedure.js +29 -0
  5. package/dist/assemble-procedure.js.map +1 -0
  6. package/dist/bound-call.d.ts +1 -1
  7. package/dist/bound-call.d.ts.map +1 -1
  8. package/dist/bound-call.js.map +1 -1
  9. package/dist/client.d.ts +4 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/compatibility.d.ts +16 -0
  14. package/dist/compatibility.d.ts.map +1 -0
  15. package/dist/compatibility.js +4 -0
  16. package/dist/compatibility.js.map +1 -0
  17. package/dist/create-fn.d.ts +30 -34
  18. package/dist/create-fn.d.ts.map +1 -1
  19. package/dist/create-fn.js +177 -121
  20. package/dist/create-fn.js.map +1 -1
  21. package/dist/expo.d.ts +11 -25
  22. package/dist/expo.d.ts.map +1 -1
  23. package/dist/expo.js +25 -30
  24. package/dist/expo.js.map +1 -1
  25. package/dist/hono.d.ts +12 -4
  26. package/dist/hono.d.ts.map +1 -1
  27. package/dist/hono.js +29 -12
  28. package/dist/hono.js.map +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/json-schema.d.ts +2 -0
  34. package/dist/json-schema.d.ts.map +1 -1
  35. package/dist/json-schema.js +11 -0
  36. package/dist/json-schema.js.map +1 -1
  37. package/dist/live/codec.d.ts +6 -1
  38. package/dist/live/codec.d.ts.map +1 -1
  39. package/dist/live/codec.js +14 -3
  40. package/dist/live/codec.js.map +1 -1
  41. package/dist/live/fn-live.d.ts +63 -22
  42. package/dist/live/fn-live.d.ts.map +1 -1
  43. package/dist/live/fn-live.js +98 -16
  44. package/dist/live/fn-live.js.map +1 -1
  45. package/dist/live/index.d.ts +1 -1
  46. package/dist/live/index.d.ts.map +1 -1
  47. package/dist/live/index.js.map +1 -1
  48. package/dist/live/ioredis.d.ts +4 -1
  49. package/dist/live/ioredis.d.ts.map +1 -1
  50. package/dist/live/ioredis.js +21 -2
  51. package/dist/live/ioredis.js.map +1 -1
  52. package/dist/live/lifecycle.d.ts +7 -0
  53. package/dist/live/lifecycle.d.ts.map +1 -0
  54. package/dist/live/lifecycle.js +36 -0
  55. package/dist/live/lifecycle.js.map +1 -0
  56. package/dist/live/memory.d.ts.map +1 -1
  57. package/dist/live/memory.js +4 -0
  58. package/dist/live/memory.js.map +1 -1
  59. package/dist/live/pub-sub.d.ts +76 -12
  60. package/dist/live/pub-sub.d.ts.map +1 -1
  61. package/dist/live/pub-sub.js +429 -125
  62. package/dist/live/pub-sub.js.map +1 -1
  63. package/dist/live/redis-bun.d.ts +1 -1
  64. package/dist/live/redis-bun.d.ts.map +1 -1
  65. package/dist/live/redis-bun.js +3 -5
  66. package/dist/live/redis-bun.js.map +1 -1
  67. package/dist/live/redis.d.ts +2 -2
  68. package/dist/live/redis.d.ts.map +1 -1
  69. package/dist/live/redis.js +36 -9
  70. package/dist/live/redis.js.map +1 -1
  71. package/dist/live/transport.d.ts +5 -1
  72. package/dist/live/transport.d.ts.map +1 -1
  73. package/dist/live/transport.js.map +1 -1
  74. package/dist/logger.d.ts +2 -0
  75. package/dist/logger.d.ts.map +1 -1
  76. package/dist/logger.js +21 -0
  77. package/dist/logger.js.map +1 -1
  78. package/dist/mastra.d.ts.map +1 -1
  79. package/dist/mastra.js +16 -7
  80. package/dist/mastra.js.map +1 -1
  81. package/dist/mcp/sdk.d.ts +20 -0
  82. package/dist/mcp/sdk.d.ts.map +1 -0
  83. package/dist/mcp/sdk.js +82 -0
  84. package/dist/mcp/sdk.js.map +1 -0
  85. package/dist/mcp.d.ts +3 -2
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +22 -7
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/meta.d.ts +15 -7
  90. package/dist/meta.d.ts.map +1 -1
  91. package/dist/meta.js +9 -2
  92. package/dist/meta.js.map +1 -1
  93. package/dist/otel.d.ts.map +1 -1
  94. package/dist/otel.js +77 -6
  95. package/dist/otel.js.map +1 -1
  96. package/dist/route-options.d.ts +22 -0
  97. package/dist/route-options.d.ts.map +1 -0
  98. package/dist/route-options.js +67 -0
  99. package/dist/route-options.js.map +1 -0
  100. package/dist/router.d.ts +5 -1
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +22 -0
  103. package/dist/router.js.map +1 -1
  104. package/dist/timing.d.ts +8 -0
  105. package/dist/timing.d.ts.map +1 -0
  106. package/dist/timing.js +4 -0
  107. package/dist/timing.js.map +1 -0
  108. package/dist/types.d.ts +86 -41
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js +6 -2
  111. package/dist/types.js.map +1 -1
  112. package/package.json +15 -3
  113. package/src/assemble-procedure.ts +42 -0
  114. package/src/bound-call.ts +4 -3
  115. package/src/client.ts +9 -2
  116. package/src/compatibility.ts +39 -0
  117. package/src/create-fn.ts +297 -183
  118. package/src/expo.ts +49 -44
  119. package/src/hono.ts +36 -17
  120. package/src/index.ts +6 -2
  121. package/src/json-schema.ts +16 -0
  122. package/src/live/codec.ts +22 -3
  123. package/src/live/fn-live.ts +244 -69
  124. package/src/live/index.ts +2 -0
  125. package/src/live/ioredis.ts +22 -2
  126. package/src/live/lifecycle.ts +44 -0
  127. package/src/live/memory.ts +4 -0
  128. package/src/live/pub-sub.ts +608 -166
  129. package/src/live/redis-bun.ts +7 -5
  130. package/src/live/redis.ts +37 -11
  131. package/src/live/transport.ts +4 -1
  132. package/src/logger.ts +20 -0
  133. package/src/mastra.ts +15 -7
  134. package/src/mcp/sdk.ts +118 -0
  135. package/src/mcp.ts +48 -20
  136. package/src/meta.ts +40 -24
  137. package/src/otel.ts +77 -10
  138. package/src/route-options.ts +100 -0
  139. package/src/router.ts +39 -1
  140. package/src/timing.ts +5 -0
  141. package/src/types.ts +204 -62
@@ -4,15 +4,17 @@ import type { ZodObject, ZodRawShape, ZodType, z } from 'zod'
4
4
  import type { FnLogger } from '../logger.js'
5
5
  import { errorMessageOf, type SpanLike, type Tracing } from '../otel.js'
6
6
  import type {
7
- BuildProcedure,
7
+ GuardOptions,
8
8
  FnContext,
9
9
  FnDefinition,
10
10
  FnProcedure,
11
+ FnRouteOptions,
11
12
  MaybePromise,
12
13
  ProcedureKey,
13
14
  ProcedureOption
14
15
  } from '../types.js'
15
- import { decodePayload, encodePayload } from './codec.js'
16
+ import { decodeMessage, encodePayload } from './codec.js'
17
+ import { LIVE_GAP, positiveInteger, safely, waitFor } from './lifecycle.js'
16
18
  import type {
17
19
  BacklogOptions,
18
20
  PubSubMessage,
@@ -36,6 +38,10 @@ export type ObjectSchema = ZodObject<ZodRawShape, z.core.$ZodObjectConfig>
36
38
  */
37
39
  export type ChannelDefinition<TInput, TEventData, TContext = unknown> =
38
40
  | string
41
+ | {
42
+ subscribe: (params: { input: TInput; context: TContext }) => string
43
+ publish: (event: TEventData) => string
44
+ }
39
45
  | ((
40
46
  params: Partial<TInput> | Partial<TEventData>,
41
47
  context?: TContext
@@ -75,37 +81,58 @@ export type PublisherConfig = {
75
81
  mirrorChannel?: string
76
82
  }
77
83
 
84
+ /** Per-definition overrides of the factory queue bounds. */
85
+ export type ChannelQueueOptions = {
86
+ maxQueueSize?: number
87
+ maxIngressSize?: number
88
+ maxReplaySize?: number
89
+ }
90
+
91
+ /** Synchronous observational events; failures cannot interrupt delivery. */
92
+ export type PubSubMetric = { name: string; channel: string } & (
93
+ | {
94
+ type: 'queue'
95
+ stage: 'ingress' | 'replay' | 'delivery'
96
+ depth: number
97
+ limit: number
98
+ }
99
+ | { type: 'drop'; stage: 'ingress' | 'replay' | 'delivery'; count: number }
100
+ | { type: 'reconnect' }
101
+ | { type: 'parseError'; stage: 'ingress' | 'replay'; error: unknown }
102
+ )
103
+
78
104
  export type PubSubOptions<
79
105
  TDef extends FnDefinition,
80
106
  TInputSchema extends ObjectSchema,
81
107
  TEventSchema extends ZodType,
82
108
  TKey extends ProcedureKey<TDef>
83
- > = PublisherConfig & {
84
- channel: ChannelDefinition<
85
- z.output<TInputSchema>,
86
- z.output<TEventSchema>,
87
- FnContext<TDef, TKey>
88
- >
89
- /** What the subscriber provides. */
90
- inputSchema: TInputSchema
91
- /** What gets published. */
92
- eventSchema: TEventSchema
93
- filterFn?: FilterFn<z.output<TInputSchema>, z.output<TEventSchema>>
94
- authFn?: AuthFn<z.output<TInputSchema>, FnContext<TDef, TKey>>
95
- /**
96
- * When a slow subscriber's queue overflows (drop-oldest), enqueue this
97
- * marker INSTEAD of the new event so the subscriber resyncs from its
98
- * cursor. A wholly dropped item leaves no per-item gap a consumer could
99
- * detect, so the marker is the only reliable live signal of loss. Return
100
- * null to skip.
101
- */
102
- overflowMarker?: (
103
- input: z.output<TInputSchema>
104
- ) => z.output<TEventSchema> | null
105
- tags?: TDef['tag'][]
106
- summary?: string
107
- description?: string
108
- }
109
+ > = PublisherConfig &
110
+ ChannelQueueOptions & {
111
+ channel: ChannelDefinition<
112
+ z.output<TInputSchema>,
113
+ z.output<TEventSchema>,
114
+ FnContext<TDef, TKey>
115
+ >
116
+ /** What the subscriber provides. */
117
+ inputSchema: TInputSchema
118
+ /** What gets published. */
119
+ eventSchema: TEventSchema
120
+ filterFn?: FilterFn<z.output<TInputSchema>, z.output<TEventSchema>>
121
+ authFn?: AuthFn<z.output<TInputSchema>, FnContext<TDef, TKey>>
122
+ /**
123
+ * When a slow subscriber's queue overflows (drop-oldest), enqueue this
124
+ * marker INSTEAD of the new event so the subscriber resyncs from its
125
+ * cursor. A wholly dropped item leaves no per-item gap a consumer could
126
+ * detect, so the marker is the only reliable live signal of loss. Return
127
+ * null to skip.
128
+ */
129
+ overflowMarker?: (
130
+ input: z.output<TInputSchema>
131
+ ) => z.output<TEventSchema> | null
132
+ tags?: TDef['tag'][]
133
+ summary?: string
134
+ description?: string
135
+ }
109
136
 
110
137
  export type PubSub<
111
138
  TDef extends FnDefinition,
@@ -125,6 +152,12 @@ export type PubSub<
125
152
  publish: (data: z.input<TEventSchema>) => Promise<void>
126
153
  /** Publish a batch in one atomic round-trip; items may target different channels. */
127
154
  publishMany: (items: readonly z.input<TEventSchema>[]) => Promise<void>
155
+ getSubscriptionChannelName: (params: {
156
+ input: z.output<TInputSchema>
157
+ context: FnContext<TDef, TKey>
158
+ }) => string
159
+ getPublishChannelName: (event: z.output<TEventSchema>) => string
160
+ /** @deprecated Prefer the separate required-input resolver methods. */
128
161
  getChannelName: (
129
162
  params: Partial<z.output<TInputSchema>> | Partial<z.output<TEventSchema>>,
130
163
  context?: FnContext<TDef, TKey>
@@ -137,8 +170,15 @@ export type CreatePubSub<TDef extends FnDefinition> = <
137
170
  TKey extends ProcedureKey<TDef> = TDef['default']
138
171
  >(
139
172
  options: PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>> &
140
- /** Which `procedures` builder the subscribe route uses. */
141
- ProcedureOption<TDef, TKey>
173
+ GuardOptions<TDef['guards']> &
174
+ Pick<
175
+ FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
176
+ 'meta' | 'guardResolvers'
177
+ > &
178
+ /** Which `procedures` builder the subscribe route uses. */ ProcedureOption<
179
+ TDef,
180
+ TKey
181
+ >
142
182
  ) => PubSub<TDef, TInputSchema, TEventSchema, TKey>
143
183
 
144
184
  export type PublisherOptions<TEventSchema extends ZodType> = PublisherConfig & {
@@ -162,19 +202,31 @@ export type CreatePublisher = <TEventSchema extends ZodType>(
162
202
  ) => Publisher<TEventSchema>
163
203
 
164
204
  export type PubSubRuntimeOptions = {
205
+ /** Queue depths, drops, successful reconnects and parse failures. */
206
+ onMetric?: (event: PubSubMetric) => void
165
207
  transport: PubSubTransport
166
208
  /**
167
- * Called once per event dropped from a slow subscriber's queue. Events wait
168
- * there before the subscriber's filter runs, so drops count unfiltered events.
209
+ * Called per event dropped from ingress, replay or delivery queues.
210
+ * Delivery drops count events before subscriber filtering.
169
211
  */
170
212
  onDrop?: (count: number, info: { name: string; channel: string }) => void
171
213
  /** Per-subscriber queue bound before drop-oldest (default: 1000). */
172
214
  maxQueueSize?: number
215
+ /** Pending raw messages per channel, before asynchronous parsing. */
216
+ maxIngressSize?: number
217
+ /** Buffered live messages during replay. */
218
+ maxReplaySize?: number
219
+ /** Broker subscription and backlog wait bound (default 10000ms). */
220
+ initializationTimeoutMs?: number
221
+ /** Applied to channels, mirrors and therefore backlog keys. */
222
+ namespace?: string
223
+ /** Close this transport only when its ownership is explicitly transferred. */
224
+ ownsTransport?: boolean
173
225
  }
174
226
 
175
227
  /** @internal */
176
228
  export type LiveRuntimeOptions = {
177
- buildProcedure: BuildProcedure
229
+ fn: (options: Record<string, unknown>) => import('@orpc/server').AnyProcedure
178
230
  tracing: Tracing
179
231
  createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
180
232
  pubsub?: PubSubRuntimeOptions | undefined
@@ -206,7 +258,9 @@ export function createBoundedEventQueue<T>(opts: {
206
258
  createOverflowMarker?: () => T | null
207
259
  /** Called once per dropped message (metrics/counters). */
208
260
  onDrop?: () => void
261
+ onDepth?: (depth: number) => void
209
262
  }) {
263
+ positiveInteger(opts.maxSize, 'maxSize')
210
264
  const items: T[] = []
211
265
  // The queued marker while an overflow episode is open. Created once per
212
266
  // episode, so identity is the exact "is this the pending marker?" test.
@@ -217,35 +271,40 @@ export function createBoundedEventQueue<T>(opts: {
217
271
  return items.length
218
272
  },
219
273
  enqueue(data: T): void {
220
- if (items.length >= opts.maxSize) {
221
- if (pendingOverflowMarker !== null) {
222
- // Episode already signalled: drop the oldest non-marker message
223
- // and enqueue WITHOUT a second marker.
224
- const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0
225
- if (dropIndex >= items.length) {
226
- // Degenerate maxSize: the queue is just the pending marker, so
227
- // the new event is the drop - the marker's resync covers it.
228
- opts.onDrop?.()
274
+ try {
275
+ if (items.length >= opts.maxSize) {
276
+ if (pendingOverflowMarker !== null) {
277
+ // Episode already signalled: drop the oldest non-marker message
278
+ // and enqueue WITHOUT a second marker.
279
+ const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0
280
+ if (dropIndex >= items.length) {
281
+ // Degenerate maxSize: the queue is just the pending marker, so
282
+ // the new event is the drop - the marker's resync covers it.
283
+ safely(opts.onDrop)
284
+ return
285
+ }
286
+ items.splice(dropIndex, 1)
287
+ safely(opts.onDrop)
288
+ items.push(data)
289
+ return
290
+ }
291
+ items.shift()
292
+ safely(opts.onDrop)
293
+ const marker = opts.createOverflowMarker?.() ?? null
294
+ if (marker !== null) {
295
+ pendingOverflowMarker = marker
296
+ items.push(marker)
229
297
  return
230
298
  }
231
- items.splice(dropIndex, 1)
232
- opts.onDrop?.()
233
- items.push(data)
234
- return
235
- }
236
- items.shift()
237
- opts.onDrop?.()
238
- const marker = opts.createOverflowMarker?.() ?? null
239
- if (marker !== null) {
240
- pendingOverflowMarker = marker
241
- items.push(marker)
242
- return
243
299
  }
300
+ items.push(data)
301
+ } finally {
302
+ safely(() => opts.onDepth?.(items.length))
244
303
  }
245
- items.push(data)
246
304
  },
247
305
  dequeue(): T | undefined {
248
306
  const next = items.shift()
307
+ safely(() => opts.onDepth?.(items.length))
249
308
  if (next !== undefined && next === pendingOverflowMarker) {
250
309
  pendingOverflowMarker = null
251
310
  }
@@ -267,6 +326,7 @@ export function createSubscriberDelivery<T>(options: {
267
326
  overflowMarker?: (() => T | null) | undefined
268
327
  onFilterError: (error: unknown) => void
269
328
  onDrop: () => void
329
+ onDepth?: (depth: number) => void
270
330
  }) {
271
331
  type Item = { data: T; marker: boolean }
272
332
  const queue = createBoundedEventQueue<Item>({
@@ -275,9 +335,11 @@ export function createSubscriberDelivery<T>(options: {
275
335
  const marker = options.overflowMarker?.() ?? null
276
336
  return marker === null ? null : { data: marker, marker: true }
277
337
  },
278
- onDrop: options.onDrop
338
+ onDrop: options.onDrop,
339
+ ...(options.onDepth ? { onDepth: options.onDepth } : {})
279
340
  })
280
341
  let closed = false
342
+ let failure: unknown
281
343
  let wake: (() => void) | null = null
282
344
  const notify = () => {
283
345
  wake?.()
@@ -297,25 +359,29 @@ export function createSubscriberDelivery<T>(options: {
297
359
  try {
298
360
  if (!(await options.accept(item.data))) continue
299
361
  } catch (error) {
300
- options.onFilterError(error)
362
+ safely(() => options.onFilterError(error))
301
363
  continue
302
364
  }
303
365
  if (closed) break
304
366
  }
305
367
  yield item.data
306
368
  }
369
+ if (failure !== undefined) throw failure
307
370
  }
308
371
 
309
372
  return {
310
- push(data: T) {
373
+ push(data: T, marker = false) {
311
374
  if (closed) return
312
- queue.enqueue({ data, marker: false })
375
+ queue.enqueue({ data, marker })
313
376
  notify()
314
377
  },
315
378
  events: events(),
316
379
  /** Stop delivering; a parked read ends at once. */
317
- close() {
380
+ close(error?: unknown) {
381
+ if (closed) return
382
+ failure = error
318
383
  closed = true
384
+ safely(() => options.onDepth?.(0))
319
385
  notify()
320
386
  }
321
387
  }
@@ -344,12 +410,14 @@ type Input = Record<string, unknown>
344
410
 
345
411
  type LocalSubscriber = {
346
412
  isActive: () => boolean
347
- push: (data: unknown) => void
413
+ push: (data: unknown, id?: string) => void
414
+ gap: () => void
415
+ fail: (error: unknown) => void
348
416
  }
349
417
 
350
418
  type Hub = {
351
419
  subscribers: Set<LocalSubscriber>
352
- /** Settles after the first subscribe attempt (or when the hub closes). */
420
+ /** Resolves after a successful broker subscription (or closure). */
353
421
  ready: Promise<void>
354
422
  close: () => void
355
423
  }
@@ -360,16 +428,18 @@ export type LiveSubscription = {
360
428
  events: AsyncGenerator<unknown, void, unknown>
361
429
  /** Release everything; safe to call more than once, and before `events` runs. */
362
430
  close: () => void
431
+ isClosed: () => boolean
363
432
  }
364
433
 
365
434
  /** @internal Untyped options; `PubSubOptions` is the typed surface. */
366
- export type ChannelOptions = PublisherConfig & {
367
- channel: ChannelDefinition<Input, unknown>
368
- eventSchema: ZodType
369
- filterFn?: FilterFn<Input, unknown>
370
- authFn?: AuthFn<Input, unknown>
371
- overflowMarker?: (input: Input) => unknown
372
- }
435
+ export type ChannelOptions = PublisherConfig &
436
+ ChannelQueueOptions & {
437
+ channel: ChannelDefinition<Input, unknown>
438
+ eventSchema: ZodType
439
+ filterFn?: FilterFn<Input, unknown>
440
+ authFn?: AuthFn<Input, unknown>
441
+ overflowMarker?: (input: Input) => unknown
442
+ }
373
443
 
374
444
  /** @internal */
375
445
  export type Channel = {
@@ -378,22 +448,77 @@ export type Channel = {
378
448
  input: Input
379
449
  context: unknown
380
450
  signal: AbortSignal | undefined
451
+ recoverGaps?: boolean
381
452
  }) => Promise<LiveSubscription>
382
453
  publish: (data: unknown) => Promise<void>
383
454
  publishMany: (items: readonly unknown[]) => Promise<void>
384
455
  getChannelName: (params: Input, context?: unknown) => string
456
+ getSubscriptionChannelName: (params: {
457
+ input: Input
458
+ context: unknown
459
+ }) => string
460
+ getPublishChannelName: (event: Input) => string
385
461
  }
386
462
 
387
463
  const resolveChannel = (
388
- channel: string | ((params: Input, context?: unknown) => string),
464
+ channel: ChannelDefinition<Input, unknown>,
389
465
  params: Input,
390
- context?: unknown
391
- ) => (typeof channel === 'string' ? channel : channel(params, context))
466
+ context?: unknown,
467
+ publishing = false
468
+ ) => {
469
+ if (typeof channel === 'string') return channel
470
+ if (typeof channel === 'function') return channel(params, context)
471
+ return publishing
472
+ ? channel.publish(params)
473
+ : channel.subscribe({ input: params, context })
474
+ }
392
475
 
393
476
  /** @internal */
394
477
  export function createLiveRuntime(options: LiveRuntimeOptions) {
395
478
  const { tracing, createLogger } = options
396
479
  const logger = createLogger('pubsub', undefined)
480
+ let terminated = false
481
+ let shutdownPromise: Promise<void> | undefined
482
+ const releases = new Set<Promise<void>>()
483
+ const pendingInitialization = new Set<() => void>()
484
+ const pendingOpens = new Set<Promise<LiveSubscription>>()
485
+ const defaultMaxQueue = positiveInteger(
486
+ options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
487
+ 'maxQueueSize'
488
+ )
489
+ const defaultMaxIngress = positiveInteger(
490
+ options.pubsub?.maxIngressSize ?? defaultMaxQueue,
491
+ 'maxIngressSize'
492
+ )
493
+ const defaultMaxReplay = positiveInteger(
494
+ options.pubsub?.maxReplaySize ?? defaultMaxQueue,
495
+ 'maxReplaySize'
496
+ )
497
+ const initializationTimeout = positiveInteger(
498
+ options.pubsub?.initializationTimeoutMs ?? 10000,
499
+ 'initializationTimeoutMs'
500
+ )
501
+ const metric = (event: PubSubMetric) =>
502
+ safely(() => options.pubsub?.onMetric?.(event))
503
+ const namespace = options.pubsub?.namespace
504
+ const qualify = (name: string) => (namespace ? `${namespace}:${name}` : name)
505
+ const assertOpen = () => {
506
+ if (terminated)
507
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
508
+ message: 'Live runtime has shut down'
509
+ })
510
+ }
511
+ const releaseTransport = (release: (() => Promise<void>) | null) => {
512
+ if (!release) return
513
+ const task = Promise.resolve(release()).catch((error) => {
514
+ logger.warn('Failed to release subscription', {
515
+ error: errorMessageOf(error)
516
+ })
517
+ })
518
+ releases.add(task)
519
+ void task.finally(() => releases.delete(task))
520
+ }
521
+
397
522
  // Deploy drain: every open subscription registers its close here. On
398
523
  // SIGTERM the app ends them deliberately instead of letting the socket die
399
524
  // with the process: the generator returns, oRPC closes the SSE response and
@@ -420,7 +545,14 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
420
545
  getChannelName: (data: Input) => string
421
546
  }
422
547
  ) {
423
- const { name, eventSchema, getChannelName, mirrorChannel } = config
548
+ const { name, eventSchema, getChannelName } = config
549
+ const mirrorChannel = config.mirrorChannel
550
+ ? qualify(config.mirrorChannel)
551
+ : undefined
552
+ if (config.backlogSize !== undefined)
553
+ positiveInteger(config.backlogSize, 'backlogSize')
554
+ if (config.backlogTtl !== undefined)
555
+ positiveInteger(config.backlogTtl, 'backlogTtl')
424
556
  const backlog: BacklogOptions | undefined = config.useBacklog
425
557
  ? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
426
558
  : undefined
@@ -444,6 +576,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
444
576
  items: readonly unknown[]
445
577
  ) =>
446
578
  tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
579
+ assertOpen()
447
580
  const startTime = performance.now()
448
581
  try {
449
582
  span?.setAttribute('pubsub.operation', operation)
@@ -479,7 +612,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
479
612
  * applying each subscriber's own filter. The hub also owns the broker
480
613
  * subscription and its resubscribe backoff.
481
614
  */
482
- function createChannelHubs(config: { eventSchema: ZodType }) {
615
+ function createChannelHubs(config: {
616
+ eventSchema: ZodType
617
+ name: string
618
+ maxIngress: number
619
+ }) {
483
620
  const { eventSchema } = config
484
621
  const hubs = new Map<string, Hub>()
485
622
 
@@ -490,38 +627,85 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
490
627
  let attempt = 0
491
628
  let timer: ReturnType<typeof setTimeout> | null = null
492
629
  let markReady = () => {}
493
- const ready = new Promise<void>((resolve) => {
494
- markReady = resolve
495
- })
496
- // Parse in arrival order even when the schema is async.
497
- // ponytail: unbounded while an async schema lags behind the broker;
498
- // bound it if a channel can outpace its schema.
499
- let parsing = Promise.resolve()
630
+ let connectedOnce = false
631
+ let connected = false
632
+ let ready: Promise<void>
633
+ const markPending = () => {
634
+ ready = new Promise<void>((resolve) => {
635
+ markReady = resolve
636
+ })
637
+ }
638
+ markPending()
500
639
 
501
- const deliver = async (raw: string) => {
502
- let parsed: unknown
503
- try {
504
- parsed = await eventSchema.parseAsync(decodePayload(raw))
505
- } catch (error) {
506
- // Malformed payloads are dropped once for the whole channel.
507
- logger.warn(`Dropped malformed message on ${channel}`, {
508
- error: errorMessageOf(error)
640
+ let parsing = false
641
+ const ingress = createBoundedEventQueue<string>({
642
+ maxSize: config.maxIngress,
643
+ onDepth: (depth) =>
644
+ metric({
645
+ type: 'queue',
646
+ stage: 'ingress',
647
+ depth,
648
+ limit: config.maxIngress,
649
+ name: config.name,
650
+ channel
651
+ }),
652
+ onDrop: () => {
653
+ metric({
654
+ type: 'drop',
655
+ stage: 'ingress',
656
+ count: 1,
657
+ name: config.name,
658
+ channel
509
659
  })
510
- return
660
+ safely(() =>
661
+ options.pubsub?.onDrop?.(1, { name: config.name, channel })
662
+ )
663
+ for (const sub of [...subscribers]) {
664
+ try {
665
+ sub.gap()
666
+ } catch (error) {
667
+ safely(() => sub.fail(error))
668
+ }
669
+ }
511
670
  }
512
- // Parsed once; each subscriber filters in its own delivery.
513
- for (const sub of [...subscribers]) {
514
- if (sub.isActive()) sub.push(parsed)
671
+ })
672
+ const parsePending = async () => {
673
+ if (parsing) return
674
+ parsing = true
675
+ try {
676
+ while (!closed) {
677
+ const raw = ingress.dequeue()
678
+ if (raw === undefined) break
679
+ try {
680
+ const message = decodeMessage(raw)
681
+ const parsed = await eventSchema.parseAsync(message.value)
682
+ if (closed) break
683
+ for (const sub of [...subscribers])
684
+ if (sub.isActive()) {
685
+ try {
686
+ sub.push(parsed, message.id)
687
+ } catch (error) {
688
+ safely(() => sub.fail(error))
689
+ }
690
+ }
691
+ } catch (error) {
692
+ metric({
693
+ type: 'parseError',
694
+ stage: 'ingress',
695
+ error,
696
+ name: config.name,
697
+ channel
698
+ })
699
+ logger.warn(`Dropped malformed message on ${channel}`, {
700
+ error: errorMessageOf(error)
701
+ })
702
+ }
703
+ }
704
+ } finally {
705
+ parsing = false
515
706
  }
516
707
  }
517
-
518
- const release = (stale: (() => Promise<void>) | null) => {
519
- stale?.().catch((error) => {
520
- logger.warn(`Failed to unsubscribe from ${channel}`, {
521
- error: errorMessageOf(error)
522
- })
523
- })
524
- }
708
+ const release = releaseTransport
525
709
 
526
710
  const scheduleResubscribe = (reason: string) => {
527
711
  if (closed || timer) return
@@ -546,17 +730,49 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
546
730
  const next = await requireTransport().subscribe(
547
731
  channel,
548
732
  (raw) => {
549
- parsing = parsing.then(() => deliver(raw))
733
+ if (closed) return
734
+ ingress.enqueue(raw)
735
+ void parsePending()
550
736
  },
551
737
  (error) => {
738
+ if (connected) {
739
+ connected = false
740
+ markPending()
741
+ }
552
742
  logger.error(`Subscription to ${channel} lost`, {
553
743
  error: error.message
554
744
  })
555
745
  scheduleResubscribe('lost')
746
+ },
747
+ {
748
+ onReconnect: () => {
749
+ if (closed) return
750
+ metric({ type: 'reconnect', name: config.name, channel })
751
+ for (const sub of [...subscribers]) {
752
+ try {
753
+ sub.gap()
754
+ } catch (error) {
755
+ safely(() => sub.fail(error))
756
+ }
757
+ }
758
+ }
556
759
  }
557
760
  )
558
761
  if (closed) return release(next)
559
762
  unsubscribe = next
763
+ connected = true
764
+ if (connectedOnce) {
765
+ metric({ type: 'reconnect', name: config.name, channel })
766
+ for (const sub of [...subscribers]) {
767
+ try {
768
+ sub.gap()
769
+ } catch (error) {
770
+ safely(() => sub.fail(error))
771
+ }
772
+ }
773
+ }
774
+ connectedOnce = true
775
+ markReady()
560
776
  attempt = 0
561
777
  logger.info(`Subscribed to ${channel}`)
562
778
  } catch (error) {
@@ -564,10 +780,6 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
564
780
  error: errorMessageOf(error)
565
781
  })
566
782
  scheduleResubscribe('subscribe-failed')
567
- } finally {
568
- // A failed first attempt must not hold subscribers forever; the
569
- // backoff keeps retrying in the background.
570
- markReady()
571
783
  }
572
784
  }
573
785
 
@@ -575,9 +787,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
575
787
 
576
788
  return {
577
789
  subscribers,
578
- ready,
790
+ get ready() {
791
+ return ready
792
+ },
579
793
  close: () => {
580
794
  closed = true
795
+ metric({
796
+ type: 'queue',
797
+ stage: 'ingress',
798
+ depth: 0,
799
+ limit: config.maxIngress,
800
+ name: config.name,
801
+ channel
802
+ })
581
803
  if (timer) clearTimeout(timer)
582
804
  timer = null
583
805
  markReady()
@@ -624,10 +846,36 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
624
846
  } = channelOptions
625
847
 
626
848
  const getChannelName = (params: Input, context?: unknown) =>
627
- resolveChannel(channel, params, context)
628
- const addLocalSubscriber = createChannelHubs({ eventSchema })
849
+ qualify(resolveChannel(channel, params, context))
850
+ const getPublishChannel = (params: Input) =>
851
+ qualify(resolveChannel(channel, params, undefined, true))
852
+ const maxQueue = positiveInteger(
853
+ channelOptions.maxQueueSize ?? defaultMaxQueue,
854
+ 'maxQueueSize'
855
+ )
856
+ const maxIngress = positiveInteger(
857
+ channelOptions.maxIngressSize ?? defaultMaxIngress,
858
+ 'maxIngressSize'
859
+ )
860
+ const maxReplay = positiveInteger(
861
+ channelOptions.maxReplaySize ?? defaultMaxReplay,
862
+ 'maxReplaySize'
863
+ )
864
+ const addLocalSubscriber = createChannelHubs({
865
+ eventSchema,
866
+ name,
867
+ maxIngress
868
+ })
629
869
 
630
- const open: Channel['open'] = async ({ input, context, signal }) => {
870
+ const openInternal: Channel['open'] = async ({
871
+ input,
872
+ context,
873
+ signal,
874
+ recoverGaps = false
875
+ }) => {
876
+ assertOpen()
877
+ signal?.throwIfAborted()
878
+ const initializing = new AbortController()
631
879
  const transport = requireTransport()
632
880
  const channelName = getChannelName(input, context)
633
881
  const startTime = performance.now()
@@ -637,18 +885,33 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
637
885
  'pubsub.name': name
638
886
  })
639
887
  const subscriberLogger = createLogger('pubsub', span)
888
+ const cancelInitialization = () =>
889
+ initializing.abort(
890
+ signal?.reason ?? new Error('Subscription initialization stopped')
891
+ )
892
+ pendingInitialization.add(cancelInitialization)
893
+ signal?.addEventListener('abort', cancelInitialization, { once: true })
894
+ const finishInitialization = () => {
895
+ pendingInitialization.delete(cancelInitialization)
896
+ signal?.removeEventListener('abort', cancelInitialization)
897
+ }
640
898
 
641
899
  try {
642
900
  if (
643
901
  authFn !== undefined &&
644
902
  authFn !== true &&
645
- !(await authFn({ input, ctx: context }))
903
+ !(await waitFor(
904
+ Promise.resolve(authFn({ input, ctx: context })),
905
+ initializationTimeout,
906
+ initializing.signal
907
+ ))
646
908
  ) {
647
909
  throw new ORPCError('FORBIDDEN', {
648
910
  message: 'You do not have access to this subscription'
649
911
  })
650
912
  }
651
913
  } catch (error) {
914
+ finishInitialization()
652
915
  tracing.end(span, error)
653
916
  throw error
654
917
  }
@@ -658,25 +921,107 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
658
921
  let messageCount = 0
659
922
  let droppedCount = 0
660
923
  const delivery = createSubscriberDelivery<unknown>({
661
- maxSize: options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
662
- accept: filterFn && ((data) => filterFn({ input, data })),
663
- overflowMarker: overflowMarker && (() => overflowMarker(input)),
924
+ maxSize: maxQueue,
925
+ onDepth: (depth) =>
926
+ metric({
927
+ type: 'queue',
928
+ stage: 'delivery',
929
+ depth,
930
+ limit: maxQueue,
931
+ name,
932
+ channel: channelName
933
+ }),
934
+ accept:
935
+ filterFn &&
936
+ ((data) => (data === LIVE_GAP ? true : filterFn({ input, data }))),
937
+ overflowMarker: recoverGaps
938
+ ? () => LIVE_GAP
939
+ : overflowMarker && (() => overflowMarker(input)),
664
940
  onFilterError: (error) =>
665
941
  subscriberLogger.error('Error processing message', {
666
942
  error: errorMessageOf(error)
667
943
  }),
668
944
  onDrop: () => {
669
945
  droppedCount++
670
- options.pubsub?.onDrop?.(1, { name, channel: channelName })
946
+ metric({
947
+ type: 'drop',
948
+ stage: 'delivery',
949
+ count: 1,
950
+ name,
951
+ channel: channelName
952
+ })
953
+ safely(() =>
954
+ options.pubsub?.onDrop?.(1, { name, channel: channelName })
955
+ )
956
+ if (!recoverGaps && !overflowMarker)
957
+ queueMicrotask(() =>
958
+ close(
959
+ new ORPCError('SERVICE_UNAVAILABLE', {
960
+ message: 'Subscription queue overflow; reconnect and resync'
961
+ })
962
+ )
963
+ )
671
964
  }
672
965
  })
673
- // Live events wait here while the backlog replays, so replay comes first.
674
- // ponytail: unbounded for the length of one backlog read.
675
- let replaying: unknown[] | null = useBacklog ? [] : null
676
- const push = (data: unknown) => {
966
+
967
+ let replaying: Array<{ data: unknown; id: string | undefined }> | null =
968
+ useBacklog ? [] : null
969
+ let replayGap = false
970
+ const seen = new Set<string>()
971
+ const dedupe = (id: string | undefined) => {
972
+ if (!id) return false
973
+ if (seen.has(id)) return true
974
+ seen.add(id)
975
+ if (
976
+ seen.size >
977
+ Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2
978
+ )
979
+ seen.delete(seen.values().next().value as string)
980
+ return false
981
+ }
982
+ const push = (data: unknown, id?: string) => {
677
983
  messageCount++
678
- if (replaying) replaying.push(data)
679
- else delivery.push(data)
984
+ if (replaying) {
985
+ if (replaying.length >= maxReplay) {
986
+ replaying.shift()
987
+ replayGap = true
988
+ metric({
989
+ type: 'drop',
990
+ stage: 'replay',
991
+ count: 1,
992
+ name,
993
+ channel: channelName
994
+ })
995
+ safely(() =>
996
+ options.pubsub?.onDrop?.(1, { name, channel: channelName })
997
+ )
998
+ }
999
+ replaying.push({ data, id })
1000
+ metric({
1001
+ type: 'queue',
1002
+ stage: 'replay',
1003
+ depth: replaying.length,
1004
+ limit: maxReplay,
1005
+ name,
1006
+ channel: channelName
1007
+ })
1008
+ } else if (!dedupe(id)) delivery.push(data)
1009
+ }
1010
+ const gap = () => {
1011
+ if (replaying) {
1012
+ replayGap = true
1013
+ return
1014
+ }
1015
+ if (recoverGaps) delivery.push(LIVE_GAP)
1016
+ else if (overflowMarker) {
1017
+ const marker = overflowMarker(input)
1018
+ if (marker !== null) delivery.push(marker, true)
1019
+ } else
1020
+ close(
1021
+ new ORPCError('SERVICE_UNAVAILABLE', {
1022
+ message: 'Subscription lost events; reconnect and resync'
1023
+ })
1024
+ )
680
1025
  }
681
1026
 
682
1027
  // The one way a subscription ends, whatever ends it: abort, drain, the
@@ -686,7 +1031,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
686
1031
  const close = (...error: [] | [unknown]) => {
687
1032
  if (closed) return
688
1033
  closed = true
689
- delivery.close()
1034
+ initializing.abort(error[0] ?? new Error('Subscription closed'))
1035
+ delivery.close(error[0])
690
1036
  activeSubscriberCleanups.delete(stop)
691
1037
  signal?.removeEventListener('abort', stop)
692
1038
  local.remove()
@@ -704,41 +1050,81 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
704
1050
  const stop = () => close()
705
1051
  const local = addLocalSubscriber(channelName, {
706
1052
  isActive: () => !closed,
707
- push
1053
+ push,
1054
+ gap,
1055
+ fail: (error) => close(error)
708
1056
  })
709
1057
  activeSubscriberCleanups.add(stop)
710
1058
  signal?.addEventListener('abort', stop)
711
1059
  if (signal?.aborted) close()
712
1060
 
713
- // Subscribed before returning, so nothing published after `open`
714
- // resolves is missed.
715
- // ponytail: a failed first attempt also settles `ready` (the hub keeps
716
- // retrying); events published before a retry succeeds are lost.
717
- await local.ready
718
- if (replaying && !closed) {
719
- try {
720
- const items = await transport.readBacklog(channelName)
721
- span?.setAttribute('pubsub.backlog_count', items.length)
722
- for (const raw of items) {
723
- if (closed) break
724
- try {
725
- messageCount++
726
- delivery.push(await eventSchema.parseAsync(decodePayload(raw)))
727
- } catch {
728
- // Ignore malformed backlog entries
1061
+ try {
1062
+ await waitFor(local.ready, initializationTimeout, initializing.signal)
1063
+ if (replaying && !closed) {
1064
+ try {
1065
+ const items = await waitFor(
1066
+ transport.readBacklog(channelName),
1067
+ initializationTimeout,
1068
+ initializing.signal
1069
+ )
1070
+ span?.setAttribute('pubsub.backlog_count', items.length)
1071
+ for (const raw of items.slice(-maxReplay)) {
1072
+ if (closed) break
1073
+ try {
1074
+ const message = decodeMessage(raw)
1075
+ if (!dedupe(message.id)) {
1076
+ messageCount++
1077
+ delivery.push(
1078
+ await waitFor(
1079
+ eventSchema.parseAsync(message.value),
1080
+ initializationTimeout,
1081
+ initializing.signal
1082
+ )
1083
+ )
1084
+ }
1085
+ } catch (error) {
1086
+ metric({
1087
+ type: 'parseError',
1088
+ stage: 'replay',
1089
+ error,
1090
+ name,
1091
+ channel: channelName
1092
+ })
1093
+ /* Malformed backlog entries are skipped. */
1094
+ }
729
1095
  }
1096
+ if (items.length > maxReplay) replayGap = true
1097
+ } catch (error) {
1098
+ replayGap = true
1099
+ subscriberLogger.warn(
1100
+ `Failed to replay backlog for ${channelName}`,
1101
+ { error: errorMessageOf(error) }
1102
+ )
730
1103
  }
731
- } catch (error) {
732
- // Live events still flow; only the backlog replay is lost.
733
- subscriberLogger.warn(`Failed to replay backlog for ${channelName}`, {
734
- error: errorMessageOf(error)
1104
+ }
1105
+ for (const { data, id } of replaying ?? [])
1106
+ if (!dedupe(id)) delivery.push(data)
1107
+ replaying = null
1108
+ metric({
1109
+ type: 'queue',
1110
+ stage: 'replay',
1111
+ depth: 0,
1112
+ limit: maxReplay,
1113
+ name,
1114
+ channel: channelName
1115
+ })
1116
+ if (replayGap && !closed) gap()
1117
+ } catch (error) {
1118
+ if (!closed) {
1119
+ close(error)
1120
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
1121
+ message: 'Unable to establish subscription',
1122
+ cause: error
735
1123
  })
736
1124
  }
1125
+ } finally {
1126
+ finishInitialization()
737
1127
  }
738
- // ponytail: an event both in the backlog and arriving live is delivered
739
- // twice; events carry no id to dedupe on.
740
- for (const data of replaying ?? []) delivery.push(data)
741
- replaying = null
742
1128
 
743
1129
  async function* events() {
744
1130
  try {
@@ -748,15 +1134,32 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
748
1134
  }
749
1135
  }
750
1136
 
751
- return { events: events(), close: () => close() }
1137
+ return { events: events(), close: () => close(), isClosed: () => closed }
752
1138
  }
753
1139
 
754
1140
  const { publish, publishMany } = createChannelPublisher({
755
1141
  ...channelOptions,
756
- getChannelName
1142
+ getChannelName: getPublishChannel
757
1143
  })
758
1144
 
759
- return { open, publish, publishMany, getChannelName }
1145
+ const open: Channel['open'] = (params) => {
1146
+ const task = openInternal(params)
1147
+ pendingOpens.add(task)
1148
+ void task.then(
1149
+ () => pendingOpens.delete(task),
1150
+ () => pendingOpens.delete(task)
1151
+ )
1152
+ return task
1153
+ }
1154
+ return {
1155
+ open,
1156
+ publish,
1157
+ publishMany,
1158
+ getChannelName,
1159
+ getSubscriptionChannelName: ({ input, context }) =>
1160
+ getChannelName(input, context),
1161
+ getPublishChannelName: getPublishChannel
1162
+ }
760
1163
  }
761
1164
 
762
1165
  function createPubSub(
@@ -770,31 +1173,57 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
770
1173
  ) {
771
1174
  const { name } = pubsubOptions
772
1175
  const channel = createChannel(pubsubOptions)
773
- const subscribe = options.buildProcedure({
1176
+
1177
+ const {
1178
+ channel: _channel,
1179
+ eventSchema: _eventSchema,
1180
+ inputSchema,
1181
+ filterFn: _filterFn,
1182
+ authFn: _authFn,
1183
+ overflowMarker: _overflowMarker,
1184
+ useBacklog: _useBacklog,
1185
+ backlogSize: _backlogSize,
1186
+ backlogTtl: _backlogTtl,
1187
+ mirrorChannel: _mirrorChannel,
1188
+ maxQueueSize: _maxQueueSize,
1189
+ maxIngressSize: _maxIngressSize,
1190
+ maxReplaySize: _maxReplaySize,
1191
+ ...routeOptions
1192
+ } = pubsubOptions
1193
+ const subscribe = options.fn({
1194
+ ...routeOptions,
774
1195
  name,
775
- procedure: pubsubOptions.procedure,
776
- meta: {},
777
- route: {
778
- method: 'GET',
779
- tags: pubsubOptions.tags,
780
- summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
781
- description: pubsubOptions.description
782
- },
783
- input: pubsubOptions.inputSchema,
784
- handler: async function* ({ input, context, signal }) {
785
- // The generator from `events()` releases everything when it ends.
786
- yield* (await channel.open({ input: input as Input, context, signal }))
787
- .events
1196
+ stream: true,
1197
+ method: 'GET',
1198
+ input: inputSchema,
1199
+ summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
1200
+ handler: async function* ({
1201
+ input,
1202
+ context,
1203
+ signal
1204
+ }: {
1205
+ input: Input
1206
+ context: unknown
1207
+ signal: AbortSignal | undefined
1208
+ }) {
1209
+ yield* (await channel.open({ input, context, signal })).events
788
1210
  }
789
1211
  })
790
1212
 
791
1213
  const { publish, publishMany, getChannelName } = channel
792
- return { subscribe, publish, publishMany, getChannelName }
1214
+ return {
1215
+ subscribe,
1216
+ publish,
1217
+ publishMany,
1218
+ getChannelName,
1219
+ getSubscriptionChannelName: channel.getSubscriptionChannelName,
1220
+ getPublishChannelName: channel.getPublishChannelName
1221
+ }
793
1222
  }
794
1223
 
795
1224
  function createPublisher(publisherOptions: PublisherOptions<ZodType>) {
796
1225
  const getChannelName = (params: Input) =>
797
- resolveChannel(publisherOptions.channel, params)
1226
+ qualify(resolveChannel(publisherOptions.channel, params, undefined, true))
798
1227
  const { publish, publishMany } = createChannelPublisher({
799
1228
  ...publisherOptions,
800
1229
  getChannelName
@@ -804,6 +1233,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
804
1233
 
805
1234
  return {
806
1235
  createChannel,
1236
+ shutdown: () => {
1237
+ if (shutdownPromise) return shutdownPromise
1238
+ terminated = true
1239
+ for (const cancel of [...pendingInitialization]) safely(cancel)
1240
+ for (const cleanup of [...activeSubscriberCleanups]) safely(cleanup)
1241
+ shutdownPromise = (async () => {
1242
+ await Promise.allSettled([...pendingOpens])
1243
+ await Promise.all([...releases])
1244
+ if (options.pubsub?.ownsTransport)
1245
+ await options.pubsub.transport.close?.()
1246
+ })()
1247
+ return shutdownPromise
1248
+ },
807
1249
  createPubSub,
808
1250
  createPublisher,
809
1251
  /** Open subscriptions of this instance (drain progress, gauges). */