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/src/live/pub-sub.ts
CHANGED
|
@@ -4,15 +4,17 @@ import type { ZodObject, ZodRawShape, ZodType, z } from 'zod'
|
|
|
4
4
|
import type { FnLogger } from '../logger.js'
|
|
5
5
|
import { errorMessageOf, type SpanLike, type Tracing } from '../otel.js'
|
|
6
6
|
import type {
|
|
7
|
-
|
|
7
|
+
GuardOptions,
|
|
8
8
|
FnContext,
|
|
9
9
|
FnDefinition,
|
|
10
10
|
FnProcedure,
|
|
11
|
+
FnRouteOptions,
|
|
11
12
|
MaybePromise,
|
|
12
13
|
ProcedureKey,
|
|
13
14
|
ProcedureOption
|
|
14
15
|
} from '../types.js'
|
|
15
|
-
import {
|
|
16
|
+
import { decodeMessage, encodePayload } from './codec.js'
|
|
17
|
+
import { LIVE_GAP, positiveInteger, safely, waitFor } from './lifecycle.js'
|
|
16
18
|
import type {
|
|
17
19
|
BacklogOptions,
|
|
18
20
|
PubSubMessage,
|
|
@@ -36,6 +38,10 @@ export type ObjectSchema = ZodObject<ZodRawShape, z.core.$ZodObjectConfig>
|
|
|
36
38
|
*/
|
|
37
39
|
export type ChannelDefinition<TInput, TEventData, TContext = unknown> =
|
|
38
40
|
| string
|
|
41
|
+
| {
|
|
42
|
+
subscribe: (params: { input: TInput; context: TContext }) => string
|
|
43
|
+
publish: (event: TEventData) => string
|
|
44
|
+
}
|
|
39
45
|
| ((
|
|
40
46
|
params: Partial<TInput> | Partial<TEventData>,
|
|
41
47
|
context?: TContext
|
|
@@ -75,37 +81,58 @@ export type PublisherConfig = {
|
|
|
75
81
|
mirrorChannel?: string
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
/** Per-definition overrides of the factory queue bounds. */
|
|
85
|
+
export type ChannelQueueOptions = {
|
|
86
|
+
maxQueueSize?: number
|
|
87
|
+
maxIngressSize?: number
|
|
88
|
+
maxReplaySize?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Synchronous observational events; failures cannot interrupt delivery. */
|
|
92
|
+
export type PubSubMetric = { name: string; channel: string } & (
|
|
93
|
+
| {
|
|
94
|
+
type: 'queue'
|
|
95
|
+
stage: 'ingress' | 'replay' | 'delivery'
|
|
96
|
+
depth: number
|
|
97
|
+
limit: number
|
|
98
|
+
}
|
|
99
|
+
| { type: 'drop'; stage: 'ingress' | 'replay' | 'delivery'; count: number }
|
|
100
|
+
| { type: 'reconnect' }
|
|
101
|
+
| { type: 'parseError'; stage: 'ingress' | 'replay'; error: unknown }
|
|
102
|
+
)
|
|
103
|
+
|
|
78
104
|
export type PubSubOptions<
|
|
79
105
|
TDef extends FnDefinition,
|
|
80
106
|
TInputSchema extends ObjectSchema,
|
|
81
107
|
TEventSchema extends ZodType,
|
|
82
108
|
TKey extends ProcedureKey<TDef>
|
|
83
|
-
> = PublisherConfig &
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
> = PublisherConfig &
|
|
110
|
+
ChannelQueueOptions & {
|
|
111
|
+
channel: ChannelDefinition<
|
|
112
|
+
z.output<TInputSchema>,
|
|
113
|
+
z.output<TEventSchema>,
|
|
114
|
+
FnContext<TDef, TKey>
|
|
115
|
+
>
|
|
116
|
+
/** What the subscriber provides. */
|
|
117
|
+
inputSchema: TInputSchema
|
|
118
|
+
/** What gets published. */
|
|
119
|
+
eventSchema: TEventSchema
|
|
120
|
+
filterFn?: FilterFn<z.output<TInputSchema>, z.output<TEventSchema>>
|
|
121
|
+
authFn?: AuthFn<z.output<TInputSchema>, FnContext<TDef, TKey>>
|
|
122
|
+
/**
|
|
123
|
+
* When a slow subscriber's queue overflows (drop-oldest), enqueue this
|
|
124
|
+
* marker INSTEAD of the new event so the subscriber resyncs from its
|
|
125
|
+
* cursor. A wholly dropped item leaves no per-item gap a consumer could
|
|
126
|
+
* detect, so the marker is the only reliable live signal of loss. Return
|
|
127
|
+
* null to skip.
|
|
128
|
+
*/
|
|
129
|
+
overflowMarker?: (
|
|
130
|
+
input: z.output<TInputSchema>
|
|
131
|
+
) => z.output<TEventSchema> | null
|
|
132
|
+
tags?: TDef['tag'][]
|
|
133
|
+
summary?: string
|
|
134
|
+
description?: string
|
|
135
|
+
}
|
|
109
136
|
|
|
110
137
|
export type PubSub<
|
|
111
138
|
TDef extends FnDefinition,
|
|
@@ -125,6 +152,12 @@ export type PubSub<
|
|
|
125
152
|
publish: (data: z.input<TEventSchema>) => Promise<void>
|
|
126
153
|
/** Publish a batch in one atomic round-trip; items may target different channels. */
|
|
127
154
|
publishMany: (items: readonly z.input<TEventSchema>[]) => Promise<void>
|
|
155
|
+
getSubscriptionChannelName: (params: {
|
|
156
|
+
input: z.output<TInputSchema>
|
|
157
|
+
context: FnContext<TDef, TKey>
|
|
158
|
+
}) => string
|
|
159
|
+
getPublishChannelName: (event: z.output<TEventSchema>) => string
|
|
160
|
+
/** @deprecated Prefer the separate required-input resolver methods. */
|
|
128
161
|
getChannelName: (
|
|
129
162
|
params: Partial<z.output<TInputSchema>> | Partial<z.output<TEventSchema>>,
|
|
130
163
|
context?: FnContext<TDef, TKey>
|
|
@@ -137,8 +170,15 @@ export type CreatePubSub<TDef extends FnDefinition> = <
|
|
|
137
170
|
TKey extends ProcedureKey<TDef> = TDef['default']
|
|
138
171
|
>(
|
|
139
172
|
options: PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>> &
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
GuardOptions<TDef['guards']> &
|
|
174
|
+
Pick<
|
|
175
|
+
FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
|
|
176
|
+
'meta' | 'guardResolvers'
|
|
177
|
+
> &
|
|
178
|
+
/** Which `procedures` builder the subscribe route uses. */ ProcedureOption<
|
|
179
|
+
TDef,
|
|
180
|
+
TKey
|
|
181
|
+
>
|
|
142
182
|
) => PubSub<TDef, TInputSchema, TEventSchema, TKey>
|
|
143
183
|
|
|
144
184
|
export type PublisherOptions<TEventSchema extends ZodType> = PublisherConfig & {
|
|
@@ -162,19 +202,31 @@ export type CreatePublisher = <TEventSchema extends ZodType>(
|
|
|
162
202
|
) => Publisher<TEventSchema>
|
|
163
203
|
|
|
164
204
|
export type PubSubRuntimeOptions = {
|
|
205
|
+
/** Queue depths, drops, successful reconnects and parse failures. */
|
|
206
|
+
onMetric?: (event: PubSubMetric) => void
|
|
165
207
|
transport: PubSubTransport
|
|
166
208
|
/**
|
|
167
|
-
* Called
|
|
168
|
-
*
|
|
209
|
+
* Called per event dropped from ingress, replay or delivery queues.
|
|
210
|
+
* Delivery drops count events before subscriber filtering.
|
|
169
211
|
*/
|
|
170
212
|
onDrop?: (count: number, info: { name: string; channel: string }) => void
|
|
171
213
|
/** Per-subscriber queue bound before drop-oldest (default: 1000). */
|
|
172
214
|
maxQueueSize?: number
|
|
215
|
+
/** Pending raw messages per channel, before asynchronous parsing. */
|
|
216
|
+
maxIngressSize?: number
|
|
217
|
+
/** Buffered live messages during replay. */
|
|
218
|
+
maxReplaySize?: number
|
|
219
|
+
/** Broker subscription and backlog wait bound (default 10000ms). */
|
|
220
|
+
initializationTimeoutMs?: number
|
|
221
|
+
/** Applied to channels, mirrors and therefore backlog keys. */
|
|
222
|
+
namespace?: string
|
|
223
|
+
/** Close this transport only when its ownership is explicitly transferred. */
|
|
224
|
+
ownsTransport?: boolean
|
|
173
225
|
}
|
|
174
226
|
|
|
175
227
|
/** @internal */
|
|
176
228
|
export type LiveRuntimeOptions = {
|
|
177
|
-
|
|
229
|
+
fn: (options: Record<string, unknown>) => import('@orpc/server').AnyProcedure
|
|
178
230
|
tracing: Tracing
|
|
179
231
|
createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
|
|
180
232
|
pubsub?: PubSubRuntimeOptions | undefined
|
|
@@ -206,7 +258,9 @@ export function createBoundedEventQueue<T>(opts: {
|
|
|
206
258
|
createOverflowMarker?: () => T | null
|
|
207
259
|
/** Called once per dropped message (metrics/counters). */
|
|
208
260
|
onDrop?: () => void
|
|
261
|
+
onDepth?: (depth: number) => void
|
|
209
262
|
}) {
|
|
263
|
+
positiveInteger(opts.maxSize, 'maxSize')
|
|
210
264
|
const items: T[] = []
|
|
211
265
|
// The queued marker while an overflow episode is open. Created once per
|
|
212
266
|
// episode, so identity is the exact "is this the pending marker?" test.
|
|
@@ -217,35 +271,40 @@ export function createBoundedEventQueue<T>(opts: {
|
|
|
217
271
|
return items.length
|
|
218
272
|
},
|
|
219
273
|
enqueue(data: T): void {
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
274
|
+
try {
|
|
275
|
+
if (items.length >= opts.maxSize) {
|
|
276
|
+
if (pendingOverflowMarker !== null) {
|
|
277
|
+
// Episode already signalled: drop the oldest non-marker message
|
|
278
|
+
// and enqueue WITHOUT a second marker.
|
|
279
|
+
const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0
|
|
280
|
+
if (dropIndex >= items.length) {
|
|
281
|
+
// Degenerate maxSize: the queue is just the pending marker, so
|
|
282
|
+
// the new event is the drop - the marker's resync covers it.
|
|
283
|
+
safely(opts.onDrop)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
items.splice(dropIndex, 1)
|
|
287
|
+
safely(opts.onDrop)
|
|
288
|
+
items.push(data)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
items.shift()
|
|
292
|
+
safely(opts.onDrop)
|
|
293
|
+
const marker = opts.createOverflowMarker?.() ?? null
|
|
294
|
+
if (marker !== null) {
|
|
295
|
+
pendingOverflowMarker = marker
|
|
296
|
+
items.push(marker)
|
|
229
297
|
return
|
|
230
298
|
}
|
|
231
|
-
items.splice(dropIndex, 1)
|
|
232
|
-
opts.onDrop?.()
|
|
233
|
-
items.push(data)
|
|
234
|
-
return
|
|
235
|
-
}
|
|
236
|
-
items.shift()
|
|
237
|
-
opts.onDrop?.()
|
|
238
|
-
const marker = opts.createOverflowMarker?.() ?? null
|
|
239
|
-
if (marker !== null) {
|
|
240
|
-
pendingOverflowMarker = marker
|
|
241
|
-
items.push(marker)
|
|
242
|
-
return
|
|
243
299
|
}
|
|
300
|
+
items.push(data)
|
|
301
|
+
} finally {
|
|
302
|
+
safely(() => opts.onDepth?.(items.length))
|
|
244
303
|
}
|
|
245
|
-
items.push(data)
|
|
246
304
|
},
|
|
247
305
|
dequeue(): T | undefined {
|
|
248
306
|
const next = items.shift()
|
|
307
|
+
safely(() => opts.onDepth?.(items.length))
|
|
249
308
|
if (next !== undefined && next === pendingOverflowMarker) {
|
|
250
309
|
pendingOverflowMarker = null
|
|
251
310
|
}
|
|
@@ -267,6 +326,7 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
267
326
|
overflowMarker?: (() => T | null) | undefined
|
|
268
327
|
onFilterError: (error: unknown) => void
|
|
269
328
|
onDrop: () => void
|
|
329
|
+
onDepth?: (depth: number) => void
|
|
270
330
|
}) {
|
|
271
331
|
type Item = { data: T; marker: boolean }
|
|
272
332
|
const queue = createBoundedEventQueue<Item>({
|
|
@@ -275,9 +335,11 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
275
335
|
const marker = options.overflowMarker?.() ?? null
|
|
276
336
|
return marker === null ? null : { data: marker, marker: true }
|
|
277
337
|
},
|
|
278
|
-
onDrop: options.onDrop
|
|
338
|
+
onDrop: options.onDrop,
|
|
339
|
+
...(options.onDepth ? { onDepth: options.onDepth } : {})
|
|
279
340
|
})
|
|
280
341
|
let closed = false
|
|
342
|
+
let failure: unknown
|
|
281
343
|
let wake: (() => void) | null = null
|
|
282
344
|
const notify = () => {
|
|
283
345
|
wake?.()
|
|
@@ -297,25 +359,29 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
297
359
|
try {
|
|
298
360
|
if (!(await options.accept(item.data))) continue
|
|
299
361
|
} catch (error) {
|
|
300
|
-
options.onFilterError(error)
|
|
362
|
+
safely(() => options.onFilterError(error))
|
|
301
363
|
continue
|
|
302
364
|
}
|
|
303
365
|
if (closed) break
|
|
304
366
|
}
|
|
305
367
|
yield item.data
|
|
306
368
|
}
|
|
369
|
+
if (failure !== undefined) throw failure
|
|
307
370
|
}
|
|
308
371
|
|
|
309
372
|
return {
|
|
310
|
-
push(data: T) {
|
|
373
|
+
push(data: T, marker = false) {
|
|
311
374
|
if (closed) return
|
|
312
|
-
queue.enqueue({ data, marker
|
|
375
|
+
queue.enqueue({ data, marker })
|
|
313
376
|
notify()
|
|
314
377
|
},
|
|
315
378
|
events: events(),
|
|
316
379
|
/** Stop delivering; a parked read ends at once. */
|
|
317
|
-
close() {
|
|
380
|
+
close(error?: unknown) {
|
|
381
|
+
if (closed) return
|
|
382
|
+
failure = error
|
|
318
383
|
closed = true
|
|
384
|
+
safely(() => options.onDepth?.(0))
|
|
319
385
|
notify()
|
|
320
386
|
}
|
|
321
387
|
}
|
|
@@ -344,12 +410,14 @@ type Input = Record<string, unknown>
|
|
|
344
410
|
|
|
345
411
|
type LocalSubscriber = {
|
|
346
412
|
isActive: () => boolean
|
|
347
|
-
push: (data: unknown) => void
|
|
413
|
+
push: (data: unknown, id?: string) => void
|
|
414
|
+
gap: () => void
|
|
415
|
+
fail: (error: unknown) => void
|
|
348
416
|
}
|
|
349
417
|
|
|
350
418
|
type Hub = {
|
|
351
419
|
subscribers: Set<LocalSubscriber>
|
|
352
|
-
/**
|
|
420
|
+
/** Resolves after a successful broker subscription (or closure). */
|
|
353
421
|
ready: Promise<void>
|
|
354
422
|
close: () => void
|
|
355
423
|
}
|
|
@@ -360,16 +428,18 @@ export type LiveSubscription = {
|
|
|
360
428
|
events: AsyncGenerator<unknown, void, unknown>
|
|
361
429
|
/** Release everything; safe to call more than once, and before `events` runs. */
|
|
362
430
|
close: () => void
|
|
431
|
+
isClosed: () => boolean
|
|
363
432
|
}
|
|
364
433
|
|
|
365
434
|
/** @internal Untyped options; `PubSubOptions` is the typed surface. */
|
|
366
|
-
export type ChannelOptions = PublisherConfig &
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
435
|
+
export type ChannelOptions = PublisherConfig &
|
|
436
|
+
ChannelQueueOptions & {
|
|
437
|
+
channel: ChannelDefinition<Input, unknown>
|
|
438
|
+
eventSchema: ZodType
|
|
439
|
+
filterFn?: FilterFn<Input, unknown>
|
|
440
|
+
authFn?: AuthFn<Input, unknown>
|
|
441
|
+
overflowMarker?: (input: Input) => unknown
|
|
442
|
+
}
|
|
373
443
|
|
|
374
444
|
/** @internal */
|
|
375
445
|
export type Channel = {
|
|
@@ -378,22 +448,77 @@ export type Channel = {
|
|
|
378
448
|
input: Input
|
|
379
449
|
context: unknown
|
|
380
450
|
signal: AbortSignal | undefined
|
|
451
|
+
recoverGaps?: boolean
|
|
381
452
|
}) => Promise<LiveSubscription>
|
|
382
453
|
publish: (data: unknown) => Promise<void>
|
|
383
454
|
publishMany: (items: readonly unknown[]) => Promise<void>
|
|
384
455
|
getChannelName: (params: Input, context?: unknown) => string
|
|
456
|
+
getSubscriptionChannelName: (params: {
|
|
457
|
+
input: Input
|
|
458
|
+
context: unknown
|
|
459
|
+
}) => string
|
|
460
|
+
getPublishChannelName: (event: Input) => string
|
|
385
461
|
}
|
|
386
462
|
|
|
387
463
|
const resolveChannel = (
|
|
388
|
-
channel:
|
|
464
|
+
channel: ChannelDefinition<Input, unknown>,
|
|
389
465
|
params: Input,
|
|
390
|
-
context?: unknown
|
|
391
|
-
|
|
466
|
+
context?: unknown,
|
|
467
|
+
publishing = false
|
|
468
|
+
) => {
|
|
469
|
+
if (typeof channel === 'string') return channel
|
|
470
|
+
if (typeof channel === 'function') return channel(params, context)
|
|
471
|
+
return publishing
|
|
472
|
+
? channel.publish(params)
|
|
473
|
+
: channel.subscribe({ input: params, context })
|
|
474
|
+
}
|
|
392
475
|
|
|
393
476
|
/** @internal */
|
|
394
477
|
export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
395
478
|
const { tracing, createLogger } = options
|
|
396
479
|
const logger = createLogger('pubsub', undefined)
|
|
480
|
+
let terminated = false
|
|
481
|
+
let shutdownPromise: Promise<void> | undefined
|
|
482
|
+
const releases = new Set<Promise<void>>()
|
|
483
|
+
const pendingInitialization = new Set<() => void>()
|
|
484
|
+
const pendingOpens = new Set<Promise<LiveSubscription>>()
|
|
485
|
+
const defaultMaxQueue = positiveInteger(
|
|
486
|
+
options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
487
|
+
'maxQueueSize'
|
|
488
|
+
)
|
|
489
|
+
const defaultMaxIngress = positiveInteger(
|
|
490
|
+
options.pubsub?.maxIngressSize ?? defaultMaxQueue,
|
|
491
|
+
'maxIngressSize'
|
|
492
|
+
)
|
|
493
|
+
const defaultMaxReplay = positiveInteger(
|
|
494
|
+
options.pubsub?.maxReplaySize ?? defaultMaxQueue,
|
|
495
|
+
'maxReplaySize'
|
|
496
|
+
)
|
|
497
|
+
const initializationTimeout = positiveInteger(
|
|
498
|
+
options.pubsub?.initializationTimeoutMs ?? 10000,
|
|
499
|
+
'initializationTimeoutMs'
|
|
500
|
+
)
|
|
501
|
+
const metric = (event: PubSubMetric) =>
|
|
502
|
+
safely(() => options.pubsub?.onMetric?.(event))
|
|
503
|
+
const namespace = options.pubsub?.namespace
|
|
504
|
+
const qualify = (name: string) => (namespace ? `${namespace}:${name}` : name)
|
|
505
|
+
const assertOpen = () => {
|
|
506
|
+
if (terminated)
|
|
507
|
+
throw new ORPCError('SERVICE_UNAVAILABLE', {
|
|
508
|
+
message: 'Live runtime has shut down'
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
const releaseTransport = (release: (() => Promise<void>) | null) => {
|
|
512
|
+
if (!release) return
|
|
513
|
+
const task = Promise.resolve(release()).catch((error) => {
|
|
514
|
+
logger.warn('Failed to release subscription', {
|
|
515
|
+
error: errorMessageOf(error)
|
|
516
|
+
})
|
|
517
|
+
})
|
|
518
|
+
releases.add(task)
|
|
519
|
+
void task.finally(() => releases.delete(task))
|
|
520
|
+
}
|
|
521
|
+
|
|
397
522
|
// Deploy drain: every open subscription registers its close here. On
|
|
398
523
|
// SIGTERM the app ends them deliberately instead of letting the socket die
|
|
399
524
|
// with the process: the generator returns, oRPC closes the SSE response and
|
|
@@ -420,7 +545,14 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
420
545
|
getChannelName: (data: Input) => string
|
|
421
546
|
}
|
|
422
547
|
) {
|
|
423
|
-
const { name, eventSchema, getChannelName
|
|
548
|
+
const { name, eventSchema, getChannelName } = config
|
|
549
|
+
const mirrorChannel = config.mirrorChannel
|
|
550
|
+
? qualify(config.mirrorChannel)
|
|
551
|
+
: undefined
|
|
552
|
+
if (config.backlogSize !== undefined)
|
|
553
|
+
positiveInteger(config.backlogSize, 'backlogSize')
|
|
554
|
+
if (config.backlogTtl !== undefined)
|
|
555
|
+
positiveInteger(config.backlogTtl, 'backlogTtl')
|
|
424
556
|
const backlog: BacklogOptions | undefined = config.useBacklog
|
|
425
557
|
? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
|
|
426
558
|
: undefined
|
|
@@ -444,6 +576,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
444
576
|
items: readonly unknown[]
|
|
445
577
|
) =>
|
|
446
578
|
tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
|
|
579
|
+
assertOpen()
|
|
447
580
|
const startTime = performance.now()
|
|
448
581
|
try {
|
|
449
582
|
span?.setAttribute('pubsub.operation', operation)
|
|
@@ -479,7 +612,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
479
612
|
* applying each subscriber's own filter. The hub also owns the broker
|
|
480
613
|
* subscription and its resubscribe backoff.
|
|
481
614
|
*/
|
|
482
|
-
function createChannelHubs(config: {
|
|
615
|
+
function createChannelHubs(config: {
|
|
616
|
+
eventSchema: ZodType
|
|
617
|
+
name: string
|
|
618
|
+
maxIngress: number
|
|
619
|
+
}) {
|
|
483
620
|
const { eventSchema } = config
|
|
484
621
|
const hubs = new Map<string, Hub>()
|
|
485
622
|
|
|
@@ -490,38 +627,85 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
490
627
|
let attempt = 0
|
|
491
628
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
492
629
|
let markReady = () => {}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
630
|
+
let connectedOnce = false
|
|
631
|
+
let connected = false
|
|
632
|
+
let ready: Promise<void>
|
|
633
|
+
const markPending = () => {
|
|
634
|
+
ready = new Promise<void>((resolve) => {
|
|
635
|
+
markReady = resolve
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
markPending()
|
|
500
639
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
640
|
+
let parsing = false
|
|
641
|
+
const ingress = createBoundedEventQueue<string>({
|
|
642
|
+
maxSize: config.maxIngress,
|
|
643
|
+
onDepth: (depth) =>
|
|
644
|
+
metric({
|
|
645
|
+
type: 'queue',
|
|
646
|
+
stage: 'ingress',
|
|
647
|
+
depth,
|
|
648
|
+
limit: config.maxIngress,
|
|
649
|
+
name: config.name,
|
|
650
|
+
channel
|
|
651
|
+
}),
|
|
652
|
+
onDrop: () => {
|
|
653
|
+
metric({
|
|
654
|
+
type: 'drop',
|
|
655
|
+
stage: 'ingress',
|
|
656
|
+
count: 1,
|
|
657
|
+
name: config.name,
|
|
658
|
+
channel
|
|
509
659
|
})
|
|
510
|
-
|
|
660
|
+
safely(() =>
|
|
661
|
+
options.pubsub?.onDrop?.(1, { name: config.name, channel })
|
|
662
|
+
)
|
|
663
|
+
for (const sub of [...subscribers]) {
|
|
664
|
+
try {
|
|
665
|
+
sub.gap()
|
|
666
|
+
} catch (error) {
|
|
667
|
+
safely(() => sub.fail(error))
|
|
668
|
+
}
|
|
669
|
+
}
|
|
511
670
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
671
|
+
})
|
|
672
|
+
const parsePending = async () => {
|
|
673
|
+
if (parsing) return
|
|
674
|
+
parsing = true
|
|
675
|
+
try {
|
|
676
|
+
while (!closed) {
|
|
677
|
+
const raw = ingress.dequeue()
|
|
678
|
+
if (raw === undefined) break
|
|
679
|
+
try {
|
|
680
|
+
const message = decodeMessage(raw)
|
|
681
|
+
const parsed = await eventSchema.parseAsync(message.value)
|
|
682
|
+
if (closed) break
|
|
683
|
+
for (const sub of [...subscribers])
|
|
684
|
+
if (sub.isActive()) {
|
|
685
|
+
try {
|
|
686
|
+
sub.push(parsed, message.id)
|
|
687
|
+
} catch (error) {
|
|
688
|
+
safely(() => sub.fail(error))
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch (error) {
|
|
692
|
+
metric({
|
|
693
|
+
type: 'parseError',
|
|
694
|
+
stage: 'ingress',
|
|
695
|
+
error,
|
|
696
|
+
name: config.name,
|
|
697
|
+
channel
|
|
698
|
+
})
|
|
699
|
+
logger.warn(`Dropped malformed message on ${channel}`, {
|
|
700
|
+
error: errorMessageOf(error)
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} finally {
|
|
705
|
+
parsing = false
|
|
515
706
|
}
|
|
516
707
|
}
|
|
517
|
-
|
|
518
|
-
const release = (stale: (() => Promise<void>) | null) => {
|
|
519
|
-
stale?.().catch((error) => {
|
|
520
|
-
logger.warn(`Failed to unsubscribe from ${channel}`, {
|
|
521
|
-
error: errorMessageOf(error)
|
|
522
|
-
})
|
|
523
|
-
})
|
|
524
|
-
}
|
|
708
|
+
const release = releaseTransport
|
|
525
709
|
|
|
526
710
|
const scheduleResubscribe = (reason: string) => {
|
|
527
711
|
if (closed || timer) return
|
|
@@ -546,17 +730,49 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
546
730
|
const next = await requireTransport().subscribe(
|
|
547
731
|
channel,
|
|
548
732
|
(raw) => {
|
|
549
|
-
|
|
733
|
+
if (closed) return
|
|
734
|
+
ingress.enqueue(raw)
|
|
735
|
+
void parsePending()
|
|
550
736
|
},
|
|
551
737
|
(error) => {
|
|
738
|
+
if (connected) {
|
|
739
|
+
connected = false
|
|
740
|
+
markPending()
|
|
741
|
+
}
|
|
552
742
|
logger.error(`Subscription to ${channel} lost`, {
|
|
553
743
|
error: error.message
|
|
554
744
|
})
|
|
555
745
|
scheduleResubscribe('lost')
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
onReconnect: () => {
|
|
749
|
+
if (closed) return
|
|
750
|
+
metric({ type: 'reconnect', name: config.name, channel })
|
|
751
|
+
for (const sub of [...subscribers]) {
|
|
752
|
+
try {
|
|
753
|
+
sub.gap()
|
|
754
|
+
} catch (error) {
|
|
755
|
+
safely(() => sub.fail(error))
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
556
759
|
}
|
|
557
760
|
)
|
|
558
761
|
if (closed) return release(next)
|
|
559
762
|
unsubscribe = next
|
|
763
|
+
connected = true
|
|
764
|
+
if (connectedOnce) {
|
|
765
|
+
metric({ type: 'reconnect', name: config.name, channel })
|
|
766
|
+
for (const sub of [...subscribers]) {
|
|
767
|
+
try {
|
|
768
|
+
sub.gap()
|
|
769
|
+
} catch (error) {
|
|
770
|
+
safely(() => sub.fail(error))
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
connectedOnce = true
|
|
775
|
+
markReady()
|
|
560
776
|
attempt = 0
|
|
561
777
|
logger.info(`Subscribed to ${channel}`)
|
|
562
778
|
} catch (error) {
|
|
@@ -564,10 +780,6 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
564
780
|
error: errorMessageOf(error)
|
|
565
781
|
})
|
|
566
782
|
scheduleResubscribe('subscribe-failed')
|
|
567
|
-
} finally {
|
|
568
|
-
// A failed first attempt must not hold subscribers forever; the
|
|
569
|
-
// backoff keeps retrying in the background.
|
|
570
|
-
markReady()
|
|
571
783
|
}
|
|
572
784
|
}
|
|
573
785
|
|
|
@@ -575,9 +787,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
575
787
|
|
|
576
788
|
return {
|
|
577
789
|
subscribers,
|
|
578
|
-
ready
|
|
790
|
+
get ready() {
|
|
791
|
+
return ready
|
|
792
|
+
},
|
|
579
793
|
close: () => {
|
|
580
794
|
closed = true
|
|
795
|
+
metric({
|
|
796
|
+
type: 'queue',
|
|
797
|
+
stage: 'ingress',
|
|
798
|
+
depth: 0,
|
|
799
|
+
limit: config.maxIngress,
|
|
800
|
+
name: config.name,
|
|
801
|
+
channel
|
|
802
|
+
})
|
|
581
803
|
if (timer) clearTimeout(timer)
|
|
582
804
|
timer = null
|
|
583
805
|
markReady()
|
|
@@ -624,10 +846,36 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
624
846
|
} = channelOptions
|
|
625
847
|
|
|
626
848
|
const getChannelName = (params: Input, context?: unknown) =>
|
|
627
|
-
resolveChannel(channel, params, context)
|
|
628
|
-
const
|
|
849
|
+
qualify(resolveChannel(channel, params, context))
|
|
850
|
+
const getPublishChannel = (params: Input) =>
|
|
851
|
+
qualify(resolveChannel(channel, params, undefined, true))
|
|
852
|
+
const maxQueue = positiveInteger(
|
|
853
|
+
channelOptions.maxQueueSize ?? defaultMaxQueue,
|
|
854
|
+
'maxQueueSize'
|
|
855
|
+
)
|
|
856
|
+
const maxIngress = positiveInteger(
|
|
857
|
+
channelOptions.maxIngressSize ?? defaultMaxIngress,
|
|
858
|
+
'maxIngressSize'
|
|
859
|
+
)
|
|
860
|
+
const maxReplay = positiveInteger(
|
|
861
|
+
channelOptions.maxReplaySize ?? defaultMaxReplay,
|
|
862
|
+
'maxReplaySize'
|
|
863
|
+
)
|
|
864
|
+
const addLocalSubscriber = createChannelHubs({
|
|
865
|
+
eventSchema,
|
|
866
|
+
name,
|
|
867
|
+
maxIngress
|
|
868
|
+
})
|
|
629
869
|
|
|
630
|
-
const
|
|
870
|
+
const openInternal: Channel['open'] = async ({
|
|
871
|
+
input,
|
|
872
|
+
context,
|
|
873
|
+
signal,
|
|
874
|
+
recoverGaps = false
|
|
875
|
+
}) => {
|
|
876
|
+
assertOpen()
|
|
877
|
+
signal?.throwIfAborted()
|
|
878
|
+
const initializing = new AbortController()
|
|
631
879
|
const transport = requireTransport()
|
|
632
880
|
const channelName = getChannelName(input, context)
|
|
633
881
|
const startTime = performance.now()
|
|
@@ -637,18 +885,33 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
637
885
|
'pubsub.name': name
|
|
638
886
|
})
|
|
639
887
|
const subscriberLogger = createLogger('pubsub', span)
|
|
888
|
+
const cancelInitialization = () =>
|
|
889
|
+
initializing.abort(
|
|
890
|
+
signal?.reason ?? new Error('Subscription initialization stopped')
|
|
891
|
+
)
|
|
892
|
+
pendingInitialization.add(cancelInitialization)
|
|
893
|
+
signal?.addEventListener('abort', cancelInitialization, { once: true })
|
|
894
|
+
const finishInitialization = () => {
|
|
895
|
+
pendingInitialization.delete(cancelInitialization)
|
|
896
|
+
signal?.removeEventListener('abort', cancelInitialization)
|
|
897
|
+
}
|
|
640
898
|
|
|
641
899
|
try {
|
|
642
900
|
if (
|
|
643
901
|
authFn !== undefined &&
|
|
644
902
|
authFn !== true &&
|
|
645
|
-
!(await
|
|
903
|
+
!(await waitFor(
|
|
904
|
+
Promise.resolve(authFn({ input, ctx: context })),
|
|
905
|
+
initializationTimeout,
|
|
906
|
+
initializing.signal
|
|
907
|
+
))
|
|
646
908
|
) {
|
|
647
909
|
throw new ORPCError('FORBIDDEN', {
|
|
648
910
|
message: 'You do not have access to this subscription'
|
|
649
911
|
})
|
|
650
912
|
}
|
|
651
913
|
} catch (error) {
|
|
914
|
+
finishInitialization()
|
|
652
915
|
tracing.end(span, error)
|
|
653
916
|
throw error
|
|
654
917
|
}
|
|
@@ -658,25 +921,107 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
658
921
|
let messageCount = 0
|
|
659
922
|
let droppedCount = 0
|
|
660
923
|
const delivery = createSubscriberDelivery<unknown>({
|
|
661
|
-
maxSize:
|
|
662
|
-
|
|
663
|
-
|
|
924
|
+
maxSize: maxQueue,
|
|
925
|
+
onDepth: (depth) =>
|
|
926
|
+
metric({
|
|
927
|
+
type: 'queue',
|
|
928
|
+
stage: 'delivery',
|
|
929
|
+
depth,
|
|
930
|
+
limit: maxQueue,
|
|
931
|
+
name,
|
|
932
|
+
channel: channelName
|
|
933
|
+
}),
|
|
934
|
+
accept:
|
|
935
|
+
filterFn &&
|
|
936
|
+
((data) => (data === LIVE_GAP ? true : filterFn({ input, data }))),
|
|
937
|
+
overflowMarker: recoverGaps
|
|
938
|
+
? () => LIVE_GAP
|
|
939
|
+
: overflowMarker && (() => overflowMarker(input)),
|
|
664
940
|
onFilterError: (error) =>
|
|
665
941
|
subscriberLogger.error('Error processing message', {
|
|
666
942
|
error: errorMessageOf(error)
|
|
667
943
|
}),
|
|
668
944
|
onDrop: () => {
|
|
669
945
|
droppedCount++
|
|
670
|
-
|
|
946
|
+
metric({
|
|
947
|
+
type: 'drop',
|
|
948
|
+
stage: 'delivery',
|
|
949
|
+
count: 1,
|
|
950
|
+
name,
|
|
951
|
+
channel: channelName
|
|
952
|
+
})
|
|
953
|
+
safely(() =>
|
|
954
|
+
options.pubsub?.onDrop?.(1, { name, channel: channelName })
|
|
955
|
+
)
|
|
956
|
+
if (!recoverGaps && !overflowMarker)
|
|
957
|
+
queueMicrotask(() =>
|
|
958
|
+
close(
|
|
959
|
+
new ORPCError('SERVICE_UNAVAILABLE', {
|
|
960
|
+
message: 'Subscription queue overflow; reconnect and resync'
|
|
961
|
+
})
|
|
962
|
+
)
|
|
963
|
+
)
|
|
671
964
|
}
|
|
672
965
|
})
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
966
|
+
|
|
967
|
+
let replaying: Array<{ data: unknown; id: string | undefined }> | null =
|
|
968
|
+
useBacklog ? [] : null
|
|
969
|
+
let replayGap = false
|
|
970
|
+
const seen = new Set<string>()
|
|
971
|
+
const dedupe = (id: string | undefined) => {
|
|
972
|
+
if (!id) return false
|
|
973
|
+
if (seen.has(id)) return true
|
|
974
|
+
seen.add(id)
|
|
975
|
+
if (
|
|
976
|
+
seen.size >
|
|
977
|
+
Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2
|
|
978
|
+
)
|
|
979
|
+
seen.delete(seen.values().next().value as string)
|
|
980
|
+
return false
|
|
981
|
+
}
|
|
982
|
+
const push = (data: unknown, id?: string) => {
|
|
677
983
|
messageCount++
|
|
678
|
-
if (replaying)
|
|
679
|
-
|
|
984
|
+
if (replaying) {
|
|
985
|
+
if (replaying.length >= maxReplay) {
|
|
986
|
+
replaying.shift()
|
|
987
|
+
replayGap = true
|
|
988
|
+
metric({
|
|
989
|
+
type: 'drop',
|
|
990
|
+
stage: 'replay',
|
|
991
|
+
count: 1,
|
|
992
|
+
name,
|
|
993
|
+
channel: channelName
|
|
994
|
+
})
|
|
995
|
+
safely(() =>
|
|
996
|
+
options.pubsub?.onDrop?.(1, { name, channel: channelName })
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
replaying.push({ data, id })
|
|
1000
|
+
metric({
|
|
1001
|
+
type: 'queue',
|
|
1002
|
+
stage: 'replay',
|
|
1003
|
+
depth: replaying.length,
|
|
1004
|
+
limit: maxReplay,
|
|
1005
|
+
name,
|
|
1006
|
+
channel: channelName
|
|
1007
|
+
})
|
|
1008
|
+
} else if (!dedupe(id)) delivery.push(data)
|
|
1009
|
+
}
|
|
1010
|
+
const gap = () => {
|
|
1011
|
+
if (replaying) {
|
|
1012
|
+
replayGap = true
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
if (recoverGaps) delivery.push(LIVE_GAP)
|
|
1016
|
+
else if (overflowMarker) {
|
|
1017
|
+
const marker = overflowMarker(input)
|
|
1018
|
+
if (marker !== null) delivery.push(marker, true)
|
|
1019
|
+
} else
|
|
1020
|
+
close(
|
|
1021
|
+
new ORPCError('SERVICE_UNAVAILABLE', {
|
|
1022
|
+
message: 'Subscription lost events; reconnect and resync'
|
|
1023
|
+
})
|
|
1024
|
+
)
|
|
680
1025
|
}
|
|
681
1026
|
|
|
682
1027
|
// The one way a subscription ends, whatever ends it: abort, drain, the
|
|
@@ -686,7 +1031,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
686
1031
|
const close = (...error: [] | [unknown]) => {
|
|
687
1032
|
if (closed) return
|
|
688
1033
|
closed = true
|
|
689
|
-
|
|
1034
|
+
initializing.abort(error[0] ?? new Error('Subscription closed'))
|
|
1035
|
+
delivery.close(error[0])
|
|
690
1036
|
activeSubscriberCleanups.delete(stop)
|
|
691
1037
|
signal?.removeEventListener('abort', stop)
|
|
692
1038
|
local.remove()
|
|
@@ -704,41 +1050,81 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
704
1050
|
const stop = () => close()
|
|
705
1051
|
const local = addLocalSubscriber(channelName, {
|
|
706
1052
|
isActive: () => !closed,
|
|
707
|
-
push
|
|
1053
|
+
push,
|
|
1054
|
+
gap,
|
|
1055
|
+
fail: (error) => close(error)
|
|
708
1056
|
})
|
|
709
1057
|
activeSubscriberCleanups.add(stop)
|
|
710
1058
|
signal?.addEventListener('abort', stop)
|
|
711
1059
|
if (signal?.aborted) close()
|
|
712
1060
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1061
|
+
try {
|
|
1062
|
+
await waitFor(local.ready, initializationTimeout, initializing.signal)
|
|
1063
|
+
if (replaying && !closed) {
|
|
1064
|
+
try {
|
|
1065
|
+
const items = await waitFor(
|
|
1066
|
+
transport.readBacklog(channelName),
|
|
1067
|
+
initializationTimeout,
|
|
1068
|
+
initializing.signal
|
|
1069
|
+
)
|
|
1070
|
+
span?.setAttribute('pubsub.backlog_count', items.length)
|
|
1071
|
+
for (const raw of items.slice(-maxReplay)) {
|
|
1072
|
+
if (closed) break
|
|
1073
|
+
try {
|
|
1074
|
+
const message = decodeMessage(raw)
|
|
1075
|
+
if (!dedupe(message.id)) {
|
|
1076
|
+
messageCount++
|
|
1077
|
+
delivery.push(
|
|
1078
|
+
await waitFor(
|
|
1079
|
+
eventSchema.parseAsync(message.value),
|
|
1080
|
+
initializationTimeout,
|
|
1081
|
+
initializing.signal
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
}
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
metric({
|
|
1087
|
+
type: 'parseError',
|
|
1088
|
+
stage: 'replay',
|
|
1089
|
+
error,
|
|
1090
|
+
name,
|
|
1091
|
+
channel: channelName
|
|
1092
|
+
})
|
|
1093
|
+
/* Malformed backlog entries are skipped. */
|
|
1094
|
+
}
|
|
729
1095
|
}
|
|
1096
|
+
if (items.length > maxReplay) replayGap = true
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
replayGap = true
|
|
1099
|
+
subscriberLogger.warn(
|
|
1100
|
+
`Failed to replay backlog for ${channelName}`,
|
|
1101
|
+
{ error: errorMessageOf(error) }
|
|
1102
|
+
)
|
|
730
1103
|
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1104
|
+
}
|
|
1105
|
+
for (const { data, id } of replaying ?? [])
|
|
1106
|
+
if (!dedupe(id)) delivery.push(data)
|
|
1107
|
+
replaying = null
|
|
1108
|
+
metric({
|
|
1109
|
+
type: 'queue',
|
|
1110
|
+
stage: 'replay',
|
|
1111
|
+
depth: 0,
|
|
1112
|
+
limit: maxReplay,
|
|
1113
|
+
name,
|
|
1114
|
+
channel: channelName
|
|
1115
|
+
})
|
|
1116
|
+
if (replayGap && !closed) gap()
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
if (!closed) {
|
|
1119
|
+
close(error)
|
|
1120
|
+
throw new ORPCError('SERVICE_UNAVAILABLE', {
|
|
1121
|
+
message: 'Unable to establish subscription',
|
|
1122
|
+
cause: error
|
|
735
1123
|
})
|
|
736
1124
|
}
|
|
1125
|
+
} finally {
|
|
1126
|
+
finishInitialization()
|
|
737
1127
|
}
|
|
738
|
-
// ponytail: an event both in the backlog and arriving live is delivered
|
|
739
|
-
// twice; events carry no id to dedupe on.
|
|
740
|
-
for (const data of replaying ?? []) delivery.push(data)
|
|
741
|
-
replaying = null
|
|
742
1128
|
|
|
743
1129
|
async function* events() {
|
|
744
1130
|
try {
|
|
@@ -748,15 +1134,32 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
748
1134
|
}
|
|
749
1135
|
}
|
|
750
1136
|
|
|
751
|
-
return { events: events(), close: () => close() }
|
|
1137
|
+
return { events: events(), close: () => close(), isClosed: () => closed }
|
|
752
1138
|
}
|
|
753
1139
|
|
|
754
1140
|
const { publish, publishMany } = createChannelPublisher({
|
|
755
1141
|
...channelOptions,
|
|
756
|
-
getChannelName
|
|
1142
|
+
getChannelName: getPublishChannel
|
|
757
1143
|
})
|
|
758
1144
|
|
|
759
|
-
|
|
1145
|
+
const open: Channel['open'] = (params) => {
|
|
1146
|
+
const task = openInternal(params)
|
|
1147
|
+
pendingOpens.add(task)
|
|
1148
|
+
void task.then(
|
|
1149
|
+
() => pendingOpens.delete(task),
|
|
1150
|
+
() => pendingOpens.delete(task)
|
|
1151
|
+
)
|
|
1152
|
+
return task
|
|
1153
|
+
}
|
|
1154
|
+
return {
|
|
1155
|
+
open,
|
|
1156
|
+
publish,
|
|
1157
|
+
publishMany,
|
|
1158
|
+
getChannelName,
|
|
1159
|
+
getSubscriptionChannelName: ({ input, context }) =>
|
|
1160
|
+
getChannelName(input, context),
|
|
1161
|
+
getPublishChannelName: getPublishChannel
|
|
1162
|
+
}
|
|
760
1163
|
}
|
|
761
1164
|
|
|
762
1165
|
function createPubSub(
|
|
@@ -770,31 +1173,57 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
770
1173
|
) {
|
|
771
1174
|
const { name } = pubsubOptions
|
|
772
1175
|
const channel = createChannel(pubsubOptions)
|
|
773
|
-
|
|
1176
|
+
|
|
1177
|
+
const {
|
|
1178
|
+
channel: _channel,
|
|
1179
|
+
eventSchema: _eventSchema,
|
|
1180
|
+
inputSchema,
|
|
1181
|
+
filterFn: _filterFn,
|
|
1182
|
+
authFn: _authFn,
|
|
1183
|
+
overflowMarker: _overflowMarker,
|
|
1184
|
+
useBacklog: _useBacklog,
|
|
1185
|
+
backlogSize: _backlogSize,
|
|
1186
|
+
backlogTtl: _backlogTtl,
|
|
1187
|
+
mirrorChannel: _mirrorChannel,
|
|
1188
|
+
maxQueueSize: _maxQueueSize,
|
|
1189
|
+
maxIngressSize: _maxIngressSize,
|
|
1190
|
+
maxReplaySize: _maxReplaySize,
|
|
1191
|
+
...routeOptions
|
|
1192
|
+
} = pubsubOptions
|
|
1193
|
+
const subscribe = options.fn({
|
|
1194
|
+
...routeOptions,
|
|
774
1195
|
name,
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1196
|
+
stream: true,
|
|
1197
|
+
method: 'GET',
|
|
1198
|
+
input: inputSchema,
|
|
1199
|
+
summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
|
|
1200
|
+
handler: async function* ({
|
|
1201
|
+
input,
|
|
1202
|
+
context,
|
|
1203
|
+
signal
|
|
1204
|
+
}: {
|
|
1205
|
+
input: Input
|
|
1206
|
+
context: unknown
|
|
1207
|
+
signal: AbortSignal | undefined
|
|
1208
|
+
}) {
|
|
1209
|
+
yield* (await channel.open({ input, context, signal })).events
|
|
788
1210
|
}
|
|
789
1211
|
})
|
|
790
1212
|
|
|
791
1213
|
const { publish, publishMany, getChannelName } = channel
|
|
792
|
-
return {
|
|
1214
|
+
return {
|
|
1215
|
+
subscribe,
|
|
1216
|
+
publish,
|
|
1217
|
+
publishMany,
|
|
1218
|
+
getChannelName,
|
|
1219
|
+
getSubscriptionChannelName: channel.getSubscriptionChannelName,
|
|
1220
|
+
getPublishChannelName: channel.getPublishChannelName
|
|
1221
|
+
}
|
|
793
1222
|
}
|
|
794
1223
|
|
|
795
1224
|
function createPublisher(publisherOptions: PublisherOptions<ZodType>) {
|
|
796
1225
|
const getChannelName = (params: Input) =>
|
|
797
|
-
resolveChannel(publisherOptions.channel, params)
|
|
1226
|
+
qualify(resolveChannel(publisherOptions.channel, params, undefined, true))
|
|
798
1227
|
const { publish, publishMany } = createChannelPublisher({
|
|
799
1228
|
...publisherOptions,
|
|
800
1229
|
getChannelName
|
|
@@ -804,6 +1233,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
804
1233
|
|
|
805
1234
|
return {
|
|
806
1235
|
createChannel,
|
|
1236
|
+
shutdown: () => {
|
|
1237
|
+
if (shutdownPromise) return shutdownPromise
|
|
1238
|
+
terminated = true
|
|
1239
|
+
for (const cancel of [...pendingInitialization]) safely(cancel)
|
|
1240
|
+
for (const cleanup of [...activeSubscriberCleanups]) safely(cleanup)
|
|
1241
|
+
shutdownPromise = (async () => {
|
|
1242
|
+
await Promise.allSettled([...pendingOpens])
|
|
1243
|
+
await Promise.all([...releases])
|
|
1244
|
+
if (options.pubsub?.ownsTransport)
|
|
1245
|
+
await options.pubsub.transport.close?.()
|
|
1246
|
+
})()
|
|
1247
|
+
return shutdownPromise
|
|
1248
|
+
},
|
|
807
1249
|
createPubSub,
|
|
808
1250
|
createPublisher,
|
|
809
1251
|
/** Open subscriptions of this instance (drain progress, gauges). */
|