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.
- package/README.md +48 -32
- package/dist/assemble-procedure.d.ts +13 -0
- package/dist/assemble-procedure.d.ts.map +1 -0
- package/dist/assemble-procedure.js +29 -0
- package/dist/assemble-procedure.js.map +1 -0
- package/dist/bound-call.d.ts +1 -1
- package/dist/bound-call.d.ts.map +1 -1
- package/dist/bound-call.js.map +1 -1
- package/dist/client.d.ts +4 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -2
- package/dist/client.js.map +1 -1
- package/dist/compatibility.d.ts +16 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +4 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/create-fn.d.ts +30 -34
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +177 -121
- package/dist/create-fn.js.map +1 -1
- package/dist/expo.d.ts +11 -25
- package/dist/expo.d.ts.map +1 -1
- package/dist/expo.js +25 -30
- package/dist/expo.js.map +1 -1
- package/dist/hono.d.ts +12 -4
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +29 -12
- package/dist/hono.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/json-schema.d.ts +2 -0
- package/dist/json-schema.d.ts.map +1 -1
- package/dist/json-schema.js +11 -0
- package/dist/json-schema.js.map +1 -1
- package/dist/live/codec.d.ts +6 -1
- package/dist/live/codec.d.ts.map +1 -1
- package/dist/live/codec.js +14 -3
- package/dist/live/codec.js.map +1 -1
- package/dist/live/fn-live.d.ts +63 -22
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +98 -16
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +1 -1
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js.map +1 -1
- package/dist/live/ioredis.d.ts +4 -1
- package/dist/live/ioredis.d.ts.map +1 -1
- package/dist/live/ioredis.js +21 -2
- package/dist/live/ioredis.js.map +1 -1
- package/dist/live/lifecycle.d.ts +7 -0
- package/dist/live/lifecycle.d.ts.map +1 -0
- package/dist/live/lifecycle.js +36 -0
- package/dist/live/lifecycle.js.map +1 -0
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +4 -0
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +76 -12
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +429 -125
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +1 -1
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +3 -5
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/redis.d.ts +2 -2
- package/dist/live/redis.d.ts.map +1 -1
- package/dist/live/redis.js +36 -9
- package/dist/live/redis.js.map +1 -1
- package/dist/live/transport.d.ts +5 -1
- package/dist/live/transport.d.ts.map +1 -1
- package/dist/live/transport.js.map +1 -1
- package/dist/logger.d.ts +2 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +21 -0
- package/dist/logger.js.map +1 -1
- package/dist/mastra.d.ts.map +1 -1
- package/dist/mastra.js +16 -7
- package/dist/mastra.js.map +1 -1
- package/dist/mcp/sdk.d.ts +20 -0
- package/dist/mcp/sdk.d.ts.map +1 -0
- package/dist/mcp/sdk.js +82 -0
- package/dist/mcp/sdk.js.map +1 -0
- package/dist/mcp.d.ts +3 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +22 -7
- package/dist/mcp.js.map +1 -1
- package/dist/meta.d.ts +15 -7
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +9 -2
- package/dist/meta.js.map +1 -1
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +77 -6
- package/dist/otel.js.map +1 -1
- package/dist/route-options.d.ts +22 -0
- package/dist/route-options.d.ts.map +1 -0
- package/dist/route-options.js +67 -0
- package/dist/route-options.js.map +1 -0
- package/dist/router.d.ts +5 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +22 -0
- package/dist/router.js.map +1 -1
- package/dist/timing.d.ts +8 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +4 -0
- package/dist/timing.js.map +1 -0
- package/dist/types.d.ts +86 -41
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -2
- package/dist/types.js.map +1 -1
- package/package.json +15 -3
- package/src/assemble-procedure.ts +42 -0
- package/src/bound-call.ts +4 -3
- package/src/client.ts +9 -2
- package/src/compatibility.ts +39 -0
- package/src/create-fn.ts +297 -183
- package/src/expo.ts +49 -44
- package/src/hono.ts +36 -17
- package/src/index.ts +6 -2
- package/src/json-schema.ts +16 -0
- package/src/live/codec.ts +22 -3
- package/src/live/fn-live.ts +244 -69
- package/src/live/index.ts +2 -0
- package/src/live/ioredis.ts +22 -2
- package/src/live/lifecycle.ts +44 -0
- package/src/live/memory.ts +4 -0
- package/src/live/pub-sub.ts +608 -166
- package/src/live/redis-bun.ts +7 -5
- package/src/live/redis.ts +37 -11
- package/src/live/transport.ts +4 -1
- package/src/logger.ts +20 -0
- package/src/mastra.ts +15 -7
- package/src/mcp/sdk.ts +118 -0
- package/src/mcp.ts +48 -20
- package/src/meta.ts +40 -24
- package/src/otel.ts +77 -10
- package/src/route-options.ts +100 -0
- package/src/router.ts +39 -1
- package/src/timing.ts +5 -0
- package/src/types.ts +204 -62
package/dist/live/pub-sub.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ORPCError } from '@orpc/server';
|
|
2
2
|
import { errorMessageOf } from '../otel.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
if (sub.isActive())
|
|
247
|
-
sub.push(parsed);
|
|
363
|
+
finally {
|
|
364
|
+
parsing = false;
|
|
248
365
|
}
|
|
249
366
|
};
|
|
250
|
-
const release =
|
|
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
|
-
|
|
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
|
|
344
|
-
const
|
|
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:
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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 {
|
|
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). */
|