orpc-fn 0.1.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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/bound-call.d.ts +23 -0
  4. package/dist/bound-call.d.ts.map +1 -0
  5. package/dist/bound-call.js +23 -0
  6. package/dist/bound-call.js.map +1 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +49 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/create-fn.d.ts +97 -0
  12. package/dist/create-fn.d.ts.map +1 -0
  13. package/dist/create-fn.js +224 -0
  14. package/dist/create-fn.js.map +1 -0
  15. package/dist/expected-client-error.d.ts +11 -0
  16. package/dist/expected-client-error.d.ts.map +1 -0
  17. package/dist/expected-client-error.js +21 -0
  18. package/dist/expected-client-error.js.map +1 -0
  19. package/dist/expo.d.ts +66 -0
  20. package/dist/expo.d.ts.map +1 -0
  21. package/dist/expo.js +74 -0
  22. package/dist/expo.js.map +1 -0
  23. package/dist/hono.d.ts +81 -0
  24. package/dist/hono.d.ts.map +1 -0
  25. package/dist/hono.js +138 -0
  26. package/dist/hono.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/json-schema.d.ts +47 -0
  32. package/dist/json-schema.d.ts.map +1 -0
  33. package/dist/json-schema.js +44 -0
  34. package/dist/json-schema.js.map +1 -0
  35. package/dist/live/codec.d.ts +9 -0
  36. package/dist/live/codec.d.ts.map +1 -0
  37. package/dist/live/codec.js +27 -0
  38. package/dist/live/codec.js.map +1 -0
  39. package/dist/live/fn-live.d.ts +155 -0
  40. package/dist/live/fn-live.d.ts.map +1 -0
  41. package/dist/live/fn-live.js +210 -0
  42. package/dist/live/fn-live.js.map +1 -0
  43. package/dist/live/index.d.ts +4 -0
  44. package/dist/live/index.d.ts.map +1 -0
  45. package/dist/live/index.js +5 -0
  46. package/dist/live/index.js.map +1 -0
  47. package/dist/live/ioredis.d.ts +29 -0
  48. package/dist/live/ioredis.d.ts.map +1 -0
  49. package/dist/live/ioredis.js +49 -0
  50. package/dist/live/ioredis.js.map +1 -0
  51. package/dist/live/memory.d.ts +10 -0
  52. package/dist/live/memory.d.ts.map +1 -0
  53. package/dist/live/memory.js +45 -0
  54. package/dist/live/memory.js.map +1 -0
  55. package/dist/live/pub-sub.d.ts +211 -0
  56. package/dist/live/pub-sub.d.ts.map +1 -0
  57. package/dist/live/pub-sub.js +529 -0
  58. package/dist/live/pub-sub.js.map +1 -0
  59. package/dist/live/redis-bun.d.ts +26 -0
  60. package/dist/live/redis-bun.d.ts.map +1 -0
  61. package/dist/live/redis-bun.js +59 -0
  62. package/dist/live/redis-bun.js.map +1 -0
  63. package/dist/live/redis.d.ts +45 -0
  64. package/dist/live/redis.d.ts.map +1 -0
  65. package/dist/live/redis.js +214 -0
  66. package/dist/live/redis.js.map +1 -0
  67. package/dist/live/transport.d.ts +35 -0
  68. package/dist/live/transport.d.ts.map +1 -0
  69. package/dist/live/transport.js +2 -0
  70. package/dist/live/transport.js.map +1 -0
  71. package/dist/logger.d.ts +15 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +15 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/mastra.d.ts +44 -0
  76. package/dist/mastra.d.ts.map +1 -0
  77. package/dist/mastra.js +78 -0
  78. package/dist/mastra.js.map +1 -0
  79. package/dist/mcp.d.ts +39 -0
  80. package/dist/mcp.d.ts.map +1 -0
  81. package/dist/mcp.js +38 -0
  82. package/dist/mcp.js.map +1 -0
  83. package/dist/meta.d.ts +35 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +30 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/otel.d.ts +66 -0
  88. package/dist/otel.d.ts.map +1 -0
  89. package/dist/otel.js +63 -0
  90. package/dist/otel.js.map +1 -0
  91. package/dist/router.d.ts +13 -0
  92. package/dist/router.d.ts.map +1 -0
  93. package/dist/router.js +14 -0
  94. package/dist/router.js.map +1 -0
  95. package/dist/stream.d.ts +13 -0
  96. package/dist/stream.d.ts.map +1 -0
  97. package/dist/stream.js +44 -0
  98. package/dist/stream.js.map +1 -0
  99. package/dist/types.d.ts +196 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/types.js.map +1 -0
  103. package/package.json +157 -0
  104. package/src/bound-call.ts +50 -0
  105. package/src/client.ts +82 -0
  106. package/src/create-fn.ts +461 -0
  107. package/src/expected-client-error.ts +23 -0
  108. package/src/expo.ts +127 -0
  109. package/src/hono.ts +247 -0
  110. package/src/index.ts +45 -0
  111. package/src/json-schema.ts +82 -0
  112. package/src/live/codec.ts +31 -0
  113. package/src/live/fn-live.ts +470 -0
  114. package/src/live/index.ts +31 -0
  115. package/src/live/ioredis.ts +74 -0
  116. package/src/live/memory.ts +50 -0
  117. package/src/live/pub-sub.ts +828 -0
  118. package/src/live/redis-bun.ts +76 -0
  119. package/src/live/redis.ts +255 -0
  120. package/src/live/transport.ts +41 -0
  121. package/src/logger.ts +28 -0
  122. package/src/mastra.ts +140 -0
  123. package/src/mcp.ts +73 -0
  124. package/src/meta.ts +72 -0
  125. package/src/otel.ts +151 -0
  126. package/src/router.ts +15 -0
  127. package/src/stream.ts +54 -0
  128. package/src/types.ts +389 -0
