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/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,35 @@ export type CreatePublisher = <TEventSchema extends ZodType>(
|
|
|
162
202
|
) => Publisher<TEventSchema>
|
|
163
203
|
|
|
164
204
|
export type PubSubRuntimeOptions = {
|
|
165
|
-
|
|
205
|
+
/** Queue depths, drops, successful reconnects and parse failures. */
|
|
206
|
+
onMetric?: (event: PubSubMetric) => void
|
|
166
207
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
208
|
+
* The broker, or a function creating it on first use so importing routes
|
|
209
|
+
* opens no connection: `transport: () => bunRedisTransport(getRedis())`.
|
|
210
|
+
*/
|
|
211
|
+
transport: PubSubTransport | (() => PubSubTransport)
|
|
212
|
+
/**
|
|
213
|
+
* Called per event dropped from ingress, replay or delivery queues.
|
|
214
|
+
* Delivery drops count events before subscriber filtering.
|
|
169
215
|
*/
|
|
170
216
|
onDrop?: (count: number, info: { name: string; channel: string }) => void
|
|
171
217
|
/** Per-subscriber queue bound before drop-oldest (default: 1000). */
|
|
172
218
|
maxQueueSize?: number
|
|
219
|
+
/** Pending raw messages per channel, before asynchronous parsing. */
|
|
220
|
+
maxIngressSize?: number
|
|
221
|
+
/** Buffered live messages during replay. */
|
|
222
|
+
maxReplaySize?: number
|
|
223
|
+
/** Broker subscription and backlog wait bound (default 10000ms). */
|
|
224
|
+
initializationTimeoutMs?: number
|
|
225
|
+
/** Applied to channels, mirrors and therefore backlog keys. */
|
|
226
|
+
namespace?: string
|
|
227
|
+
/** Close this transport only when its ownership is explicitly transferred. */
|
|
228
|
+
ownsTransport?: boolean
|
|
173
229
|
}
|
|
174
230
|
|
|
175
231
|
/** @internal */
|
|
176
232
|
export type LiveRuntimeOptions = {
|
|
177
|
-
|
|
233
|
+
fn: (options: Record<string, unknown>) => import('@orpc/server').AnyProcedure
|
|
178
234
|
tracing: Tracing
|
|
179
235
|
createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
|
|
180
236
|
pubsub?: PubSubRuntimeOptions | undefined
|
|
@@ -206,7 +262,9 @@ export function createBoundedEventQueue<T>(opts: {
|
|
|
206
262
|
createOverflowMarker?: () => T | null
|
|
207
263
|
/** Called once per dropped message (metrics/counters). */
|
|
208
264
|
onDrop?: () => void
|
|
265
|
+
onDepth?: (depth: number) => void
|
|
209
266
|
}) {
|
|
267
|
+
positiveInteger(opts.maxSize, 'maxSize')
|
|
210
268
|
const items: T[] = []
|
|
211
269
|
// The queued marker while an overflow episode is open. Created once per
|
|
212
270
|
// episode, so identity is the exact "is this the pending marker?" test.
|
|
@@ -217,35 +275,40 @@ export function createBoundedEventQueue<T>(opts: {
|
|
|
217
275
|
return items.length
|
|
218
276
|
},
|
|
219
277
|
enqueue(data: T): void {
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
278
|
+
try {
|
|
279
|
+
if (items.length >= opts.maxSize) {
|
|
280
|
+
if (pendingOverflowMarker !== null) {
|
|
281
|
+
// Episode already signalled: drop the oldest non-marker message
|
|
282
|
+
// and enqueue WITHOUT a second marker.
|
|
283
|
+
const dropIndex = items[0] === pendingOverflowMarker ? 1 : 0
|
|
284
|
+
if (dropIndex >= items.length) {
|
|
285
|
+
// Degenerate maxSize: the queue is just the pending marker, so
|
|
286
|
+
// the new event is the drop - the marker's resync covers it.
|
|
287
|
+
safely(opts.onDrop)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
items.splice(dropIndex, 1)
|
|
291
|
+
safely(opts.onDrop)
|
|
292
|
+
items.push(data)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
items.shift()
|
|
296
|
+
safely(opts.onDrop)
|
|
297
|
+
const marker = opts.createOverflowMarker?.() ?? null
|
|
298
|
+
if (marker !== null) {
|
|
299
|
+
pendingOverflowMarker = marker
|
|
300
|
+
items.push(marker)
|
|
229
301
|
return
|
|
230
302
|
}
|
|
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
303
|
}
|
|
304
|
+
items.push(data)
|
|
305
|
+
} finally {
|
|
306
|
+
safely(() => opts.onDepth?.(items.length))
|
|
244
307
|
}
|
|
245
|
-
items.push(data)
|
|
246
308
|
},
|
|
247
309
|
dequeue(): T | undefined {
|
|
248
310
|
const next = items.shift()
|
|
311
|
+
safely(() => opts.onDepth?.(items.length))
|
|
249
312
|
if (next !== undefined && next === pendingOverflowMarker) {
|
|
250
313
|
pendingOverflowMarker = null
|
|
251
314
|
}
|
|
@@ -267,6 +330,7 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
267
330
|
overflowMarker?: (() => T | null) | undefined
|
|
268
331
|
onFilterError: (error: unknown) => void
|
|
269
332
|
onDrop: () => void
|
|
333
|
+
onDepth?: (depth: number) => void
|
|
270
334
|
}) {
|
|
271
335
|
type Item = { data: T; marker: boolean }
|
|
272
336
|
const queue = createBoundedEventQueue<Item>({
|
|
@@ -275,9 +339,11 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
275
339
|
const marker = options.overflowMarker?.() ?? null
|
|
276
340
|
return marker === null ? null : { data: marker, marker: true }
|
|
277
341
|
},
|
|
278
|
-
onDrop: options.onDrop
|
|
342
|
+
onDrop: options.onDrop,
|
|
343
|
+
...(options.onDepth ? { onDepth: options.onDepth } : {})
|
|
279
344
|
})
|
|
280
345
|
let closed = false
|
|
346
|
+
let failure: unknown
|
|
281
347
|
let wake: (() => void) | null = null
|
|
282
348
|
const notify = () => {
|
|
283
349
|
wake?.()
|
|
@@ -297,25 +363,29 @@ export function createSubscriberDelivery<T>(options: {
|
|
|
297
363
|
try {
|
|
298
364
|
if (!(await options.accept(item.data))) continue
|
|
299
365
|
} catch (error) {
|
|
300
|
-
options.onFilterError(error)
|
|
366
|
+
safely(() => options.onFilterError(error))
|
|
301
367
|
continue
|
|
302
368
|
}
|
|
303
369
|
if (closed) break
|
|
304
370
|
}
|
|
305
371
|
yield item.data
|
|
306
372
|
}
|
|
373
|
+
if (failure !== undefined) throw failure
|
|
307
374
|
}
|
|
308
375
|
|
|
309
376
|
return {
|
|
310
|
-
push(data: T) {
|
|
377
|
+
push(data: T, marker = false) {
|
|
311
378
|
if (closed) return
|
|
312
|
-
queue.enqueue({ data, marker
|
|
379
|
+
queue.enqueue({ data, marker })
|
|
313
380
|
notify()
|
|
314
381
|
},
|
|
315
382
|
events: events(),
|
|
316
383
|
/** Stop delivering; a parked read ends at once. */
|
|
317
|
-
close() {
|
|
384
|
+
close(error?: unknown) {
|
|
385
|
+
if (closed) return
|
|
386
|
+
failure = error
|
|
318
387
|
closed = true
|
|
388
|
+
safely(() => options.onDepth?.(0))
|
|
319
389
|
notify()
|
|
320
390
|
}
|
|
321
391
|
}
|
|
@@ -344,12 +414,14 @@ type Input = Record<string, unknown>
|
|
|
344
414
|
|
|
345
415
|
type LocalSubscriber = {
|
|
346
416
|
isActive: () => boolean
|
|
347
|
-
push: (data: unknown) => void
|
|
417
|
+
push: (data: unknown, id?: string) => void
|
|
418
|
+
gap: () => void
|
|
419
|
+
fail: (error: unknown) => void
|
|
348
420
|
}
|
|
349
421
|
|
|
350
422
|
type Hub = {
|
|
351
423
|
subscribers: Set<LocalSubscriber>
|
|
352
|
-
/**
|
|
424
|
+
/** Resolves after a successful broker subscription (or closure). */
|
|
353
425
|
ready: Promise<void>
|
|
354
426
|
close: () => void
|
|
355
427
|
}
|
|
@@ -360,16 +432,18 @@ export type LiveSubscription = {
|
|
|
360
432
|
events: AsyncGenerator<unknown, void, unknown>
|
|
361
433
|
/** Release everything; safe to call more than once, and before `events` runs. */
|
|
362
434
|
close: () => void
|
|
435
|
+
isClosed: () => boolean
|
|
363
436
|
}
|
|
364
437
|
|
|
365
438
|
/** @internal Untyped options; `PubSubOptions` is the typed surface. */
|
|
366
|
-
export type ChannelOptions = PublisherConfig &
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
439
|
+
export type ChannelOptions = PublisherConfig &
|
|
440
|
+
ChannelQueueOptions & {
|
|
441
|
+
channel: ChannelDefinition<Input, unknown>
|
|
442
|
+
eventSchema: ZodType
|
|
443
|
+
filterFn?: FilterFn<Input, unknown>
|
|
444
|
+
authFn?: AuthFn<Input, unknown>
|
|
445
|
+
overflowMarker?: (input: Input) => unknown
|
|
446
|
+
}
|
|
373
447
|
|
|
374
448
|
/** @internal */
|
|
375
449
|
export type Channel = {
|
|
@@ -378,35 +452,96 @@ export type Channel = {
|
|
|
378
452
|
input: Input
|
|
379
453
|
context: unknown
|
|
380
454
|
signal: AbortSignal | undefined
|
|
455
|
+
recoverGaps?: boolean
|
|
381
456
|
}) => Promise<LiveSubscription>
|
|
382
457
|
publish: (data: unknown) => Promise<void>
|
|
383
458
|
publishMany: (items: readonly unknown[]) => Promise<void>
|
|
384
459
|
getChannelName: (params: Input, context?: unknown) => string
|
|
460
|
+
getSubscriptionChannelName: (params: {
|
|
461
|
+
input: Input
|
|
462
|
+
context: unknown
|
|
463
|
+
}) => string
|
|
464
|
+
getPublishChannelName: (event: Input) => string
|
|
385
465
|
}
|
|
386
466
|
|
|
387
467
|
const resolveChannel = (
|
|
388
|
-
channel:
|
|
468
|
+
channel: ChannelDefinition<Input, unknown>,
|
|
389
469
|
params: Input,
|
|
390
|
-
context?: unknown
|
|
391
|
-
|
|
470
|
+
context?: unknown,
|
|
471
|
+
publishing = false
|
|
472
|
+
) => {
|
|
473
|
+
if (typeof channel === 'string') return channel
|
|
474
|
+
if (typeof channel === 'function') return channel(params, context)
|
|
475
|
+
return publishing
|
|
476
|
+
? channel.publish(params)
|
|
477
|
+
: channel.subscribe({ input: params, context })
|
|
478
|
+
}
|
|
392
479
|
|
|
393
480
|
/** @internal */
|
|
394
481
|
export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
395
482
|
const { tracing, createLogger } = options
|
|
396
483
|
const logger = createLogger('pubsub', undefined)
|
|
484
|
+
let terminated = false
|
|
485
|
+
let shutdownPromise: Promise<void> | undefined
|
|
486
|
+
const releases = new Set<Promise<void>>()
|
|
487
|
+
const pendingInitialization = new Set<() => void>()
|
|
488
|
+
const pendingOpens = new Set<Promise<LiveSubscription>>()
|
|
489
|
+
const defaultMaxQueue = positiveInteger(
|
|
490
|
+
options.pubsub?.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
491
|
+
'maxQueueSize'
|
|
492
|
+
)
|
|
493
|
+
const defaultMaxIngress = positiveInteger(
|
|
494
|
+
options.pubsub?.maxIngressSize ?? defaultMaxQueue,
|
|
495
|
+
'maxIngressSize'
|
|
496
|
+
)
|
|
497
|
+
const defaultMaxReplay = positiveInteger(
|
|
498
|
+
options.pubsub?.maxReplaySize ?? defaultMaxQueue,
|
|
499
|
+
'maxReplaySize'
|
|
500
|
+
)
|
|
501
|
+
const initializationTimeout = positiveInteger(
|
|
502
|
+
options.pubsub?.initializationTimeoutMs ?? 10000,
|
|
503
|
+
'initializationTimeoutMs'
|
|
504
|
+
)
|
|
505
|
+
const metric = (event: PubSubMetric) =>
|
|
506
|
+
safely(() => options.pubsub?.onMetric?.(event))
|
|
507
|
+
const namespace = options.pubsub?.namespace
|
|
508
|
+
const qualify = (name: string) => (namespace ? `${namespace}:${name}` : name)
|
|
509
|
+
const assertOpen = () => {
|
|
510
|
+
if (terminated)
|
|
511
|
+
throw new ORPCError('SERVICE_UNAVAILABLE', {
|
|
512
|
+
message: 'Live runtime has shut down'
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
const releaseTransport = (release: (() => Promise<void>) | null) => {
|
|
516
|
+
if (!release) return
|
|
517
|
+
const task = Promise.resolve(release()).catch((error) => {
|
|
518
|
+
logger.warn('Failed to release subscription', {
|
|
519
|
+
error: errorMessageOf(error)
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
releases.add(task)
|
|
523
|
+
void task.finally(() => releases.delete(task))
|
|
524
|
+
}
|
|
525
|
+
|
|
397
526
|
// Deploy drain: every open subscription registers its close here. On
|
|
398
527
|
// SIGTERM the app ends them deliberately instead of letting the socket die
|
|
399
528
|
// with the process: the generator returns, oRPC closes the SSE response and
|
|
400
529
|
// the client's backoff reconnects to the new deployment.
|
|
401
530
|
const activeSubscriberCleanups = new Set<() => void>()
|
|
402
531
|
|
|
532
|
+
let transport: PubSubTransport | undefined
|
|
403
533
|
const requireTransport = () => {
|
|
404
534
|
if (!options.pubsub) {
|
|
405
535
|
throw new Error(
|
|
406
536
|
'orpc-fn: live routes need a transport, pass createFn({ pubsub: { transport } })'
|
|
407
537
|
)
|
|
408
538
|
}
|
|
409
|
-
return
|
|
539
|
+
if (transport) return transport
|
|
540
|
+
// A lazy transport is never created during or after shutdown.
|
|
541
|
+
assertOpen()
|
|
542
|
+
const configured = options.pubsub.transport
|
|
543
|
+
transport = typeof configured === 'function' ? configured() : configured
|
|
544
|
+
return transport
|
|
410
545
|
}
|
|
411
546
|
|
|
412
547
|
/**
|
|
@@ -420,7 +555,14 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
420
555
|
getChannelName: (data: Input) => string
|
|
421
556
|
}
|
|
422
557
|
) {
|
|
423
|
-
const { name, eventSchema, getChannelName
|
|
558
|
+
const { name, eventSchema, getChannelName } = config
|
|
559
|
+
const mirrorChannel = config.mirrorChannel
|
|
560
|
+
? qualify(config.mirrorChannel)
|
|
561
|
+
: undefined
|
|
562
|
+
if (config.backlogSize !== undefined)
|
|
563
|
+
positiveInteger(config.backlogSize, 'backlogSize')
|
|
564
|
+
if (config.backlogTtl !== undefined)
|
|
565
|
+
positiveInteger(config.backlogTtl, 'backlogTtl')
|
|
424
566
|
const backlog: BacklogOptions | undefined = config.useBacklog
|
|
425
567
|
? { size: config.backlogSize ?? 50, ttlSeconds: config.backlogTtl ?? 30 }
|
|
426
568
|
: undefined
|
|
@@ -444,6 +586,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
444
586
|
items: readonly unknown[]
|
|
445
587
|
) =>
|
|
446
588
|
tracing.inSpan(`${name}:${operation}`, 'PRODUCER', async (span) => {
|
|
589
|
+
assertOpen()
|
|
447
590
|
const startTime = performance.now()
|
|
448
591
|
try {
|
|
449
592
|
span?.setAttribute('pubsub.operation', operation)
|
|
@@ -479,7 +622,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
479
622
|
* applying each subscriber's own filter. The hub also owns the broker
|
|
480
623
|
* subscription and its resubscribe backoff.
|
|
481
624
|
*/
|
|
482
|
-
function createChannelHubs(config: {
|
|
625
|
+
function createChannelHubs(config: {
|
|
626
|
+
eventSchema: ZodType
|
|
627
|
+
name: string
|
|
628
|
+
maxIngress: number
|
|
629
|
+
}) {
|
|
483
630
|
const { eventSchema } = config
|
|
484
631
|
const hubs = new Map<string, Hub>()
|
|
485
632
|
|
|
@@ -490,38 +637,85 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
490
637
|
let attempt = 0
|
|
491
638
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
492
639
|
let markReady = () => {}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
640
|
+
let connectedOnce = false
|
|
641
|
+
let connected = false
|
|
642
|
+
let ready: Promise<void>
|
|
643
|
+
const markPending = () => {
|
|
644
|
+
ready = new Promise<void>((resolve) => {
|
|
645
|
+
markReady = resolve
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
markPending()
|
|
500
649
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
650
|
+
let parsing = false
|
|
651
|
+
const ingress = createBoundedEventQueue<string>({
|
|
652
|
+
maxSize: config.maxIngress,
|
|
653
|
+
onDepth: (depth) =>
|
|
654
|
+
metric({
|
|
655
|
+
type: 'queue',
|
|
656
|
+
stage: 'ingress',
|
|
657
|
+
depth,
|
|
658
|
+
limit: config.maxIngress,
|
|
659
|
+
name: config.name,
|
|
660
|
+
channel
|
|
661
|
+
}),
|
|
662
|
+
onDrop: () => {
|
|
663
|
+
metric({
|
|
664
|
+
type: 'drop',
|
|
665
|
+
stage: 'ingress',
|
|
666
|
+
count: 1,
|
|
667
|
+
name: config.name,
|
|
668
|
+
channel
|
|
509
669
|
})
|
|
510
|
-
|
|
670
|
+
safely(() =>
|
|
671
|
+
options.pubsub?.onDrop?.(1, { name: config.name, channel })
|
|
672
|
+
)
|
|
673
|
+
for (const sub of [...subscribers]) {
|
|
674
|
+
try {
|
|
675
|
+
sub.gap()
|
|
676
|
+
} catch (error) {
|
|
677
|
+
safely(() => sub.fail(error))
|
|
678
|
+
}
|
|
679
|
+
}
|
|
511
680
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
681
|
+
})
|
|
682
|
+
const parsePending = async () => {
|
|
683
|
+
if (parsing) return
|
|
684
|
+
parsing = true
|
|
685
|
+
try {
|
|
686
|
+
while (!closed) {
|
|
687
|
+
const raw = ingress.dequeue()
|
|
688
|
+
if (raw === undefined) break
|
|
689
|
+
try {
|
|
690
|
+
const message = decodeMessage(raw)
|
|
691
|
+
const parsed = await eventSchema.parseAsync(message.value)
|
|
692
|
+
if (closed) break
|
|
693
|
+
for (const sub of [...subscribers])
|
|
694
|
+
if (sub.isActive()) {
|
|
695
|
+
try {
|
|
696
|
+
sub.push(parsed, message.id)
|
|
697
|
+
} catch (error) {
|
|
698
|
+
safely(() => sub.fail(error))
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
metric({
|
|
703
|
+
type: 'parseError',
|
|
704
|
+
stage: 'ingress',
|
|
705
|
+
error,
|
|
706
|
+
name: config.name,
|
|
707
|
+
channel
|
|
708
|
+
})
|
|
709
|
+
logger.warn(`Dropped malformed message on ${channel}`, {
|
|
710
|
+
error: errorMessageOf(error)
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
} finally {
|
|
715
|
+
parsing = false
|
|
515
716
|
}
|
|
516
717
|
}
|
|
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
|
-
}
|
|
718
|
+
const release = releaseTransport
|
|
525
719
|
|
|
526
720
|
const scheduleResubscribe = (reason: string) => {
|
|
527
721
|
if (closed || timer) return
|
|
@@ -546,17 +740,49 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
546
740
|
const next = await requireTransport().subscribe(
|
|
547
741
|
channel,
|
|
548
742
|
(raw) => {
|
|
549
|
-
|
|
743
|
+
if (closed) return
|
|
744
|
+
ingress.enqueue(raw)
|
|
745
|
+
void parsePending()
|
|
550
746
|
},
|
|
551
747
|
(error) => {
|
|
748
|
+
if (connected) {
|
|
749
|
+
connected = false
|
|
750
|
+
markPending()
|
|
751
|
+
}
|
|
552
752
|
logger.error(`Subscription to ${channel} lost`, {
|
|
553
753
|
error: error.message
|
|
554
754
|
})
|
|
555
755
|
scheduleResubscribe('lost')
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
onReconnect: () => {
|
|
759
|
+
if (closed) return
|
|
760
|
+
metric({ type: 'reconnect', name: config.name, channel })
|
|
761
|
+
for (const sub of [...subscribers]) {
|
|
762
|
+
try {
|
|
763
|
+
sub.gap()
|
|
764
|
+
} catch (error) {
|
|
765
|
+
safely(() => sub.fail(error))
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
556
769
|
}
|
|
557
770
|
)
|
|
558
771
|
if (closed) return release(next)
|
|
559
772
|
unsubscribe = next
|
|
773
|
+
connected = true
|
|
774
|
+
if (connectedOnce) {
|
|
775
|
+
metric({ type: 'reconnect', name: config.name, channel })
|
|
776
|
+
for (const sub of [...subscribers]) {
|
|
777
|
+
try {
|
|
778
|
+
sub.gap()
|
|
779
|
+
} catch (error) {
|
|
780
|
+
safely(() => sub.fail(error))
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
connectedOnce = true
|
|
785
|
+
markReady()
|
|
560
786
|
attempt = 0
|
|
561
787
|
logger.info(`Subscribed to ${channel}`)
|
|
562
788
|
} catch (error) {
|
|
@@ -564,10 +790,6 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
564
790
|
error: errorMessageOf(error)
|
|
565
791
|
})
|
|
566
792
|
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
793
|
}
|
|
572
794
|
}
|
|
573
795
|
|
|
@@ -575,9 +797,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
575
797
|
|
|
576
798
|
return {
|
|
577
799
|
subscribers,
|
|
578
|
-
ready
|
|
800
|
+
get ready() {
|
|
801
|
+
return ready
|
|
802
|
+
},
|
|
579
803
|
close: () => {
|
|
580
804
|
closed = true
|
|
805
|
+
metric({
|
|
806
|
+
type: 'queue',
|
|
807
|
+
stage: 'ingress',
|
|
808
|
+
depth: 0,
|
|
809
|
+
limit: config.maxIngress,
|
|
810
|
+
name: config.name,
|
|
811
|
+
channel
|
|
812
|
+
})
|
|
581
813
|
if (timer) clearTimeout(timer)
|
|
582
814
|
timer = null
|
|
583
815
|
markReady()
|
|
@@ -624,10 +856,36 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
624
856
|
} = channelOptions
|
|
625
857
|
|
|
626
858
|
const getChannelName = (params: Input, context?: unknown) =>
|
|
627
|
-
resolveChannel(channel, params, context)
|
|
628
|
-
const
|
|
859
|
+
qualify(resolveChannel(channel, params, context))
|
|
860
|
+
const getPublishChannel = (params: Input) =>
|
|
861
|
+
qualify(resolveChannel(channel, params, undefined, true))
|
|
862
|
+
const maxQueue = positiveInteger(
|
|
863
|
+
channelOptions.maxQueueSize ?? defaultMaxQueue,
|
|
864
|
+
'maxQueueSize'
|
|
865
|
+
)
|
|
866
|
+
const maxIngress = positiveInteger(
|
|
867
|
+
channelOptions.maxIngressSize ?? defaultMaxIngress,
|
|
868
|
+
'maxIngressSize'
|
|
869
|
+
)
|
|
870
|
+
const maxReplay = positiveInteger(
|
|
871
|
+
channelOptions.maxReplaySize ?? defaultMaxReplay,
|
|
872
|
+
'maxReplaySize'
|
|
873
|
+
)
|
|
874
|
+
const addLocalSubscriber = createChannelHubs({
|
|
875
|
+
eventSchema,
|
|
876
|
+
name,
|
|
877
|
+
maxIngress
|
|
878
|
+
})
|
|
629
879
|
|
|
630
|
-
const
|
|
880
|
+
const openInternal: Channel['open'] = async ({
|
|
881
|
+
input,
|
|
882
|
+
context,
|
|
883
|
+
signal,
|
|
884
|
+
recoverGaps = false
|
|
885
|
+
}) => {
|
|
886
|
+
assertOpen()
|
|
887
|
+
signal?.throwIfAborted()
|
|
888
|
+
const initializing = new AbortController()
|
|
631
889
|
const transport = requireTransport()
|
|
632
890
|
const channelName = getChannelName(input, context)
|
|
633
891
|
const startTime = performance.now()
|
|
@@ -637,18 +895,33 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
637
895
|
'pubsub.name': name
|
|
638
896
|
})
|
|
639
897
|
const subscriberLogger = createLogger('pubsub', span)
|
|
898
|
+
const cancelInitialization = () =>
|
|
899
|
+
initializing.abort(
|
|
900
|
+
signal?.reason ?? new Error('Subscription initialization stopped')
|
|
901
|
+
)
|
|
902
|
+
pendingInitialization.add(cancelInitialization)
|
|
903
|
+
signal?.addEventListener('abort', cancelInitialization, { once: true })
|
|
904
|
+
const finishInitialization = () => {
|
|
905
|
+
pendingInitialization.delete(cancelInitialization)
|
|
906
|
+
signal?.removeEventListener('abort', cancelInitialization)
|
|
907
|
+
}
|
|
640
908
|
|
|
641
909
|
try {
|
|
642
910
|
if (
|
|
643
911
|
authFn !== undefined &&
|
|
644
912
|
authFn !== true &&
|
|
645
|
-
!(await
|
|
913
|
+
!(await waitFor(
|
|
914
|
+
Promise.resolve(authFn({ input, ctx: context })),
|
|
915
|
+
initializationTimeout,
|
|
916
|
+
initializing.signal
|
|
917
|
+
))
|
|
646
918
|
) {
|
|
647
919
|
throw new ORPCError('FORBIDDEN', {
|
|
648
920
|
message: 'You do not have access to this subscription'
|
|
649
921
|
})
|
|
650
922
|
}
|
|
651
923
|
} catch (error) {
|
|
924
|
+
finishInitialization()
|
|
652
925
|
tracing.end(span, error)
|
|
653
926
|
throw error
|
|
654
927
|
}
|
|
@@ -658,25 +931,107 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
658
931
|
let messageCount = 0
|
|
659
932
|
let droppedCount = 0
|
|
660
933
|
const delivery = createSubscriberDelivery<unknown>({
|
|
661
|
-
maxSize:
|
|
662
|
-
|
|
663
|
-
|
|
934
|
+
maxSize: maxQueue,
|
|
935
|
+
onDepth: (depth) =>
|
|
936
|
+
metric({
|
|
937
|
+
type: 'queue',
|
|
938
|
+
stage: 'delivery',
|
|
939
|
+
depth,
|
|
940
|
+
limit: maxQueue,
|
|
941
|
+
name,
|
|
942
|
+
channel: channelName
|
|
943
|
+
}),
|
|
944
|
+
accept:
|
|
945
|
+
filterFn &&
|
|
946
|
+
((data) => (data === LIVE_GAP ? true : filterFn({ input, data }))),
|
|
947
|
+
overflowMarker: recoverGaps
|
|
948
|
+
? () => LIVE_GAP
|
|
949
|
+
: overflowMarker && (() => overflowMarker(input)),
|
|
664
950
|
onFilterError: (error) =>
|
|
665
951
|
subscriberLogger.error('Error processing message', {
|
|
666
952
|
error: errorMessageOf(error)
|
|
667
953
|
}),
|
|
668
954
|
onDrop: () => {
|
|
669
955
|
droppedCount++
|
|
670
|
-
|
|
956
|
+
metric({
|
|
957
|
+
type: 'drop',
|
|
958
|
+
stage: 'delivery',
|
|
959
|
+
count: 1,
|
|
960
|
+
name,
|
|
961
|
+
channel: channelName
|
|
962
|
+
})
|
|
963
|
+
safely(() =>
|
|
964
|
+
options.pubsub?.onDrop?.(1, { name, channel: channelName })
|
|
965
|
+
)
|
|
966
|
+
if (!recoverGaps && !overflowMarker)
|
|
967
|
+
queueMicrotask(() =>
|
|
968
|
+
close(
|
|
969
|
+
new ORPCError('SERVICE_UNAVAILABLE', {
|
|
970
|
+
message: 'Subscription queue overflow; reconnect and resync'
|
|
971
|
+
})
|
|
972
|
+
)
|
|
973
|
+
)
|
|
671
974
|
}
|
|
672
975
|
})
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
976
|
+
|
|
977
|
+
let replaying: Array<{ data: unknown; id: string | undefined }> | null =
|
|
978
|
+
useBacklog ? [] : null
|
|
979
|
+
let replayGap = false
|
|
980
|
+
const seen = new Set<string>()
|
|
981
|
+
const dedupe = (id: string | undefined) => {
|
|
982
|
+
if (!id) return false
|
|
983
|
+
if (seen.has(id)) return true
|
|
984
|
+
seen.add(id)
|
|
985
|
+
if (
|
|
986
|
+
seen.size >
|
|
987
|
+
Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2
|
|
988
|
+
)
|
|
989
|
+
seen.delete(seen.values().next().value as string)
|
|
990
|
+
return false
|
|
991
|
+
}
|
|
992
|
+
const push = (data: unknown, id?: string) => {
|
|
677
993
|
messageCount++
|
|
678
|
-
if (replaying)
|
|
679
|
-
|
|
994
|
+
if (replaying) {
|
|
995
|
+
if (replaying.length >= maxReplay) {
|
|
996
|
+
replaying.shift()
|
|
997
|
+
replayGap = true
|
|
998
|
+
metric({
|
|
999
|
+
type: 'drop',
|
|
1000
|
+
stage: 'replay',
|
|
1001
|
+
count: 1,
|
|
1002
|
+
name,
|
|
1003
|
+
channel: channelName
|
|
1004
|
+
})
|
|
1005
|
+
safely(() =>
|
|
1006
|
+
options.pubsub?.onDrop?.(1, { name, channel: channelName })
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
replaying.push({ data, id })
|
|
1010
|
+
metric({
|
|
1011
|
+
type: 'queue',
|
|
1012
|
+
stage: 'replay',
|
|
1013
|
+
depth: replaying.length,
|
|
1014
|
+
limit: maxReplay,
|
|
1015
|
+
name,
|
|
1016
|
+
channel: channelName
|
|
1017
|
+
})
|
|
1018
|
+
} else if (!dedupe(id)) delivery.push(data)
|
|
1019
|
+
}
|
|
1020
|
+
const gap = () => {
|
|
1021
|
+
if (replaying) {
|
|
1022
|
+
replayGap = true
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
if (recoverGaps) delivery.push(LIVE_GAP)
|
|
1026
|
+
else if (overflowMarker) {
|
|
1027
|
+
const marker = overflowMarker(input)
|
|
1028
|
+
if (marker !== null) delivery.push(marker, true)
|
|
1029
|
+
} else
|
|
1030
|
+
close(
|
|
1031
|
+
new ORPCError('SERVICE_UNAVAILABLE', {
|
|
1032
|
+
message: 'Subscription lost events; reconnect and resync'
|
|
1033
|
+
})
|
|
1034
|
+
)
|
|
680
1035
|
}
|
|
681
1036
|
|
|
682
1037
|
// The one way a subscription ends, whatever ends it: abort, drain, the
|
|
@@ -686,7 +1041,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
686
1041
|
const close = (...error: [] | [unknown]) => {
|
|
687
1042
|
if (closed) return
|
|
688
1043
|
closed = true
|
|
689
|
-
|
|
1044
|
+
initializing.abort(error[0] ?? new Error('Subscription closed'))
|
|
1045
|
+
delivery.close(error[0])
|
|
690
1046
|
activeSubscriberCleanups.delete(stop)
|
|
691
1047
|
signal?.removeEventListener('abort', stop)
|
|
692
1048
|
local.remove()
|
|
@@ -704,41 +1060,81 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
704
1060
|
const stop = () => close()
|
|
705
1061
|
const local = addLocalSubscriber(channelName, {
|
|
706
1062
|
isActive: () => !closed,
|
|
707
|
-
push
|
|
1063
|
+
push,
|
|
1064
|
+
gap,
|
|
1065
|
+
fail: (error) => close(error)
|
|
708
1066
|
})
|
|
709
1067
|
activeSubscriberCleanups.add(stop)
|
|
710
1068
|
signal?.addEventListener('abort', stop)
|
|
711
1069
|
if (signal?.aborted) close()
|
|
712
1070
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1071
|
+
try {
|
|
1072
|
+
await waitFor(local.ready, initializationTimeout, initializing.signal)
|
|
1073
|
+
if (replaying && !closed) {
|
|
1074
|
+
try {
|
|
1075
|
+
const items = await waitFor(
|
|
1076
|
+
transport.readBacklog(channelName),
|
|
1077
|
+
initializationTimeout,
|
|
1078
|
+
initializing.signal
|
|
1079
|
+
)
|
|
1080
|
+
span?.setAttribute('pubsub.backlog_count', items.length)
|
|
1081
|
+
for (const raw of items.slice(-maxReplay)) {
|
|
1082
|
+
if (closed) break
|
|
1083
|
+
try {
|
|
1084
|
+
const message = decodeMessage(raw)
|
|
1085
|
+
if (!dedupe(message.id)) {
|
|
1086
|
+
messageCount++
|
|
1087
|
+
delivery.push(
|
|
1088
|
+
await waitFor(
|
|
1089
|
+
eventSchema.parseAsync(message.value),
|
|
1090
|
+
initializationTimeout,
|
|
1091
|
+
initializing.signal
|
|
1092
|
+
)
|
|
1093
|
+
)
|
|
1094
|
+
}
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
metric({
|
|
1097
|
+
type: 'parseError',
|
|
1098
|
+
stage: 'replay',
|
|
1099
|
+
error,
|
|
1100
|
+
name,
|
|
1101
|
+
channel: channelName
|
|
1102
|
+
})
|
|
1103
|
+
/* Malformed backlog entries are skipped. */
|
|
1104
|
+
}
|
|
729
1105
|
}
|
|
1106
|
+
if (items.length > maxReplay) replayGap = true
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
replayGap = true
|
|
1109
|
+
subscriberLogger.warn(
|
|
1110
|
+
`Failed to replay backlog for ${channelName}`,
|
|
1111
|
+
{ error: errorMessageOf(error) }
|
|
1112
|
+
)
|
|
730
1113
|
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1114
|
+
}
|
|
1115
|
+
for (const { data, id } of replaying ?? [])
|
|
1116
|
+
if (!dedupe(id)) delivery.push(data)
|
|
1117
|
+
replaying = null
|
|
1118
|
+
metric({
|
|
1119
|
+
type: 'queue',
|
|
1120
|
+
stage: 'replay',
|
|
1121
|
+
depth: 0,
|
|
1122
|
+
limit: maxReplay,
|
|
1123
|
+
name,
|
|
1124
|
+
channel: channelName
|
|
1125
|
+
})
|
|
1126
|
+
if (replayGap && !closed) gap()
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
if (!closed) {
|
|
1129
|
+
close(error)
|
|
1130
|
+
throw new ORPCError('SERVICE_UNAVAILABLE', {
|
|
1131
|
+
message: 'Unable to establish subscription',
|
|
1132
|
+
cause: error
|
|
735
1133
|
})
|
|
736
1134
|
}
|
|
1135
|
+
} finally {
|
|
1136
|
+
finishInitialization()
|
|
737
1137
|
}
|
|
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
1138
|
|
|
743
1139
|
async function* events() {
|
|
744
1140
|
try {
|
|
@@ -748,15 +1144,32 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
748
1144
|
}
|
|
749
1145
|
}
|
|
750
1146
|
|
|
751
|
-
return { events: events(), close: () => close() }
|
|
1147
|
+
return { events: events(), close: () => close(), isClosed: () => closed }
|
|
752
1148
|
}
|
|
753
1149
|
|
|
754
1150
|
const { publish, publishMany } = createChannelPublisher({
|
|
755
1151
|
...channelOptions,
|
|
756
|
-
getChannelName
|
|
1152
|
+
getChannelName: getPublishChannel
|
|
757
1153
|
})
|
|
758
1154
|
|
|
759
|
-
|
|
1155
|
+
const open: Channel['open'] = (params) => {
|
|
1156
|
+
const task = openInternal(params)
|
|
1157
|
+
pendingOpens.add(task)
|
|
1158
|
+
void task.then(
|
|
1159
|
+
() => pendingOpens.delete(task),
|
|
1160
|
+
() => pendingOpens.delete(task)
|
|
1161
|
+
)
|
|
1162
|
+
return task
|
|
1163
|
+
}
|
|
1164
|
+
return {
|
|
1165
|
+
open,
|
|
1166
|
+
publish,
|
|
1167
|
+
publishMany,
|
|
1168
|
+
getChannelName,
|
|
1169
|
+
getSubscriptionChannelName: ({ input, context }) =>
|
|
1170
|
+
getChannelName(input, context),
|
|
1171
|
+
getPublishChannelName: getPublishChannel
|
|
1172
|
+
}
|
|
760
1173
|
}
|
|
761
1174
|
|
|
762
1175
|
function createPubSub(
|
|
@@ -770,31 +1183,57 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
770
1183
|
) {
|
|
771
1184
|
const { name } = pubsubOptions
|
|
772
1185
|
const channel = createChannel(pubsubOptions)
|
|
773
|
-
|
|
1186
|
+
|
|
1187
|
+
const {
|
|
1188
|
+
channel: _channel,
|
|
1189
|
+
eventSchema: _eventSchema,
|
|
1190
|
+
inputSchema,
|
|
1191
|
+
filterFn: _filterFn,
|
|
1192
|
+
authFn: _authFn,
|
|
1193
|
+
overflowMarker: _overflowMarker,
|
|
1194
|
+
useBacklog: _useBacklog,
|
|
1195
|
+
backlogSize: _backlogSize,
|
|
1196
|
+
backlogTtl: _backlogTtl,
|
|
1197
|
+
mirrorChannel: _mirrorChannel,
|
|
1198
|
+
maxQueueSize: _maxQueueSize,
|
|
1199
|
+
maxIngressSize: _maxIngressSize,
|
|
1200
|
+
maxReplaySize: _maxReplaySize,
|
|
1201
|
+
...routeOptions
|
|
1202
|
+
} = pubsubOptions
|
|
1203
|
+
const subscribe = options.fn({
|
|
1204
|
+
...routeOptions,
|
|
774
1205
|
name,
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1206
|
+
stream: true,
|
|
1207
|
+
method: 'GET',
|
|
1208
|
+
input: inputSchema,
|
|
1209
|
+
summary: pubsubOptions.summary ?? `Subscribe to ${name}`,
|
|
1210
|
+
handler: async function* ({
|
|
1211
|
+
input,
|
|
1212
|
+
context,
|
|
1213
|
+
signal
|
|
1214
|
+
}: {
|
|
1215
|
+
input: Input
|
|
1216
|
+
context: unknown
|
|
1217
|
+
signal: AbortSignal | undefined
|
|
1218
|
+
}) {
|
|
1219
|
+
yield* (await channel.open({ input, context, signal })).events
|
|
788
1220
|
}
|
|
789
1221
|
})
|
|
790
1222
|
|
|
791
1223
|
const { publish, publishMany, getChannelName } = channel
|
|
792
|
-
return {
|
|
1224
|
+
return {
|
|
1225
|
+
subscribe,
|
|
1226
|
+
publish,
|
|
1227
|
+
publishMany,
|
|
1228
|
+
getChannelName,
|
|
1229
|
+
getSubscriptionChannelName: channel.getSubscriptionChannelName,
|
|
1230
|
+
getPublishChannelName: channel.getPublishChannelName
|
|
1231
|
+
}
|
|
793
1232
|
}
|
|
794
1233
|
|
|
795
1234
|
function createPublisher(publisherOptions: PublisherOptions<ZodType>) {
|
|
796
1235
|
const getChannelName = (params: Input) =>
|
|
797
|
-
resolveChannel(publisherOptions.channel, params)
|
|
1236
|
+
qualify(resolveChannel(publisherOptions.channel, params, undefined, true))
|
|
798
1237
|
const { publish, publishMany } = createChannelPublisher({
|
|
799
1238
|
...publisherOptions,
|
|
800
1239
|
getChannelName
|
|
@@ -804,6 +1243,18 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
804
1243
|
|
|
805
1244
|
return {
|
|
806
1245
|
createChannel,
|
|
1246
|
+
shutdown: () => {
|
|
1247
|
+
if (shutdownPromise) return shutdownPromise
|
|
1248
|
+
terminated = true
|
|
1249
|
+
for (const cancel of [...pendingInitialization]) safely(cancel)
|
|
1250
|
+
for (const cleanup of [...activeSubscriberCleanups]) safely(cleanup)
|
|
1251
|
+
shutdownPromise = (async () => {
|
|
1252
|
+
await Promise.allSettled([...pendingOpens])
|
|
1253
|
+
await Promise.all([...releases])
|
|
1254
|
+
if (options.pubsub?.ownsTransport) await transport?.close?.()
|
|
1255
|
+
})()
|
|
1256
|
+
return shutdownPromise
|
|
1257
|
+
},
|
|
807
1258
|
createPubSub,
|
|
808
1259
|
createPublisher,
|
|
809
1260
|
/** Open subscriptions of this instance (drain progress, gauges). */
|