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
@@ -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,21 +156,65 @@ 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
151
204
  // the client's backoff reconnects to the new deployment.
152
205
  const activeSubscriberCleanups = new Set();
206
+ let transport;
153
207
  const requireTransport = () => {
154
208
  if (!options.pubsub) {
155
209
  throw new Error('orpc-fn: live routes need a transport, pass createFn({ pubsub: { transport } })');
156
210
  }
157
- return options.pubsub.transport;
211
+ if (transport)
212
+ return transport;
213
+ // A lazy transport is never created during or after shutdown.
214
+ assertOpen();
215
+ const configured = options.pubsub.transport;
216
+ transport = typeof configured === 'function' ? configured() : configured;
217
+ return transport;
158
218
  };
159
219
  /**
160
220
  * Shared publish pipeline: validate the raw event, resolve the channel from
@@ -162,7 +222,14 @@ export function createLiveRuntime(options) {
162
222
  * backlog when enabled) inside a producer span.
163
223
  */
164
224
  function createChannelPublisher(config) {
165
- const { name, eventSchema, getChannelName, mirrorChannel } = config;
225
+ const { name, eventSchema, getChannelName } = config;
226
+ const mirrorChannel = config.mirrorChannel
227
+ ? qualify(config.mirrorChannel)
228
+ : undefined;
229
+ if (config.backlogSize !== undefined)
230
+ positiveInteger(config.backlogSize, 'backlogSize');
231
+ if (config.backlogTtl !== undefined)
232
+ positiveInteger(config.backlogTtl, 'backlogTtl');
166
233
  const backlog = config.useBacklog
167
234
  ? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
168
235
  : undefined;
@@ -180,6 +247,7 @@ export function createLiveRuntime(options) {
180
247
  return messages;
181
248
  };
182
249
  const send = (operation, items) => tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
250
+ assertOpen();
183
251
  const startTime = performance.now();
184
252
  try {
185
253
  span?.setAttribute('pubsub.operation', operation);
@@ -222,38 +290,88 @@ export function createLiveRuntime(options) {
222
290
  let attempt = 0;
223
291
  let timer = null;
224
292
  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)
293
+ let connectedOnce = false;
294
+ let connected = false;
295
+ let ready;
296
+ const markPending = () => {
297
+ ready = new Promise((resolve) => {
298
+ markReady = resolve;
299
+ });
300
+ };
301
+ markPending();
302
+ let parsing = false;
303
+ const ingress = createBoundedEventQueue({
304
+ maxSize: config.maxIngress,
305
+ onDepth: (depth) => metric({
306
+ type: 'queue',
307
+ stage: 'ingress',
308
+ depth,
309
+ limit: config.maxIngress,
310
+ name: config.name,
311
+ channel
312
+ }),
313
+ onDrop: () => {
314
+ metric({
315
+ type: 'drop',
316
+ stage: 'ingress',
317
+ count: 1,
318
+ name: config.name,
319
+ channel
241
320
  });
321
+ safely(() => options.pubsub?.onDrop?.(1, { name: config.name, channel }));
322
+ for (const sub of [...subscribers]) {
323
+ try {
324
+ sub.gap();
325
+ }
326
+ catch (error) {
327
+ safely(() => sub.fail(error));
328
+ }
329
+ }
330
+ }
331
+ });
332
+ const parsePending = async () => {
333
+ if (parsing)
242
334
  return;
335
+ parsing = true;
336
+ try {
337
+ while (!closed) {
338
+ const raw = ingress.dequeue();
339
+ if (raw === undefined)
340
+ break;
341
+ try {
342
+ const message = decodeMessage(raw);
343
+ const parsed = await eventSchema.parseAsync(message.value);
344
+ if (closed)
345
+ break;
346
+ for (const sub of [...subscribers])
347
+ if (sub.isActive()) {
348
+ try {
349
+ sub.push(parsed, message.id);
350
+ }
351
+ catch (error) {
352
+ safely(() => sub.fail(error));
353
+ }
354
+ }
355
+ }
356
+ catch (error) {
357
+ metric({
358
+ type: 'parseError',
359
+ stage: 'ingress',
360
+ error,
361
+ name: config.name,
362
+ channel
363
+ });
364
+ logger.warn(`Dropped malformed message on ${channel}`, {
365
+ error: errorMessageOf(error)
366
+ });
367
+ }
368
+ }
243
369
  }
244
- // Parsed once; each subscriber filters in its own delivery.
245
- for (const sub of [...subscribers]) {
246
- if (sub.isActive())
247
- sub.push(parsed);
370
+ finally {
371
+ parsing = false;
248
372
  }
249
373
  };
250
- const release = (stale) => {
251
- stale?.().catch((error) => {
252
- logger.warn(`Failed to unsubscribe from ${channel}`, {
253
- error: errorMessageOf(error)
254
- });
255
- });
256
- };
374
+ const release = releaseTransport;
257
375
  const scheduleResubscribe = (reason) => {
258
376
  if (closed || timer)
259
377
  return;
@@ -276,16 +394,51 @@ export function createLiveRuntime(options) {
276
394
  const connect = async () => {
277
395
  try {
278
396
  const next = await requireTransport().subscribe(channel, (raw) => {
279
- parsing = parsing.then(() => deliver(raw));
397
+ if (closed)
398
+ return;
399
+ ingress.enqueue(raw);
400
+ void parsePending();
280
401
  }, (error) => {
402
+ if (connected) {
403
+ connected = false;
404
+ markPending();
405
+ }
281
406
  logger.error(`Subscription to ${channel} lost`, {
282
407
  error: error.message
283
408
  });
284
409
  scheduleResubscribe('lost');
410
+ }, {
411
+ onReconnect: () => {
412
+ if (closed)
413
+ return;
414
+ metric({ type: 'reconnect', name: config.name, channel });
415
+ for (const sub of [...subscribers]) {
416
+ try {
417
+ sub.gap();
418
+ }
419
+ catch (error) {
420
+ safely(() => sub.fail(error));
421
+ }
422
+ }
423
+ }
285
424
  });
286
425
  if (closed)
287
426
  return release(next);
288
427
  unsubscribe = next;
428
+ connected = true;
429
+ if (connectedOnce) {
430
+ metric({ type: 'reconnect', name: config.name, channel });
431
+ for (const sub of [...subscribers]) {
432
+ try {
433
+ sub.gap();
434
+ }
435
+ catch (error) {
436
+ safely(() => sub.fail(error));
437
+ }
438
+ }
439
+ }
440
+ connectedOnce = true;
441
+ markReady();
289
442
  attempt = 0;
290
443
  logger.info(`Subscribed to ${channel}`);
291
444
  }
@@ -295,18 +448,23 @@ export function createLiveRuntime(options) {
295
448
  });
296
449
  scheduleResubscribe('subscribe-failed');
297
450
  }
298
- finally {
299
- // A failed first attempt must not hold subscribers forever; the
300
- // backoff keeps retrying in the background.
301
- markReady();
302
- }
303
451
  };
304
452
  void connect();
305
453
  return {
306
454
  subscribers,
307
- ready,
455
+ get ready() {
456
+ return ready;
457
+ },
308
458
  close: () => {
309
459
  closed = true;
460
+ metric({
461
+ type: 'queue',
462
+ stage: 'ingress',
463
+ depth: 0,
464
+ limit: config.maxIngress,
465
+ name: config.name,
466
+ channel
467
+ });
310
468
  if (timer)
311
469
  clearTimeout(timer);
312
470
  timer = null;
@@ -339,9 +497,20 @@ export function createLiveRuntime(options) {
339
497
  /** Publish and open subscriptions on one channel definition. */
340
498
  function createChannel(channelOptions) {
341
499
  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 }) => {
500
+ const getChannelName = (params, context) => qualify(resolveChannel(channel, params, context));
501
+ const getPublishChannel = (params) => qualify(resolveChannel(channel, params, undefined, true));
502
+ const maxQueue = positiveInteger(channelOptions.maxQueueSize ?? defaultMaxQueue, 'maxQueueSize');
503
+ const maxIngress = positiveInteger(channelOptions.maxIngressSize ?? defaultMaxIngress, 'maxIngressSize');
504
+ const maxReplay = positiveInteger(channelOptions.maxReplaySize ?? defaultMaxReplay, 'maxReplaySize');
505
+ const addLocalSubscriber = createChannelHubs({
506
+ eventSchema,
507
+ name,
508
+ maxIngress
509
+ });
510
+ const openInternal = async ({ input, context, signal, recoverGaps = false }) => {
511
+ assertOpen();
512
+ signal?.throwIfAborted();
513
+ const initializing = new AbortController();
345
514
  const transport = requireTransport();
346
515
  const channelName = getChannelName(input, context);
347
516
  const startTime = performance.now();
@@ -351,16 +520,24 @@ export function createLiveRuntime(options) {
351
520
  'pubsub.name': name
352
521
  });
353
522
  const subscriberLogger = createLogger('pubsub', span);
523
+ const cancelInitialization = () => initializing.abort(signal?.reason ?? new Error('Subscription initialization stopped'));
524
+ pendingInitialization.add(cancelInitialization);
525
+ signal?.addEventListener('abort', cancelInitialization, { once: true });
526
+ const finishInitialization = () => {
527
+ pendingInitialization.delete(cancelInitialization);
528
+ signal?.removeEventListener('abort', cancelInitialization);
529
+ };
354
530
  try {
355
531
  if (authFn !== undefined &&
356
532
  authFn !== true &&
357
- !(await authFn({ input, ctx: context }))) {
533
+ !(await waitFor(Promise.resolve(authFn({ input, ctx: context })), initializationTimeout, initializing.signal))) {
358
534
  throw new ORPCError('FORBIDDEN', {
359
535
  message: 'You do not have access to this subscription'
360
536
  });
361
537
  }
362
538
  }
363
539
  catch (error) {
540
+ finishInitialization();
364
541
  tracing.end(span, error);
365
542
  throw error;
366
543
  }
@@ -368,27 +545,98 @@ export function createLiveRuntime(options) {
368
545
  let messageCount = 0;
369
546
  let droppedCount = 0;
370
547
  const delivery = createSubscriberDelivery({
371
- maxSize: options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
372
- accept: filterFn && ((data) => filterFn({ input, data })),
373
- overflowMarker: overflowMarker && (() => overflowMarker(input)),
548
+ maxSize: maxQueue,
549
+ onDepth: (depth) => metric({
550
+ type: 'queue',
551
+ stage: 'delivery',
552
+ depth,
553
+ limit: maxQueue,
554
+ name,
555
+ channel: channelName
556
+ }),
557
+ accept: filterFn &&
558
+ ((data) => (data === LIVE_GAP ? true : filterFn({ input, data }))),
559
+ overflowMarker: recoverGaps
560
+ ? () => LIVE_GAP
561
+ : overflowMarker && (() => overflowMarker(input)),
374
562
  onFilterError: (error) => subscriberLogger.error('Error processing message', {
375
563
  error: errorMessageOf(error)
376
564
  }),
377
565
  onDrop: () => {
378
566
  droppedCount++;
379
- options.pubsub?.onDrop?.(1, { name, channel: channelName });
567
+ metric({
568
+ type: 'drop',
569
+ stage: 'delivery',
570
+ count: 1,
571
+ name,
572
+ channel: channelName
573
+ });
574
+ safely(() => options.pubsub?.onDrop?.(1, { name, channel: channelName }));
575
+ if (!recoverGaps && !overflowMarker)
576
+ queueMicrotask(() => close(new ORPCError('SERVICE_UNAVAILABLE', {
577
+ message: 'Subscription queue overflow; reconnect and resync'
578
+ })));
380
579
  }
381
580
  });
382
- // Live events wait here while the backlog replays, so replay comes first.
383
- // ponytail: unbounded for the length of one backlog read.
384
581
  let replaying = useBacklog ? [] : null;
385
- const push = (data) => {
582
+ let replayGap = false;
583
+ const seen = new Set();
584
+ const dedupe = (id) => {
585
+ if (!id)
586
+ return false;
587
+ if (seen.has(id))
588
+ return true;
589
+ seen.add(id);
590
+ if (seen.size >
591
+ Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2)
592
+ seen.delete(seen.values().next().value);
593
+ return false;
594
+ };
595
+ const push = (data, id) => {
386
596
  messageCount++;
387
- if (replaying)
388
- replaying.push(data);
389
- else
597
+ if (replaying) {
598
+ if (replaying.length >= maxReplay) {
599
+ replaying.shift();
600
+ replayGap = true;
601
+ metric({
602
+ type: 'drop',
603
+ stage: 'replay',
604
+ count: 1,
605
+ name,
606
+ channel: channelName
607
+ });
608
+ safely(() => options.pubsub?.onDrop?.(1, { name, channel: channelName }));
609
+ }
610
+ replaying.push({ data, id });
611
+ metric({
612
+ type: 'queue',
613
+ stage: 'replay',
614
+ depth: replaying.length,
615
+ limit: maxReplay,
616
+ name,
617
+ channel: channelName
618
+ });
619
+ }
620
+ else if (!dedupe(id))
390
621
  delivery.push(data);
391
622
  };
623
+ const gap = () => {
624
+ if (replaying) {
625
+ replayGap = true;
626
+ return;
627
+ }
628
+ if (recoverGaps)
629
+ delivery.push(LIVE_GAP);
630
+ else if (overflowMarker) {
631
+ const marker = overflowMarker(input);
632
+ if (marker !== null)
633
+ delivery.push(marker, true);
634
+ }
635
+ else
636
+ close(new ORPCError('SERVICE_UNAVAILABLE', {
637
+ message: 'Subscription lost events; reconnect and resync'
638
+ }));
639
+ };
392
640
  // The one way a subscription ends, whatever ends it: abort, drain, the
393
641
  // stream finishing, or `close()`. Releases everything at once, even
394
642
  // while the stream is parked or not yet read.
@@ -397,7 +645,8 @@ export function createLiveRuntime(options) {
397
645
  if (closed)
398
646
  return;
399
647
  closed = true;
400
- delivery.close();
648
+ initializing.abort(error[0] ?? new Error('Subscription closed'));
649
+ delivery.close(error[0]);
401
650
  activeSubscriberCleanups.delete(stop);
402
651
  signal?.removeEventListener('abort', stop);
403
652
  local.remove();
@@ -415,45 +664,76 @@ export function createLiveRuntime(options) {
415
664
  const stop = () => close();
416
665
  const local = addLocalSubscriber(channelName, {
417
666
  isActive: () => !closed,
418
- push
667
+ push,
668
+ gap,
669
+ fail: (error) => close(error)
419
670
  });
420
671
  activeSubscriberCleanups.add(stop);
421
672
  signal?.addEventListener('abort', stop);
422
673
  if (signal?.aborted)
423
674
  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
675
+ try {
676
+ await waitFor(local.ready, initializationTimeout, initializing.signal);
677
+ if (replaying && !closed) {
678
+ try {
679
+ const items = await waitFor(transport.readBacklog(channelName), initializationTimeout, initializing.signal);
680
+ span?.setAttribute('pubsub.backlog_count', items.length);
681
+ for (const raw of items.slice(-maxReplay)) {
682
+ if (closed)
683
+ break;
684
+ try {
685
+ const message = decodeMessage(raw);
686
+ if (!dedupe(message.id)) {
687
+ messageCount++;
688
+ delivery.push(await waitFor(eventSchema.parseAsync(message.value), initializationTimeout, initializing.signal));
689
+ }
690
+ }
691
+ catch (error) {
692
+ metric({
693
+ type: 'parseError',
694
+ stage: 'replay',
695
+ error,
696
+ name,
697
+ channel: channelName
698
+ });
699
+ /* Malformed backlog entries are skipped. */
700
+ }
442
701
  }
702
+ if (items.length > maxReplay)
703
+ replayGap = true;
704
+ }
705
+ catch (error) {
706
+ replayGap = true;
707
+ subscriberLogger.warn(`Failed to replay backlog for ${channelName}`, { error: errorMessageOf(error) });
443
708
  }
444
709
  }
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)
710
+ for (const { data, id } of replaying ?? [])
711
+ if (!dedupe(id))
712
+ delivery.push(data);
713
+ replaying = null;
714
+ metric({
715
+ type: 'queue',
716
+ stage: 'replay',
717
+ depth: 0,
718
+ limit: maxReplay,
719
+ name,
720
+ channel: channelName
721
+ });
722
+ if (replayGap && !closed)
723
+ gap();
724
+ }
725
+ catch (error) {
726
+ if (!closed) {
727
+ close(error);
728
+ throw new ORPCError('SERVICE_UNAVAILABLE', {
729
+ message: 'Unable to establish subscription',
730
+ cause: error
449
731
  });
450
732
  }
451
733
  }
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;
734
+ finally {
735
+ finishInitialization();
736
+ }
457
737
  async function* events() {
458
738
  try {
459
739
  yield* delivery.events;
@@ -462,39 +742,54 @@ export function createLiveRuntime(options) {
462
742
  close();
463
743
  }
464
744
  }
465
- return { events: events(), close: () => close() };
745
+ return { events: events(), close: () => close(), isClosed: () => closed };
466
746
  };
467
747
  const { publish, publishMany } = createChannelPublisher({
468
748
  ...channelOptions,
469
- getChannelName
749
+ getChannelName: getPublishChannel
470
750
  });
471
- return { open, publish, publishMany, getChannelName };
751
+ const open = (params) => {
752
+ const task = openInternal(params);
753
+ pendingOpens.add(task);
754
+ void task.then(() => pendingOpens.delete(task), () => pendingOpens.delete(task));
755
+ return task;
756
+ };
757
+ return {
758
+ open,
759
+ publish,
760
+ publishMany,
761
+ getChannelName,
762
+ getSubscriptionChannelName: ({ input, context }) => getChannelName(input, context),
763
+ getPublishChannelName: getPublishChannel
764
+ };
472
765
  }
473
766
  function createPubSub(pubsubOptions) {
474
767
  const { name } = pubsubOptions;
475
768
  const channel = createChannel(pubsubOptions);
476
- const subscribe = options.buildProcedure({
769
+ 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;
770
+ const subscribe = options.fn({
771
+ ...routeOptions,
477
772
  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,
773
+ stream: true,
774
+ method: 'GET',
775
+ input: inputSchema,
776
+ summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
487
777
  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;
778
+ yield* (await channel.open({ input, context, signal })).events;
491
779
  }
492
780
  });
493
781
  const { publish, publishMany, getChannelName } = channel;
494
- return { subscribe, publish, publishMany, getChannelName };
782
+ return {
783
+ subscribe,
784
+ publish,
785
+ publishMany,
786
+ getChannelName,
787
+ getSubscriptionChannelName: channel.getSubscriptionChannelName,
788
+ getPublishChannelName: channel.getPublishChannelName
789
+ };
495
790
  }
496
791
  function createPublisher(publisherOptions) {
497
- const getChannelName = (params) => resolveChannel(publisherOptions.channel, params);
792
+ const getChannelName = (params) => qualify(resolveChannel(publisherOptions.channel, params, undefined, true));
498
793
  const { publish, publishMany } = createChannelPublisher({
499
794
  ...publisherOptions,
500
795
  getChannelName
@@ -503,6 +798,22 @@ export function createLiveRuntime(options) {
503
798
  }
504
799
  return {
505
800
  createChannel,
801
+ shutdown: () => {
802
+ if (shutdownPromise)
803
+ return shutdownPromise;
804
+ terminated = true;
805
+ for (const cancel of [...pendingInitialization])
806
+ safely(cancel);
807
+ for (const cleanup of [...activeSubscriberCleanups])
808
+ safely(cleanup);
809
+ shutdownPromise = (async () => {
810
+ await Promise.allSettled([...pendingOpens]);
811
+ await Promise.all([...releases]);
812
+ if (options.pubsub?.ownsTransport)
813
+ await transport?.close?.();
814
+ })();
815
+ return shutdownPromise;
816
+ },
506
817
  createPubSub,
507
818
  createPublisher,
508
819
  /** Open subscriptions of this instance (drain progress, gauges). */