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
@@ -1,6 +1,7 @@
1
1
  import { ORPCError } from '@orpc/server';
2
2
  import { errorMessageOf } from '../otel.js';
3
- import { decodePayload, encodePayload } from './codec.js';
3
+ import { decodeMessage, encodePayload } from './codec.js';
4
+ import { LIVE_GAP, positiveInteger, safely, waitFor } from './lifecycle.js';
4
5
  // ═══════════════════════════════════════════════════════════════════════════
5
6
  // Bounded queue
6
7
  // ═══════════════════════════════════════════════════════════════════════════
@@ -22,6 +23,7 @@ import { decodePayload, encodePayload } from './codec.js';
22
23
  * drop-oldest with the new event enqueued.
23
24
  */
24
25
  export function createBoundedEventQueue(opts) {
26
+ positiveInteger(opts.maxSize, 'maxSize');
25
27
  const items = [];
26
28
  // The queued marker while an overflow episode is open. Created once per
27
29
  // episode, so identity is the exact "is this the pending marker?" test.
@@ -31,35 +33,41 @@ export function createBoundedEventQueue(opts) {
31
33
  return items.length;
32
34
  },
33
35
  enqueue(data) {
34
- if (items.length >= opts.maxSize) {
35
- if (pendingOverflowMarker !== null) {
36
- // Episode already signalled: drop the oldest non-marker message
37
- // and enqueue WITHOUT a second marker.
38
- const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0;
39
- if (dropIndex >= items.length) {
40
- // Degenerate maxSize: the queue is just the pending marker, so
41
- // the new event is the drop - the marker's resync covers it.
42
- opts.onDrop?.();
36
+ try {
37
+ if (items.length >= opts.maxSize) {
38
+ if (pendingOverflowMarker !== null) {
39
+ // Episode already signalled: drop the oldest non-marker message
40
+ // and enqueue WITHOUT a second marker.
41
+ const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0;
42
+ if (dropIndex >= items.length) {
43
+ // Degenerate maxSize: the queue is just the pending marker, so
44
+ // the new event is the drop - the marker's resync covers it.
45
+ safely(opts.onDrop);
46
+ return;
47
+ }
48
+ items.splice(dropIndex, 1);
49
+ safely(opts.onDrop);
50
+ items.push(data);
51
+ return;
52
+ }
53
+ items.shift();
54
+ safely(opts.onDrop);
55
+ const marker = opts.createOverflowMarker?.() ?? null;
56
+ if (marker !== null) {
57
+ pendingOverflowMarker = marker;
58
+ items.push(marker);
43
59
  return;
44
60
  }
45
- items.splice(dropIndex, 1);
46
- opts.onDrop?.();
47
- items.push(data);
48
- return;
49
- }
50
- items.shift();
51
- opts.onDrop?.();
52
- const marker = opts.createOverflowMarker?.() ?? null;
53
- if (marker !== null) {
54
- pendingOverflowMarker = marker;
55
- items.push(marker);
56
- return;
57
61
  }
62
+ items.push(data);
63
+ }
64
+ finally {
65
+ safely(() => opts.onDepth?.(items.length));
58
66
  }
59
- items.push(data);
60
67
  },
61
68
  dequeue() {
62
69
  const next = items.shift();
70
+ safely(() => opts.onDepth?.(items.length));
63
71
  if (next !== undefined && next === pendingOverflowMarker) {
64
72
  pendingOverflowMarker = null;
65
73
  }
@@ -81,9 +89,11 @@ export function createSubscriberDelivery(options) {
81
89
  const marker = options.overflowMarker?.() ?? null;
82
90
  return marker === null ? null : { data: marker, marker: true };
83
91
  },
84
- onDrop: options.onDrop
92
+ onDrop: options.onDrop,
93
+ ...(options.onDepth ? { onDepth: options.onDepth } : {})
85
94
  });
86
95
  let closed = false;
96
+ let failure;
87
97
  let wake = null;
88
98
  const notify = () => {
89
99
  wake?.();
@@ -104,7 +114,7 @@ export function createSubscriberDelivery(options) {
104
114
  continue;
105
115
  }
106
116
  catch (error) {
107
- options.onFilterError(error);
117
+ safely(() => options.onFilterError(error));
108
118
  continue;
109
119
  }
110
120
  if (closed)
@@ -112,18 +122,24 @@ export function createSubscriberDelivery(options) {
112
122
  }
113
123
  yield item.data;
114
124
  }
125
+ if (failure !== undefined)
126
+ throw failure;
115
127
  }
116
128
  return {
117
- push(data) {
129
+ push(data, marker = false) {
118
130
  if (closed)
119
131
  return;
120
- queue.enqueue({ data, marker: false });
132
+ queue.enqueue({ data, marker });
121
133
  notify();
122
134
  },
123
135
  events: events(),
124
136
  /** Stop delivering; a parked read ends at once. */
125
- close() {
137
+ close(error) {
138
+ if (closed)
139
+ return;
140
+ failure = error;
126
141
  closed = true;
142
+ safely(() => options.onDepth?.(0));
127
143
  notify();
128
144
  }
129
145
  };
@@ -140,11 +156,48 @@ function getResubscribeDelayMs(attempt) {
140
156
  const base = Math.min(RESUBSCRIBE_BASE_DELAY_MS * 2 ** attempt, RESUBSCRIBE_MAX_DELAY_MS);
141
157
  return base + Math.floor(Math.random() * RESUBSCRIBE_MAX_JITTER_MS);
142
158
  }
143
- const resolveChannel = (channel, params, context) => (typeof channel === 'string' ? channel : channel(params, context));
159
+ const resolveChannel = (channel, params, context, publishing = false) => {
160
+ if (typeof channel === 'string')
161
+ return channel;
162
+ if (typeof channel === 'function')
163
+ return channel(params, context);
164
+ return publishing
165
+ ? channel.publish(params)
166
+ : channel.subscribe({ input: params, context });
167
+ };
144
168
  /** @internal */
145
169
  export function createLiveRuntime(options) {
146
170
  const { tracing, createLogger } = options;
147
171
  const logger = createLogger('pubsub', undefined);
172
+ let terminated = false;
173
+ let shutdownPromise;
174
+ const releases = new Set();
175
+ const pendingInitialization = new Set();
176
+ const pendingOpens = new Set();
177
+ const defaultMaxQueue = positiveInteger(options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE, 'maxQueueSize');
178
+ const defaultMaxIngress = positiveInteger(options.pubsub?.maxIngressSize ?? defaultMaxQueue, 'maxIngressSize');
179
+ const defaultMaxReplay = positiveInteger(options.pubsub?.maxReplaySize ?? defaultMaxQueue, 'maxReplaySize');
180
+ const initializationTimeout = positiveInteger(options.pubsub?.initializationTimeoutMs ?? 10000, 'initializationTimeoutMs');
181
+ const metric = (event) => safely(() => options.pubsub?.onMetric?.(event));
182
+ const namespace = options.pubsub?.namespace;
183
+ const qualify = (name) => (namespace ? `${namespace}:${name}` : name);
184
+ const assertOpen = () => {
185
+ if (terminated)
186
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
187
+ message: 'Live runtime has shut down'
188
+ });
189
+ };
190
+ const releaseTransport = (release) => {
191
+ if (!release)
192
+ return;
193
+ const task = Promise.resolve(release()).catch((error) => {
194
+ logger.warn('Failed to release subscription', {
195
+ error: errorMessageOf(error)
196
+ });
197
+ });
198
+ releases.add(task);
199
+ void task.finally(() => releases.delete(task));
200
+ };
148
201
  // Deploy drain: every open subscription registers its close here. On
149
202
  // SIGTERM the app ends them deliberately instead of letting the socket die
150
203
  // with the process: the generator returns, oRPC closes the SSE response and
@@ -162,7 +215,14 @@ export function createLiveRuntime(options) {
162
215
  * backlog when enabled) inside a producer span.
163
216
  */
164
217
  function createChannelPublisher(config) {
165
- const { name, eventSchema, getChannelName, mirrorChannel } = config;
218
+ const { name, eventSchema, getChannelName } = config;
219
+ const mirrorChannel = config.mirrorChannel
220
+ ? qualify(config.mirrorChannel)
221
+ : undefined;
222
+ if (config.backlogSize !== undefined)
223
+ positiveInteger(config.backlogSize, 'backlogSize');
224
+ if (config.backlogTtl !== undefined)
225
+ positiveInteger(config.backlogTtl, 'backlogTtl');
166
226
  const backlog = config.useBacklog
167
227
  ? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
168
228
  : undefined;
@@ -180,6 +240,7 @@ export function createLiveRuntime(options) {
180
240
  return messages;
181
241
  };
182
242
  const send = (operation, items) => tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
243
+ assertOpen();
183
244
  const startTime = performance.now();
184
245
  try {
185
246
  span?.setAttribute('pubsub.operation', operation);
@@ -222,38 +283,88 @@ export function createLiveRuntime(options) {
222
283
  let attempt = 0;
223
284
  let timer = null;
224
285
  let markReady = () => { };
225
- const ready = new Promise((resolve) => {
226
- markReady = resolve;
227
- });
228
- // Parse in arrival order even when the schema is async.
229
- // ponytail: unbounded while an async schema lags behind the broker;
230
- // bound it if a channel can outpace its schema.
231
- let parsing = Promise.resolve();
232
- const deliver = async (raw) => {
233
- let parsed;
234
- try {
235
- parsed = await eventSchema.parseAsync(decodePayload(raw));
236
- }
237
- catch (error) {
238
- // Malformed payloads are dropped once for the whole channel.
239
- logger.warn(`Dropped malformed message on ${channel}`, {
240
- error: errorMessageOf(error)
286
+ let connectedOnce = false;
287
+ let connected = false;
288
+ let ready;
289
+ const markPending = () => {
290
+ ready = new Promise((resolve) => {
291
+ markReady = resolve;
292
+ });
293
+ };
294
+ markPending();
295
+ let parsing = false;
296
+ const ingress = createBoundedEventQueue({
297
+ maxSize: config.maxIngress,
298
+ onDepth: (depth) => metric({
299
+ type: 'queue',
300
+ stage: 'ingress',
301
+ depth,
302
+ limit: config.maxIngress,
303
+ name: config.name,
304
+ channel
305
+ }),
306
+ onDrop: () => {
307
+ metric({
308
+ type: 'drop',
309
+ stage: 'ingress',
310
+ count: 1,
311
+ name: config.name,
312
+ channel
241
313
  });
314
+ safely(() => options.pubsub?.onDrop?.(1, { name: config.name, channel }));
315
+ for (const sub of [...subscribers]) {
316
+ try {
317
+ sub.gap();
318
+ }
319
+ catch (error) {
320
+ safely(() => sub.fail(error));
321
+ }
322
+ }
323
+ }
324
+ });
325
+ const parsePending = async () => {
326
+ if (parsing)
242
327
  return;
328
+ parsing = true;
329
+ try {
330
+ while (!closed) {
331
+ const raw = ingress.dequeue();
332
+ if (raw === undefined)
333
+ break;
334
+ try {
335
+ const message = decodeMessage(raw);
336
+ const parsed = await eventSchema.parseAsync(message.value);
337
+ if (closed)
338
+ break;
339
+ for (const sub of [...subscribers])
340
+ if (sub.isActive()) {
341
+ try {
342
+ sub.push(parsed, message.id);
343
+ }
344
+ catch (error) {
345
+ safely(() => sub.fail(error));
346
+ }
347
+ }
348
+ }
349
+ catch (error) {
350
+ metric({
351
+ type: 'parseError',
352
+ stage: 'ingress',
353
+ error,
354
+ name: config.name,
355
+ channel
356
+ });
357
+ logger.warn(`Dropped malformed message on ${channel}`, {
358
+ error: errorMessageOf(error)
359
+ });
360
+ }
361
+ }
243
362
  }
244
- // Parsed once; each subscriber filters in its own delivery.
245
- for (const sub of [...subscribers]) {
246
- if (sub.isActive())
247
- sub.push(parsed);
363
+ finally {
364
+ parsing = false;
248
365
  }
249
366
  };
250
- const release = (stale) => {
251
- stale?.().catch((error) => {
252
- logger.warn(`Failed to unsubscribe from ${channel}`, {
253
- error: errorMessageOf(error)
254
- });
255
- });
256
- };
367
+ const release = releaseTransport;
257
368
  const scheduleResubscribe = (reason) => {
258
369
  if (closed || timer)
259
370
  return;
@@ -276,16 +387,51 @@ export function createLiveRuntime(options) {
276
387
  const connect = async () => {
277
388
  try {
278
389
  const next = await requireTransport().subscribe(channel, (raw) => {
279
- parsing = parsing.then(() => deliver(raw));
390
+ if (closed)
391
+ return;
392
+ ingress.enqueue(raw);
393
+ void parsePending();
280
394
  }, (error) => {
395
+ if (connected) {
396
+ connected = false;
397
+ markPending();
398
+ }
281
399
  logger.error(`Subscription to ${channel} lost`, {
282
400
  error: error.message
283
401
  });
284
402
  scheduleResubscribe('lost');
403
+ }, {
404
+ onReconnect: () => {
405
+ if (closed)
406
+ return;
407
+ metric({ type: 'reconnect', name: config.name, channel });
408
+ for (const sub of [...subscribers]) {
409
+ try {
410
+ sub.gap();
411
+ }
412
+ catch (error) {
413
+ safely(() => sub.fail(error));
414
+ }
415
+ }
416
+ }
285
417
  });
286
418
  if (closed)
287
419
  return release(next);
288
420
  unsubscribe = next;
421
+ connected = true;
422
+ if (connectedOnce) {
423
+ metric({ type: 'reconnect', name: config.name, channel });
424
+ for (const sub of [...subscribers]) {
425
+ try {
426
+ sub.gap();
427
+ }
428
+ catch (error) {
429
+ safely(() => sub.fail(error));
430
+ }
431
+ }
432
+ }
433
+ connectedOnce = true;
434
+ markReady();
289
435
  attempt = 0;
290
436
  logger.info(`Subscribed to ${channel}`);
291
437
  }
@@ -295,18 +441,23 @@ export function createLiveRuntime(options) {
295
441
  });
296
442
  scheduleResubscribe('subscribe-failed');
297
443
  }
298
- finally {
299
- // A failed first attempt must not hold subscribers forever; the
300
- // backoff keeps retrying in the background.
301
- markReady();
302
- }
303
444
  };
304
445
  void connect();
305
446
  return {
306
447
  subscribers,
307
- ready,
448
+ get ready() {
449
+ return ready;
450
+ },
308
451
  close: () => {
309
452
  closed = true;
453
+ metric({
454
+ type: 'queue',
455
+ stage: 'ingress',
456
+ depth: 0,
457
+ limit: config.maxIngress,
458
+ name: config.name,
459
+ channel
460
+ });
310
461
  if (timer)
311
462
  clearTimeout(timer);
312
463
  timer = null;
@@ -339,9 +490,20 @@ export function createLiveRuntime(options) {
339
490
  /** Publish and open subscriptions on one channel definition. */
340
491
  function createChannel(channelOptions) {
341
492
  const { name, channel, eventSchema, filterFn, authFn, overflowMarker, useBacklog = false } = channelOptions;
342
- const getChannelName = (params, context) => resolveChannel(channel, params, context);
343
- const addLocalSubscriber = createChannelHubs({ eventSchema });
344
- const open = async ({ input, context, signal }) => {
493
+ const getChannelName = (params, context) => qualify(resolveChannel(channel, params, context));
494
+ const getPublishChannel = (params) => qualify(resolveChannel(channel, params, undefined, true));
495
+ const maxQueue = positiveInteger(channelOptions.maxQueueSize ?? defaultMaxQueue, 'maxQueueSize');
496
+ const maxIngress = positiveInteger(channelOptions.maxIngressSize ?? defaultMaxIngress, 'maxIngressSize');
497
+ const maxReplay = positiveInteger(channelOptions.maxReplaySize ?? defaultMaxReplay, 'maxReplaySize');
498
+ const addLocalSubscriber = createChannelHubs({
499
+ eventSchema,
500
+ name,
501
+ maxIngress
502
+ });
503
+ const openInternal = async ({ input, context, signal, recoverGaps = false }) => {
504
+ assertOpen();
505
+ signal?.throwIfAborted();
506
+ const initializing = new AbortController();
345
507
  const transport = requireTransport();
346
508
  const channelName = getChannelName(input, context);
347
509
  const startTime = performance.now();
@@ -351,16 +513,24 @@ export function createLiveRuntime(options) {
351
513
  'pubsub.name': name
352
514
  });
353
515
  const subscriberLogger = createLogger('pubsub', span);
516
+ const cancelInitialization = () => initializing.abort(signal?.reason ?? new Error('Subscription initialization stopped'));
517
+ pendingInitialization.add(cancelInitialization);
518
+ signal?.addEventListener('abort', cancelInitialization, { once: true });
519
+ const finishInitialization = () => {
520
+ pendingInitialization.delete(cancelInitialization);
521
+ signal?.removeEventListener('abort', cancelInitialization);
522
+ };
354
523
  try {
355
524
  if (authFn !== undefined &&
356
525
  authFn !== true &&
357
- !(await authFn({ input, ctx: context }))) {
526
+ !(await waitFor(Promise.resolve(authFn({ input, ctx: context })), initializationTimeout, initializing.signal))) {
358
527
  throw new ORPCError('FORBIDDEN', {
359
528
  message: 'You do not have access to this subscription'
360
529
  });
361
530
  }
362
531
  }
363
532
  catch (error) {
533
+ finishInitialization();
364
534
  tracing.end(span, error);
365
535
  throw error;
366
536
  }
@@ -368,27 +538,98 @@ export function createLiveRuntime(options) {
368
538
  let messageCount = 0;
369
539
  let droppedCount = 0;
370
540
  const delivery = createSubscriberDelivery({
371
- maxSize: options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
372
- accept: filterFn && ((data) => filterFn({ input, data })),
373
- overflowMarker: overflowMarker && (() => overflowMarker(input)),
541
+ maxSize: maxQueue,
542
+ onDepth: (depth) => metric({
543
+ type: 'queue',
544
+ stage: 'delivery',
545
+ depth,
546
+ limit: maxQueue,
547
+ name,
548
+ channel: channelName
549
+ }),
550
+ accept: filterFn &&
551
+ ((data) => (data === LIVE_GAP ? true : filterFn({ input, data }))),
552
+ overflowMarker: recoverGaps
553
+ ? () => LIVE_GAP
554
+ : overflowMarker && (() => overflowMarker(input)),
374
555
  onFilterError: (error) => subscriberLogger.error('Error processing message', {
375
556
  error: errorMessageOf(error)
376
557
  }),
377
558
  onDrop: () => {
378
559
  droppedCount++;
379
- options.pubsub?.onDrop?.(1, { name, channel: channelName });
560
+ metric({
561
+ type: 'drop',
562
+ stage: 'delivery',
563
+ count: 1,
564
+ name,
565
+ channel: channelName
566
+ });
567
+ safely(() => options.pubsub?.onDrop?.(1, { name, channel: channelName }));
568
+ if (!recoverGaps && !overflowMarker)
569
+ queueMicrotask(() => close(new ORPCError('SERVICE_UNAVAILABLE', {
570
+ message: 'Subscription queue overflow; reconnect and resync'
571
+ })));
380
572
  }
381
573
  });
382
- // Live events wait here while the backlog replays, so replay comes first.
383
- // ponytail: unbounded for the length of one backlog read.
384
574
  let replaying = useBacklog ? [] : null;
385
- const push = (data) => {
575
+ let replayGap = false;
576
+ const seen = new Set();
577
+ const dedupe = (id) => {
578
+ if (!id)
579
+ return false;
580
+ if (seen.has(id))
581
+ return true;
582
+ seen.add(id);
583
+ if (seen.size >
584
+ Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2)
585
+ seen.delete(seen.values().next().value);
586
+ return false;
587
+ };
588
+ const push = (data, id) => {
386
589
  messageCount++;
387
- if (replaying)
388
- replaying.push(data);
389
- else
590
+ if (replaying) {
591
+ if (replaying.length >= maxReplay) {
592
+ replaying.shift();
593
+ replayGap = true;
594
+ metric({
595
+ type: 'drop',
596
+ stage: 'replay',
597
+ count: 1,
598
+ name,
599
+ channel: channelName
600
+ });
601
+ safely(() => options.pubsub?.onDrop?.(1, { name, channel: channelName }));
602
+ }
603
+ replaying.push({ data, id });
604
+ metric({
605
+ type: 'queue',
606
+ stage: 'replay',
607
+ depth: replaying.length,
608
+ limit: maxReplay,
609
+ name,
610
+ channel: channelName
611
+ });
612
+ }
613
+ else if (!dedupe(id))
390
614
  delivery.push(data);
391
615
  };
616
+ const gap = () => {
617
+ if (replaying) {
618
+ replayGap = true;
619
+ return;
620
+ }
621
+ if (recoverGaps)
622
+ delivery.push(LIVE_GAP);
623
+ else if (overflowMarker) {
624
+ const marker = overflowMarker(input);
625
+ if (marker !== null)
626
+ delivery.push(marker, true);
627
+ }
628
+ else
629
+ close(new ORPCError('SERVICE_UNAVAILABLE', {
630
+ message: 'Subscription lost events; reconnect and resync'
631
+ }));
632
+ };
392
633
  // The one way a subscription ends, whatever ends it: abort, drain, the
393
634
  // stream finishing, or `close()`. Releases everything at once, even
394
635
  // while the stream is parked or not yet read.
@@ -397,7 +638,8 @@ export function createLiveRuntime(options) {
397
638
  if (closed)
398
639
  return;
399
640
  closed = true;
400
- delivery.close();
641
+ initializing.abort(error[0] ?? new Error('Subscription closed'));
642
+ delivery.close(error[0]);
401
643
  activeSubscriberCleanups.delete(stop);
402
644
  signal?.removeEventListener('abort', stop);
403
645
  local.remove();
@@ -415,45 +657,76 @@ export function createLiveRuntime(options) {
415
657
  const stop = () => close();
416
658
  const local = addLocalSubscriber(channelName, {
417
659
  isActive: () => !closed,
418
- push
660
+ push,
661
+ gap,
662
+ fail: (error) => close(error)
419
663
  });
420
664
  activeSubscriberCleanups.add(stop);
421
665
  signal?.addEventListener('abort', stop);
422
666
  if (signal?.aborted)
423
667
  close();
424
- // Subscribed before returning, so nothing published after `open`
425
- // resolves is missed.
426
- // ponytail: a failed first attempt also settles `ready` (the hub keeps
427
- // retrying); events published before a retry succeeds are lost.
428
- await local.ready;
429
- if (replaying && !closed) {
430
- try {
431
- const items = await transport.readBacklog(channelName);
432
- span?.setAttribute('pubsub.backlog_count', items.length);
433
- for (const raw of items) {
434
- if (closed)
435
- break;
436
- try {
437
- messageCount++;
438
- delivery.push(await eventSchema.parseAsync(decodePayload(raw)));
439
- }
440
- catch {
441
- // Ignore malformed backlog entries
668
+ try {
669
+ await waitFor(local.ready, initializationTimeout, initializing.signal);
670
+ if (replaying && !closed) {
671
+ try {
672
+ const items = await waitFor(transport.readBacklog(channelName), initializationTimeout, initializing.signal);
673
+ span?.setAttribute('pubsub.backlog_count', items.length);
674
+ for (const raw of items.slice(-maxReplay)) {
675
+ if (closed)
676
+ break;
677
+ try {
678
+ const message = decodeMessage(raw);
679
+ if (!dedupe(message.id)) {
680
+ messageCount++;
681
+ delivery.push(await waitFor(eventSchema.parseAsync(message.value), initializationTimeout, initializing.signal));
682
+ }
683
+ }
684
+ catch (error) {
685
+ metric({
686
+ type: 'parseError',
687
+ stage: 'replay',
688
+ error,
689
+ name,
690
+ channel: channelName
691
+ });
692
+ /* Malformed backlog entries are skipped. */
693
+ }
442
694
  }
695
+ if (items.length > maxReplay)
696
+ replayGap = true;
697
+ }
698
+ catch (error) {
699
+ replayGap = true;
700
+ subscriberLogger.warn(`Failed to replay backlog for ${channelName}`, { error: errorMessageOf(error) });
443
701
  }
444
702
  }
445
- catch (error) {
446
- // Live events still flow; only the backlog replay is lost.
447
- subscriberLogger.warn(`Failed to replay backlog for ${channelName}`, {
448
- error: errorMessageOf(error)
703
+ for (const { data, id } of replaying ?? [])
704
+ if (!dedupe(id))
705
+ delivery.push(data);
706
+ replaying = null;
707
+ metric({
708
+ type: 'queue',
709
+ stage: 'replay',
710
+ depth: 0,
711
+ limit: maxReplay,
712
+ name,
713
+ channel: channelName
714
+ });
715
+ if (replayGap && !closed)
716
+ gap();
717
+ }
718
+ catch (error) {
719
+ if (!closed) {
720
+ close(error);
721
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
722
+ message: 'Unable to establish subscription',
723
+ cause: error
449
724
  });
450
725
  }
451
726
  }
452
- // ponytail: an event both in the backlog and arriving live is delivered
453
- // twice; events carry no id to dedupe on.
454
- for (const data of replaying ?? [])
455
- delivery.push(data);
456
- replaying = null;
727
+ finally {
728
+ finishInitialization();
729
+ }
457
730
  async function* events() {
458
731
  try {
459
732
  yield* delivery.events;
@@ -462,39 +735,54 @@ export function createLiveRuntime(options) {
462
735
  close();
463
736
  }
464
737
  }
465
- return { events: events(), close: () => close() };
738
+ return { events: events(), close: () => close(), isClosed: () => closed };
466
739
  };
467
740
  const { publish, publishMany } = createChannelPublisher({
468
741
  ...channelOptions,
469
- getChannelName
742
+ getChannelName: getPublishChannel
470
743
  });
471
- return { open, publish, publishMany, getChannelName };
744
+ const open = (params) => {
745
+ const task = openInternal(params);
746
+ pendingOpens.add(task);
747
+ void task.then(() => pendingOpens.delete(task), () => pendingOpens.delete(task));
748
+ return task;
749
+ };
750
+ return {
751
+ open,
752
+ publish,
753
+ publishMany,
754
+ getChannelName,
755
+ getSubscriptionChannelName: ({ input, context }) => getChannelName(input, context),
756
+ getPublishChannelName: getPublishChannel
757
+ };
472
758
  }
473
759
  function createPubSub(pubsubOptions) {
474
760
  const { name } = pubsubOptions;
475
761
  const channel = createChannel(pubsubOptions);
476
- const subscribe = options.buildProcedure({
762
+ const { channel: _channel, eventSchema: _eventSchema, inputSchema, filterFn: _filterFn, authFn: _authFn, overflowMarker: _overflowMarker, useBacklog: _useBacklog, backlogSize: _backlogSize, backlogTtl: _backlogTtl, mirrorChannel: _mirrorChannel, maxQueueSize: _maxQueueSize, maxIngressSize: _maxIngressSize, maxReplaySize: _maxReplaySize, ...routeOptions } = pubsubOptions;
763
+ const subscribe = options.fn({
764
+ ...routeOptions,
477
765
  name,
478
- procedure: pubsubOptions.procedure,
479
- meta: {},
480
- route: {
481
- method: 'GET',
482
- tags: pubsubOptions.tags,
483
- summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
484
- description: pubsubOptions.description
485
- },
486
- input: pubsubOptions.inputSchema,
766
+ stream: true,
767
+ method: 'GET',
768
+ input: inputSchema,
769
+ summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
487
770
  handler: async function* ({ input, context, signal }) {
488
- // The generator from `events()` releases everything when it ends.
489
- yield* (await channel.open({ input: input, context, signal }))
490
- .events;
771
+ yield* (await channel.open({ input, context, signal })).events;
491
772
  }
492
773
  });
493
774
  const { publish, publishMany, getChannelName } = channel;
494
- return { subscribe, publish, publishMany, getChannelName };
775
+ return {
776
+ subscribe,
777
+ publish,
778
+ publishMany,
779
+ getChannelName,
780
+ getSubscriptionChannelName: channel.getSubscriptionChannelName,
781
+ getPublishChannelName: channel.getPublishChannelName
782
+ };
495
783
  }
496
784
  function createPublisher(publisherOptions) {
497
- const getChannelName = (params) => resolveChannel(publisherOptions.channel, params);
785
+ const getChannelName = (params) => qualify(resolveChannel(publisherOptions.channel, params, undefined, true));
498
786
  const { publish, publishMany } = createChannelPublisher({
499
787
  ...publisherOptions,
500
788
  getChannelName
@@ -503,6 +791,22 @@ export function createLiveRuntime(options) {
503
791
  }
504
792
  return {
505
793
  createChannel,
794
+ shutdown: () => {
795
+ if (shutdownPromise)
796
+ return shutdownPromise;
797
+ terminated = true;
798
+ for (const cancel of [...pendingInitialization])
799
+ safely(cancel);
800
+ for (const cleanup of [...activeSubscriberCleanups])
801
+ safely(cleanup);
802
+ shutdownPromise = (async () => {
803
+ await Promise.allSettled([...pendingOpens]);
804
+ await Promise.all([...releases]);
805
+ if (options.pubsub?.ownsTransport)
806
+ await options.pubsub.transport.close?.();
807
+ })();
808
+ return shutdownPromise;
809
+ },
506
810
  createPubSub,
507
811
  createPublisher,
508
812
  /** Open subscriptions of this instance (drain progress, gauges). */