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