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.
- package/README.md +70 -33
- 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/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +56 -0
- package/dist/cli.js.map +1 -0
- 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 +67 -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 +81 -13
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +437 -126
- 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/core.d.ts +41 -0
- package/dist/mcp/core.d.ts.map +1 -0
- package/dist/mcp/core.js +61 -0
- package/dist/mcp/core.js.map +1 -0
- package/dist/mcp/sdk.d.ts +12 -0
- package/dist/mcp/sdk.d.ts.map +1 -0
- package/dist/mcp/sdk.js +14 -0
- package/dist/mcp/sdk.js.map +1 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +13 -0
- package/dist/mcp/server.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 +13 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +35 -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 +96 -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 +28 -3
- package/src/assemble-procedure.ts +42 -0
- package/src/bound-call.ts +4 -3
- package/src/cli.ts +68 -0
- package/src/client.ts +9 -2
- package/src/compatibility.ts +39 -0
- package/src/create-fn.ts +295 -182
- package/src/expo.ts +49 -44
- package/src/hono.ts +36 -17
- package/src/index.ts +7 -2
- package/src/json-schema.ts +16 -0
- package/src/live/codec.ts +22 -3
- package/src/live/fn-live.ts +248 -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 +619 -168
- 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/core.ts +102 -0
- package/src/mcp/sdk.ts +36 -0
- package/src/mcp/server.ts +35 -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 +58 -1
- package/src/timing.ts +5 -0
- package/src/types.ts +186 -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,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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
if (sub.isActive())
|
|
247
|
-
sub.push(parsed);
|
|
370
|
+
finally {
|
|
371
|
+
parsing = false;
|
|
248
372
|
}
|
|
249
373
|
};
|
|
250
|
-
const release =
|
|
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
|
-
|
|
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
|
|
344
|
-
const
|
|
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:
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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 {
|
|
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). */
|