@@ -0,0 +1,828 @@
1
+ import type { Schema } from '@orpc/contract'
2
+ import { ORPCError } from '@orpc/server'
3
+ import type { ZodObject, ZodRawShape, ZodType, z } from 'zod'
4
+ import type { FnLogger } from '../logger.js'
5
+ import { errorMessageOf, type SpanLike, type Tracing } from '../otel.js'
6
+ import type {
7
+ BuildProcedure,
8
+ FnContext,
9
+ FnDefinition,
10
+ FnProcedure,
11
+ MaybePromise,
12
+ ProcedureKey,
13
+ ProcedureOption
14
+ } from '../types.js'
15
+ import { decodePayload, encodePayload } from './codec.js'
16
+ import type {
17
+ BacklogOptions,
18
+ PubSubMessage,
19
+ PubSubTransport
20
+ } from './transport.js'
21
+
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ // Types
24
+ //
25
+ // Publishers pass an event schema's INPUT (`z.input`); subscribers, filters
26
+ // and channel resolvers see its OUTPUT (`z.output`). The raw input travels on
27
+ // the wire and every receiver parses it once.
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+
30
+ /** Any Zod object schema, including `.strict()`, `.passthrough()` and `.catchall()`. */
31
+ export type ObjectSchema = ZodObject<ZodRawShape, z.core.$ZodObjectConfig>
32
+
33
+ /**
34
+ * A static channel name, or one derived from (partial) subscriber input or
35
+ * event data.
36
+ */
37
+ export type ChannelDefinition<TInput, TEventData, TContext = unknown> =
38
+ | string
39
+ | ((
40
+ params: Partial<TInput> | Partial<TEventData>,
41
+ context?: TContext
42
+ ) => string)
43
+
44
+ /** Decides whether one event reaches one subscriber. */
45
+ export type FilterFn<TInput, TEventData> = (params: {
46
+ input: TInput
47
+ data: TEventData
48
+ }) => MaybePromise<boolean>
49
+
50
+ /**
51
+ * Checked when a subscription opens, before anything is sent. `true` allows
52
+ * everyone; a function returns false (or throws) to refuse with FORBIDDEN.
53
+ */
54
+ export type AuthFn<TInput, TContext> =
55
+ | ((params: { input: TInput; ctx: TContext }) => MaybePromise<boolean>)
56
+ | true
57
+
58
+ export type PublisherConfig = {
59
+ /** Required name for tracing (e.g. 'chat.messages'). */
60
+ name: string
61
+ /**
62
+ * Store recent events in a backlog list; new subscribers receive it before
63
+ * live events. Default: false
64
+ */
65
+ useBacklog?: boolean
66
+ /** Maximum backlog items to keep (default: 50). */
67
+ backlogSize?: number
68
+ /** Backlog TTL in seconds (default: 30). */
69
+ backlogTtl?: number
70
+ /**
71
+ * Also publish every payload to this channel (with its own backlog when
72
+ * `useBacklog`). Subscribers still use `channel`. Equal to the primary
73
+ * channel means no duplicate work.
74
+ */
75
+ mirrorChannel?: string
76
+ }
77
+
78
+ export type PubSubOptions<
79
+ TDef extends FnDefinition,
80
+ TInputSchema extends ObjectSchema,
81
+ TEventSchema extends ZodType,
82
+ 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
+
110
+ export type PubSub<
111
+ TDef extends FnDefinition,
112
+ TInputSchema extends ObjectSchema,
113
+ TEventSchema extends ZodType,
114
+ TKey extends ProcedureKey<TDef>
115
+ > = {
116
+ subscribe: FnProcedure<
117
+ TDef,
118
+ TKey,
119
+ TInputSchema,
120
+ Schema<
121
+ AsyncGenerator<z.output<TEventSchema>, void, unknown>,
122
+ AsyncGenerator<z.output<TEventSchema>, void, unknown>
123
+ >
124
+ >
125
+ publish: (data: z.input<TEventSchema>) => Promise<void>
126
+ /** Publish a batch in one atomic round-trip; items may target different channels. */
127
+ publishMany: (items: readonly z.input<TEventSchema>[]) => Promise<void>
128
+ getChannelName: (
129
+ params: Partial<z.output<TInputSchema>> | Partial<z.output<TEventSchema>>,
130
+ context?: FnContext<TDef, TKey>
131
+ ) => string
132
+ }
133
+
134
+ export type CreatePubSub<TDef extends FnDefinition> = <
135
+ TInputSchema extends ObjectSchema,
136
+ TEventSchema extends ZodType,
137
+ TKey extends ProcedureKey<TDef> = TDef['default']
138
+ >(
139
+ options: PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>> &
140
+ /** Which `procedures` builder the subscribe route uses. */
141
+ ProcedureOption<TDef, TKey>
142
+ ) => PubSub<TDef, TInputSchema, TEventSchema, TKey>
143
+
144
+ export type PublisherOptions<TEventSchema extends ZodType> = PublisherConfig & {
145
+ /** Static channel name, or a resolver over (partial) parsed event data. */
146
+ channel: string | ((params: Partial<z.output<TEventSchema>>) => string)
147
+ eventSchema: TEventSchema
148
+ }
149
+
150
+ export type Publisher<TEventSchema extends ZodType> = {
151
+ publish: (data: z.input<TEventSchema>) => Promise<void>
152
+ publishMany: (items: readonly z.input<TEventSchema>[]) => Promise<void>
153
+ getChannelName: (params: Partial<z.output<TEventSchema>>) => string
154
+ }
155
+
156
+ /**
157
+ * Publish-only counterpart to `createPubSub`, for buses whose subscribe side
158
+ * lives elsewhere (e.g. an `fnLive` route sharing the channel and schema).
159
+ */
160
+ export type CreatePublisher = <TEventSchema extends ZodType>(
161
+ options: PublisherOptions<TEventSchema>
162
+ ) => Publisher<TEventSchema>
163
+
164
+ export type PubSubRuntimeOptions = {
165
+ transport: PubSubTransport
166
+ /**
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.
169
+ */
170
+ onDrop?: (count: number, info: { name: string; channel: string }) => void
171
+ /** Per-subscriber queue bound before drop-oldest (default: 1000). */
172
+ maxQueueSize?: number
173
+ }
174
+
175
+ /** @internal */
176
+ export type LiveRuntimeOptions = {
177
+ buildProcedure: BuildProcedure
178
+ tracing: Tracing
179
+ createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
180
+ pubsub?: PubSubRuntimeOptions | undefined
181
+ }
182
+
183
+ // ═══════════════════════════════════════════════════════════════════════════
184
+ // Bounded queue
185
+ // ═══════════════════════════════════════════════════════════════════════════
186
+
187
+ /**
188
+ * Bounded drop-oldest FIFO with "one overflow marker per overflow episode"
189
+ * semantics.
190
+ *
191
+ * On the FIRST overflow of an episode the oldest message is dropped and the
192
+ * marker from `createOverflowMarker` is enqueued INSTEAD of the new event - the
193
+ * subscriber's resync-from-cursor recovers the loss. While that marker is still
194
+ * queued, further overflows drop the oldest NON-marker message (the marker
195
+ * itself is never displaced) and enqueue the new event WITHOUT a second marker:
196
+ * the pending marker's resync already covers every drop that happens before it
197
+ * is delivered. Without this dedupe a sustained overflow converges the whole
198
+ * queue to markers - one full snapshot re-run per queued slot. Dequeuing the
199
+ * marker ends the episode; the next overflow starts a fresh one.
200
+ *
201
+ * `createOverflowMarker` absent (or returning null) falls back to plain
202
+ * drop-oldest with the new event enqueued.
203
+ */
204
+ export function createBoundedEventQueue<T>(opts: {
205
+ maxSize: number
206
+ createOverflowMarker?: () => T | null
207
+ /** Called once per dropped message (metrics/counters). */
208
+ onDrop?: () => void
209
+ }) {
210
+ const items: T[] = []
211
+ // The queued marker while an overflow episode is open. Created once per
212
+ // episode, so identity is the exact "is this the pending marker?" test.
213
+ let pendingOverflowMarker: T | null = null
214
+
215
+ return {
216
+ get length() {
217
+ return items.length
218
+ },
219
+ 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?.()
229
+ return
230
+ }
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
+ }
244
+ }
245
+ items.push(data)
246
+ },
247
+ dequeue(): T | undefined {
248
+ const next = items.shift()
249
+ if (next !== undefined && next === pendingOverflowMarker) {
250
+ pendingOverflowMarker = null
251
+ }
252
+ return next
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * One subscriber's ordered, bounded delivery. Events wait in a bounded
259
+ * drop-oldest queue (see {@link createBoundedEventQueue}) BEFORE the
260
+ * subscriber's filter runs; the filter runs in order as the consumer reads,
261
+ * so a slow async filter neither reorders events nor lets pending work grow
262
+ * past `maxSize`. Overflow markers skip the filter.
263
+ */
264
+ export function createSubscriberDelivery<T>(options: {
265
+ maxSize: number
266
+ accept?: ((data: T) => MaybePromise<boolean>) | undefined
267
+ overflowMarker?: (() => T | null) | undefined
268
+ onFilterError: (error: unknown) => void
269
+ onDrop: () => void
270
+ }) {
271
+ type Item = { data: T; marker: boolean }
272
+ const queue = createBoundedEventQueue<Item>({
273
+ maxSize: options.maxSize,
274
+ createOverflowMarker: () => {
275
+ const marker = options.overflowMarker?.() ?? null
276
+ return marker === null ? null : { data: marker, marker: true }
277
+ },
278
+ onDrop: options.onDrop
279
+ })
280
+ let closed = false
281
+ let wake: (() => void) | null = null
282
+ const notify = () => {
283
+ wake?.()
284
+ wake = null
285
+ }
286
+
287
+ async function* events(): AsyncGenerator<T, void, unknown> {
288
+ while (!closed) {
289
+ const item = queue.dequeue()
290
+ if (!item) {
291
+ await new Promise<void>((resolve) => {
292
+ wake = resolve
293
+ })
294
+ continue
295
+ }
296
+ if (!item.marker && options.accept) {
297
+ try {
298
+ if (!(await options.accept(item.data))) continue
299
+ } catch (error) {
300
+ options.onFilterError(error)
301
+ continue
302
+ }
303
+ if (closed) break
304
+ }
305
+ yield item.data
306
+ }
307
+ }
308
+
309
+ return {
310
+ push(data: T) {
311
+ if (closed) return
312
+ queue.enqueue({ data, marker: false })
313
+ notify()
314
+ },
315
+ events: events(),
316
+ /** Stop delivering; a parked read ends at once. */
317
+ close() {
318
+ closed = true
319
+ notify()
320
+ }
321
+ }
322
+ }
323
+
324
+ // ═══════════════════════════════════════════════════════════════════════════
325
+ // Runtime (internal: the typed surface is what `createFn` returns)
326
+ // ═══════════════════════════════════════════════════════════════════════════
327
+
328
+ const DEFAULT_MAX_QUEUE_SIZE = 1000
329
+
330
+ // Exponential resubscribe backoff with jitter so reconnecting subscribers don't stampede the broker.
331
+ const RESUBSCRIBE_BASE_DELAY_MS = 1000
332
+ const RESUBSCRIBE_MAX_DELAY_MS = 30_000
333
+ const RESUBSCRIBE_MAX_JITTER_MS = 250
334
+
335
+ function getResubscribeDelayMs(attempt: number): number {
336
+ const base = Math.min(
337
+ RESUBSCRIBE_BASE_DELAY_MS * 2 ** attempt,
338
+ RESUBSCRIBE_MAX_DELAY_MS
339
+ )
340
+ return base + Math.floor(Math.random() * RESUBSCRIBE_MAX_JITTER_MS)
341
+ }
342
+
343
+ type Input = Record<string, unknown>
344
+
345
+ type LocalSubscriber = {
346
+ isActive: () => boolean
347
+ push: (data: unknown) => void
348
+ }
349
+
350
+ type Hub = {
351
+ subscribers: Set<LocalSubscriber>
352
+ /** Settles after the first subscribe attempt (or when the hub closes). */
353
+ ready: Promise<void>
354
+ close: () => void
355
+ }
356
+
357
+ /** An open subscription: registered, authorized and subscribed. */
358
+ export type LiveSubscription = {
359
+ /** Live events, after the backlog replay. Ends when closed or aborted. */
360
+ events: AsyncGenerator<unknown, void, unknown>
361
+ /** Release everything; safe to call more than once, and before `events` runs. */
362
+ close: () => void
363
+ }
364
+
365
+ /** @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
+ }
373
+
374
+ /** @internal */
375
+ export type Channel = {
376
+ /** Authorize and subscribe; resolves once events can be delivered. */
377
+ open: (options: {
378
+ input: Input
379
+ context: unknown
380
+ signal: AbortSignal | undefined
381
+ }) => Promise<LiveSubscription>
382
+ publish: (data: unknown) => Promise<void>
383
+ publishMany: (items: readonly unknown[]) => Promise<void>
384
+ getChannelName: (params: Input, context?: unknown) => string
385
+ }
386
+
387
+ const resolveChannel = (
388
+ channel: string | ((params: Input, context?: unknown) => string),
389
+ params: Input,
390
+ context?: unknown
391
+ ) => (typeof channel === 'string' ? channel : channel(params, context))
392
+
393
+ /** @internal */
394
+ export function createLiveRuntime(options: LiveRuntimeOptions) {
395
+ const { tracing, createLogger } = options
396
+ const logger = createLogger('pubsub', undefined)
397
+ // Deploy drain: every open subscription registers its close here. On
398
+ // SIGTERM the app ends them deliberately instead of letting the socket die
399
+ // with the process: the generator returns, oRPC closes the SSE response and
400
+ // the client's backoff reconnects to the new deployment.
401
+ const activeSubscriberCleanups = new Set<() => void>()
402
+
403
+ const requireTransport = () => {
404
+ if (!options.pubsub) {
405
+ throw new Error(
406
+ 'orpc-fn: live routes need a transport, pass createFn({ pubsub: { transport } })'
407
+ )
408
+ }
409
+ return options.pubsub.transport
410
+ }
411
+
412
+ /**
413
+ * Shared publish pipeline: validate the raw event, resolve the channel from
414
+ * the parsed event, then publish the raw event (atomically maintaining the
415
+ * backlog when enabled) inside a producer span.
416
+ */
417
+ function createChannelPublisher(
418
+ config: PublisherConfig & {
419
+ eventSchema: ZodType
420
+ getChannelName: (data: Input) => string
421
+ }
422
+ ) {
423
+ const { name, eventSchema, getChannelName, mirrorChannel } = config
424
+ const backlog: BacklogOptions | undefined = config.useBacklog
425
+ ? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
426
+ : undefined
427
+
428
+ const toMessages = async (items: readonly unknown[]) => {
429
+ const messages: PubSubMessage[] = []
430
+ for (const item of items) {
431
+ const data = (await eventSchema.parseAsync(item)) as Input
432
+ const channel = getChannelName(data)
433
+ const payload = encodePayload(item)
434
+ messages.push({ channel, payload })
435
+ if (mirrorChannel && mirrorChannel !== channel) {
436
+ messages.push({ channel: mirrorChannel, payload })
437
+ }
438
+ }
439
+ return messages
440
+ }
441
+
442
+ const send = (
443
+ operation: 'publish' | 'publishMany',
444
+ items: readonly unknown[]
445
+ ) =>
446
+ tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
447
+ const startTime = performance.now()
448
+ try {
449
+ span?.setAttribute('pubsub.operation', operation)
450
+ span?.setAttribute('pubsub.name', name)
451
+ span?.setAttribute('pubsub.batch_size', items.length)
452
+ const messages = await toMessages(items)
453
+ const first = messages[0]
454
+ if (first) {
455
+ span?.setAttribute('pubsub.channel', first.channel)
456
+ span?.setAttribute('pubsub.payload_size', first.payload.length)
457
+ }
458
+ await requireTransport().publish(messages, backlog)
459
+ } finally {
460
+ span?.setAttribute(
461
+ 'pubsub.duration_ms',
462
+ performance.now() - startTime
463
+ )
464
+ }
465
+ })
466
+
467
+ return {
468
+ publish: (data: unknown) => send('publish', [data]),
469
+ publishMany: async (items: readonly unknown[]) => {
470
+ if (items.length > 0) await send('publishMany', items)
471
+ }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Hubs for one pub/sub definition, keyed by channel. Many subscribers can
477
+ * share a channel (an org with many open tabs); one hub decodes and parses
478
+ * each payload once and fans the event out to every local subscriber,
479
+ * applying each subscriber's own filter. The hub also owns the broker
480
+ * subscription and its resubscribe backoff.
481
+ */
482
+ function createChannelHubs(config: { eventSchema: ZodType }) {
483
+ const { eventSchema } = config
484
+ const hubs = new Map<string, Hub>()
485
+
486
+ const createHub = (channel: string): Hub => {
487
+ const subscribers = new Set<LocalSubscriber>()
488
+ let closed = false
489
+ let unsubscribe: (() => Promise<void>) | null = null
490
+ let attempt = 0
491
+ let timer: ReturnType<typeof setTimeout> | null = null
492
+ 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()
500
+
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)
509
+ })
510
+ return
511
+ }
512
+ // Parsed once; each subscriber filters in its own delivery.
513
+ for (const sub of [...subscribers]) {
514
+ if (sub.isActive()) sub.push(parsed)
515
+ }
516
+ }
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
+ }
525
+
526
+ const scheduleResubscribe = (reason: string) => {
527
+ if (closed || timer) return
528
+ const delayMs = getResubscribeDelayMs(attempt)
529
+ attempt++
530
+ logger.warn(`Scheduling resubscribe for ${channel}`, {
531
+ reason,
532
+ attempt,
533
+ delayMs
534
+ })
535
+ timer = setTimeout(() => {
536
+ timer = null
537
+ if (closed) return
538
+ release(unsubscribe)
539
+ unsubscribe = null
540
+ void connect()
541
+ }, delayMs)
542
+ }
543
+
544
+ const connect = async () => {
545
+ try {
546
+ const next = await requireTransport().subscribe(
547
+ channel,
548
+ (raw) => {
549
+ parsing = parsing.then(() => deliver(raw))
550
+ },
551
+ (error) => {
552
+ logger.error(`Subscription to ${channel} lost`, {
553
+ error: error.message
554
+ })
555
+ scheduleResubscribe('lost')
556
+ }
557
+ )
558
+ if (closed) return release(next)
559
+ unsubscribe = next
560
+ attempt = 0
561
+ logger.info(`Subscribed to ${channel}`)
562
+ } catch (error) {
563
+ logger.error(`Failed to subscribe to ${channel}`, {
564
+ error: errorMessageOf(error)
565
+ })
566
+ 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
+ }
572
+ }
573
+
574
+ void connect()
575
+
576
+ return {
577
+ subscribers,
578
+ ready,
579
+ close: () => {
580
+ closed = true
581
+ if (timer) clearTimeout(timer)
582
+ timer = null
583
+ markReady()
584
+ release(unsubscribe)
585
+ unsubscribe = null
586
+ }
587
+ }
588
+ }
589
+
590
+ return function addLocalSubscriber(
591
+ channel: string,
592
+ subscriber: LocalSubscriber
593
+ ) {
594
+ let hub = hubs.get(channel)
595
+ if (!hub) {
596
+ hub = createHub(channel)
597
+ hubs.set(channel, hub)
598
+ }
599
+ hub.subscribers.add(subscriber)
600
+ const current = hub
601
+ return {
602
+ ready: current.ready,
603
+ remove: () => {
604
+ current.subscribers.delete(subscriber)
605
+ if (current.subscribers.size === 0 && hubs.get(channel) === current) {
606
+ current.close()
607
+ hubs.delete(channel)
608
+ }
609
+ }
610
+ }
611
+ }
612
+ }
613
+
614
+ /** Publish and open subscriptions on one channel definition. */
615
+ function createChannel(channelOptions: ChannelOptions): Channel {
616
+ const {
617
+ name,
618
+ channel,
619
+ eventSchema,
620
+ filterFn,
621
+ authFn,
622
+ overflowMarker,
623
+ useBacklog = false
624
+ } = channelOptions
625
+
626
+ const getChannelName = (params: Input, context?: unknown) =>
627
+ resolveChannel(channel, params, context)
628
+ const addLocalSubscriber = createChannelHubs({ eventSchema })
629
+
630
+ const open: Channel['open'] = async ({ input, context, signal }) => {
631
+ const transport = requireTransport()
632
+ const channelName = getChannelName(input, context)
633
+ const startTime = performance.now()
634
+ const span = tracing.startSpan(`${name}:subscribe`, 'SERVER', {
635
+ 'pubsub.channel': channelName,
636
+ 'pubsub.operation': 'subscribe',
637
+ 'pubsub.name': name
638
+ })
639
+ const subscriberLogger = createLogger('pubsub', span)
640
+
641
+ try {
642
+ if (
643
+ authFn !== undefined &&
644
+ authFn !== true &&
645
+ !(await authFn({ input, ctx: context }))
646
+ ) {
647
+ throw new ORPCError('FORBIDDEN', {
648
+ message: 'You do not have access to this subscription'
649
+ })
650
+ }
651
+ } catch (error) {
652
+ tracing.end(span, error)
653
+ throw error
654
+ }
655
+
656
+ subscriberLogger.info(`Subscribing to ${channelName}`)
657
+
658
+ let messageCount = 0
659
+ let droppedCount = 0
660
+ 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)),
664
+ onFilterError: (error) =>
665
+ subscriberLogger.error('Error processing message', {
666
+ error: errorMessageOf(error)
667
+ }),
668
+ onDrop: () => {
669
+ droppedCount++
670
+ options.pubsub?.onDrop?.(1, { name, channel: channelName })
671
+ }
672
+ })
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) => {
677
+ messageCount++
678
+ if (replaying) replaying.push(data)
679
+ else delivery.push(data)
680
+ }
681
+
682
+ // The one way a subscription ends, whatever ends it: abort, drain, the
683
+ // stream finishing, or `close()`. Releases everything at once, even
684
+ // while the stream is parked or not yet read.
685
+ let closed = false
686
+ const close = (...error: [] | [unknown]) => {
687
+ if (closed) return
688
+ closed = true
689
+ delivery.close()
690
+ activeSubscriberCleanups.delete(stop)
691
+ signal?.removeEventListener('abort', stop)
692
+ local.remove()
693
+ const durationMs = performance.now() - startTime
694
+ span?.setAttribute('pubsub.duration_ms', durationMs)
695
+ span?.setAttribute('pubsub.message_count', messageCount)
696
+ span?.setAttribute('pubsub.dropped_count', droppedCount)
697
+ tracing.end(span, ...error)
698
+ subscriberLogger.info(`Unsubscribed from ${channelName}`, {
699
+ messageCount,
700
+ droppedCount,
701
+ durationMs: Math.round(durationMs)
702
+ })
703
+ }
704
+ const stop = () => close()
705
+ const local = addLocalSubscriber(channelName, {
706
+ isActive: () => !closed,
707
+ push
708
+ })
709
+ activeSubscriberCleanups.add(stop)
710
+ signal?.addEventListener('abort', stop)
711
+ if (signal?.aborted) close()
712
+
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
729
+ }
730
+ }
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)
735
+ })
736
+ }
737
+ }
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
+
743
+ async function* events() {
744
+ try {
745
+ yield* delivery.events
746
+ } finally {
747
+ close()
748
+ }
749
+ }
750
+
751
+ return { events: events(), close: () => close() }
752
+ }
753
+
754
+ const { publish, publishMany } = createChannelPublisher({
755
+ ...channelOptions,
756
+ getChannelName
757
+ })
758
+
759
+ return { open, publish, publishMany, getChannelName }
760
+ }
761
+
762
+ function createPubSub(
763
+ pubsubOptions: ChannelOptions & {
764
+ inputSchema: ZodType
765
+ procedure?: string
766
+ tags?: string[]
767
+ summary?: string
768
+ description?: string
769
+ }
770
+ ) {
771
+ const { name } = pubsubOptions
772
+ const channel = createChannel(pubsubOptions)
773
+ const subscribe = options.buildProcedure({
774
+ 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
788
+ }
789
+ })
790
+
791
+ const { publish, publishMany, getChannelName } = channel
792
+ return { subscribe, publish, publishMany, getChannelName }
793
+ }
794
+
795
+ function createPublisher(publisherOptions: PublisherOptions<ZodType>) {
796
+ const getChannelName = (params: Input) =>
797
+ resolveChannel(publisherOptions.channel, params)
798
+ const { publish, publishMany } = createChannelPublisher({
799
+ ...publisherOptions,
800
+ getChannelName
801
+ })
802
+ return { publish, publishMany, getChannelName }
803
+ }
804
+
805
+ return {
806
+ createChannel,
807
+ createPubSub,
808
+ createPublisher,
809
+ /** Open subscriptions of this instance (drain progress, gauges). */
810
+ activePubSubSubscriberCount: () => activeSubscriberCleanups.size,
811
+ /** End every open subscription; returns how many were closed. */
812
+ drainPubSubSubscribers: () => {
813
+ const cleanups = [...activeSubscriberCleanups]
814
+ activeSubscriberCleanups.clear()
815
+ for (const cleanup of cleanups) {
816
+ try {
817
+ cleanup()
818
+ } catch (error) {
819
+ // A failed cleanup must not stop the drain.
820
+ logger.warn('Subscriber cleanup failed during drain', {
821
+ error: errorMessageOf(error)
822
+ })
823
+ }
824
+ }
825
+ return cleanups.length
826
+ }
827
+ }
828
+ }