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/fn-live.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { Schema } from '@orpc/contract'
|
|
1
|
+
import type { AnySchema, ErrorMap, Schema } from '@orpc/contract'
|
|
2
2
|
import { type AnyProcedure, ORPCError } from '@orpc/server'
|
|
3
3
|
import type { ZodType, z } from 'zod'
|
|
4
|
+
import { RAW_INPUT } from '../timing.js'
|
|
5
|
+
import { LIVE_GAP, positiveInteger } from './lifecycle.js'
|
|
4
6
|
import type { FnLogger } from '../logger.js'
|
|
5
7
|
import { errorMessageOf, type SpanLike } from '../otel.js'
|
|
6
8
|
import type {
|
|
@@ -17,6 +19,7 @@ import type {
|
|
|
17
19
|
Channel,
|
|
18
20
|
ChannelDefinition,
|
|
19
21
|
ChannelOptions,
|
|
22
|
+
ChannelQueueOptions,
|
|
20
23
|
ObjectSchema,
|
|
21
24
|
PublisherConfig
|
|
22
25
|
} from './pub-sub.js'
|
|
@@ -57,45 +60,88 @@ export type FnLiveConfig<
|
|
|
57
60
|
TEventSchema extends ZodType,
|
|
58
61
|
TOutput,
|
|
59
62
|
TKey extends ProcedureKey<TDef>,
|
|
60
|
-
TEmit = never
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
63
|
+
TEmit = never,
|
|
64
|
+
TErrors extends ErrorMap = Record<never, never>
|
|
65
|
+
> = Omit<PublisherConfig, 'name'> &
|
|
66
|
+
ChannelQueueOptions & {
|
|
67
|
+
/** Subscribe route name; default `${name}.live`. */
|
|
68
|
+
name?: string
|
|
69
|
+
/** Validates parsed reducer state without reapplying an output transform. Required with a transformer. */
|
|
70
|
+
stateSchema?: Schema<TOutput, TOutput>
|
|
71
|
+
/** Validates patch payloads. Required when a transformer returns fnLivePatch. */
|
|
72
|
+
emitSchema?: Schema<TEmit, TEmit>
|
|
73
|
+
/** Optional access check before each update; idle streams keep their initial authorization. */
|
|
74
|
+
reauthorize?: AuthFn<TInput, FnContext<TDef, TKey>>
|
|
75
|
+
eventSchema: TEventSchema
|
|
76
|
+
channel: ChannelDefinition<
|
|
77
|
+
TInput,
|
|
78
|
+
z.output<TEventSchema>,
|
|
79
|
+
FnContext<TDef, TKey>
|
|
80
|
+
>
|
|
81
|
+
/** Fold an event into the snapshot; without it every event re-runs the handler. */
|
|
82
|
+
transformerFn?: FnLiveConfigTransformer<
|
|
83
|
+
TDef,
|
|
84
|
+
TInput,
|
|
85
|
+
TEventSchema,
|
|
86
|
+
TOutput,
|
|
87
|
+
TKey,
|
|
88
|
+
TEmit,
|
|
89
|
+
TErrors
|
|
90
|
+
>
|
|
91
|
+
/** Return false to skip an event. */
|
|
92
|
+
shouldUpdate?: (
|
|
93
|
+
params: HandlerParams<TDef, TInput, TKey, TErrors> & {
|
|
94
|
+
event: z.output<TEventSchema>
|
|
95
|
+
previous: TOutput
|
|
96
|
+
}
|
|
97
|
+
) => MaybePromise<boolean>
|
|
98
|
+
authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
|
|
99
|
+
/** Log publish failures instead of throwing them into the publishing route. */
|
|
100
|
+
safePublish?: boolean
|
|
101
|
+
/** Batch events arriving within this window into one snapshot update. */
|
|
102
|
+
coalesceMs?: number
|
|
103
|
+
/** Maximum events collected in one coalescing batch (default 1000). */
|
|
104
|
+
maxBatchSize?: number
|
|
105
|
+
/**
|
|
106
|
+
* When a slow subscriber's queue overflows (drop-oldest), this marker is
|
|
107
|
+
* enqueued INSTEAD of the dropped event so the client resyncs. Return
|
|
108
|
+
* `null` to skip.
|
|
109
|
+
*/
|
|
110
|
+
overflowMarker?: (input: TInput) => z.output<TEventSchema> | null
|
|
111
|
+
summary?: string
|
|
112
|
+
description?: string
|
|
113
|
+
} & (
|
|
114
|
+
| { transformerFn?: undefined }
|
|
115
|
+
| {
|
|
116
|
+
transformerFn: FnLiveConfigTransformer<
|
|
117
|
+
TDef,
|
|
118
|
+
TInput,
|
|
119
|
+
TEventSchema,
|
|
120
|
+
TOutput,
|
|
121
|
+
TKey,
|
|
122
|
+
TEmit,
|
|
123
|
+
TErrors
|
|
124
|
+
>
|
|
125
|
+
stateSchema: Schema<TOutput, TOutput>
|
|
126
|
+
}
|
|
127
|
+
) &
|
|
128
|
+
([TEmit] extends [never] ? object : { emitSchema: Schema<TEmit, TEmit> })
|
|
129
|
+
|
|
130
|
+
type FnLiveConfigTransformer<
|
|
131
|
+
TDef extends FnDefinition,
|
|
132
|
+
TInput,
|
|
133
|
+
TEventSchema extends ZodType,
|
|
134
|
+
TOutput,
|
|
135
|
+
TKey extends ProcedureKey<TDef>,
|
|
136
|
+
TEmit,
|
|
137
|
+
TErrors extends ErrorMap
|
|
138
|
+
> = (
|
|
139
|
+
params: HandlerParams<TDef, TInput, TKey, TErrors> & {
|
|
140
|
+
event: z.output<TEventSchema>
|
|
141
|
+
previous: TOutput
|
|
142
|
+
rerun: () => Promise<TOutput>
|
|
143
|
+
}
|
|
144
|
+
) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
|
|
99
145
|
|
|
100
146
|
export type FnLiveReturn<
|
|
101
147
|
TDef extends FnDefinition,
|
|
@@ -104,16 +150,24 @@ export type FnLiveReturn<
|
|
|
104
150
|
TOutputSchema extends Schema<unknown, unknown>,
|
|
105
151
|
TOutput,
|
|
106
152
|
TEventSchema extends ZodType,
|
|
107
|
-
TEmit = never
|
|
153
|
+
TEmit = never,
|
|
154
|
+
TErrors extends ErrorMap = Record<never, never>
|
|
108
155
|
> = {
|
|
109
|
-
procedure: FnProcedure<TDef, TKey, TInput, TOutputSchema>
|
|
156
|
+
procedure: FnProcedure<TDef, TKey, TInput, TOutputSchema, TErrors>
|
|
110
157
|
subscribe: FnProcedure<
|
|
111
158
|
TDef,
|
|
112
159
|
TKey,
|
|
113
160
|
TInput,
|
|
114
|
-
Schema<AsyncGenerator<TOutput | TEmit>, AsyncGenerator<TOutput | TEmit
|
|
161
|
+
Schema<AsyncGenerator<TOutput | TEmit>, AsyncGenerator<TOutput | TEmit>>,
|
|
162
|
+
TErrors
|
|
115
163
|
>
|
|
116
164
|
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
165
|
+
getSubscriptionChannelName: (params: {
|
|
166
|
+
input: z.output<TInput>
|
|
167
|
+
context: FnContext<TDef, TKey>
|
|
168
|
+
}) => string
|
|
169
|
+
getPublishChannelName: (event: z.output<TEventSchema>) => string
|
|
170
|
+
/** @deprecated Prefer the separate required-input resolver methods. */
|
|
117
171
|
getChannelName: (
|
|
118
172
|
params: Partial<z.output<TInput>> | Partial<z.output<TEventSchema>>,
|
|
119
173
|
context?: FnContext<TDef, TKey>
|
|
@@ -130,13 +184,19 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
130
184
|
TOutputSchema extends ZodType,
|
|
131
185
|
TEventSchema extends ZodType,
|
|
132
186
|
TKey extends ProcedureKey<TDef> = TDef['default'],
|
|
133
|
-
TEmit = never
|
|
187
|
+
TEmit = never,
|
|
188
|
+
TErrors extends ErrorMap = Record<never, never>
|
|
134
189
|
>(
|
|
135
190
|
options: {
|
|
136
191
|
input: TInput
|
|
137
192
|
output: TOutputSchema
|
|
138
193
|
handler: (
|
|
139
|
-
params: HandlerParams<
|
|
194
|
+
params: HandlerParams<
|
|
195
|
+
TDef,
|
|
196
|
+
z.output<TInput>,
|
|
197
|
+
NoInfer<TKey>,
|
|
198
|
+
TErrors
|
|
199
|
+
> & {
|
|
140
200
|
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
141
201
|
}
|
|
142
202
|
// The output schema parses what the handler returns: its input type.
|
|
@@ -147,9 +207,10 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
147
207
|
TEventSchema,
|
|
148
208
|
z.output<TOutputSchema>,
|
|
149
209
|
NoInfer<TKey>,
|
|
150
|
-
TEmit
|
|
210
|
+
TEmit,
|
|
211
|
+
TErrors
|
|
151
212
|
>
|
|
152
|
-
} & FnRouteOptions<TDef, TKey>
|
|
213
|
+
} & FnRouteOptions<TDef, TKey, z.output<TInput>, TErrors>
|
|
153
214
|
): FnLiveReturn<
|
|
154
215
|
TDef,
|
|
155
216
|
TKey,
|
|
@@ -157,7 +218,8 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
157
218
|
TOutputSchema,
|
|
158
219
|
z.output<TOutputSchema>,
|
|
159
220
|
TEventSchema,
|
|
160
|
-
TEmit
|
|
221
|
+
TEmit,
|
|
222
|
+
TErrors
|
|
161
223
|
>
|
|
162
224
|
|
|
163
225
|
<
|
|
@@ -165,13 +227,19 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
165
227
|
TOutput,
|
|
166
228
|
TEventSchema extends ZodType,
|
|
167
229
|
TKey extends ProcedureKey<TDef> = TDef['default'],
|
|
168
|
-
TEmit = never
|
|
230
|
+
TEmit = never,
|
|
231
|
+
TErrors extends ErrorMap = Record<never, never>
|
|
169
232
|
>(
|
|
170
233
|
options: {
|
|
171
234
|
input: TInput
|
|
172
235
|
output?: undefined
|
|
173
236
|
handler: (
|
|
174
|
-
params: HandlerParams<
|
|
237
|
+
params: HandlerParams<
|
|
238
|
+
TDef,
|
|
239
|
+
z.output<TInput>,
|
|
240
|
+
NoInfer<TKey>,
|
|
241
|
+
TErrors
|
|
242
|
+
> & {
|
|
175
243
|
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
176
244
|
}
|
|
177
245
|
) => MaybePromise<TOutput>
|
|
@@ -181,9 +249,10 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
181
249
|
TEventSchema,
|
|
182
250
|
TOutput,
|
|
183
251
|
NoInfer<TKey>,
|
|
184
|
-
TEmit
|
|
252
|
+
TEmit,
|
|
253
|
+
TErrors
|
|
185
254
|
>
|
|
186
|
-
} & FnRouteOptions<TDef, TKey>
|
|
255
|
+
} & FnRouteOptions<TDef, TKey, z.output<TInput>, TErrors>
|
|
187
256
|
): FnLiveReturn<
|
|
188
257
|
TDef,
|
|
189
258
|
TKey,
|
|
@@ -191,7 +260,8 @@ export interface FnLive<TDef extends FnDefinition> {
|
|
|
191
260
|
Schema<TOutput, TOutput>,
|
|
192
261
|
TOutput,
|
|
193
262
|
TEventSchema,
|
|
194
|
-
TEmit
|
|
263
|
+
TEmit,
|
|
264
|
+
TErrors
|
|
195
265
|
>
|
|
196
266
|
}
|
|
197
267
|
|
|
@@ -253,6 +323,12 @@ export async function* streamLiveSnapshots<
|
|
|
253
323
|
TEmit = never
|
|
254
324
|
>(opts: {
|
|
255
325
|
source: AsyncIterator<TEvent>
|
|
326
|
+
/** Maximum events retained in one coalescing batch. */
|
|
327
|
+
maxBatchSize?: number
|
|
328
|
+
applyBatch?: (
|
|
329
|
+
events: readonly TEvent[],
|
|
330
|
+
previous: TOutput
|
|
331
|
+
) => MaybePromise<TOutput | undefined>
|
|
256
332
|
initial: TOutput
|
|
257
333
|
coalesceMs?: number | undefined
|
|
258
334
|
apply: (
|
|
@@ -261,6 +337,15 @@ export async function* streamLiveSnapshots<
|
|
|
261
337
|
) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
|
|
262
338
|
}): AsyncGenerator<TOutput | TEmit> {
|
|
263
339
|
const { source, initial, coalesceMs, apply } = opts
|
|
340
|
+
const maxBatchSize = positiveInteger(
|
|
341
|
+
opts.maxBatchSize ?? 1000,
|
|
342
|
+
'maxBatchSize'
|
|
343
|
+
)
|
|
344
|
+
if (
|
|
345
|
+
coalesceMs !== undefined &&
|
|
346
|
+
(!Number.isFinite(coalesceMs) || coalesceMs < 0)
|
|
347
|
+
)
|
|
348
|
+
throw new RangeError('orpc-fn: coalesceMs must be finite and nonnegative')
|
|
264
349
|
|
|
265
350
|
let pending: Promise<IteratorResult<TEvent>> | null = null
|
|
266
351
|
const readNext = () => {
|
|
@@ -280,7 +365,7 @@ export async function* streamLiveSnapshots<
|
|
|
280
365
|
|
|
281
366
|
if (coalesceMs) {
|
|
282
367
|
const endAt = Date.now() + coalesceMs
|
|
283
|
-
while (Date.now() < endAt) {
|
|
368
|
+
while (Date.now() < endAt && batch.length < maxBatchSize) {
|
|
284
369
|
const remainingMs = endAt - Date.now()
|
|
285
370
|
const read = readNext()
|
|
286
371
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -307,17 +392,31 @@ export async function* streamLiveSnapshots<
|
|
|
307
392
|
}
|
|
308
393
|
}
|
|
309
394
|
|
|
395
|
+
if (opts.applyBatch) {
|
|
396
|
+
const next = await opts.applyBatch(batch, previous)
|
|
397
|
+
if (next !== undefined) {
|
|
398
|
+
previous = next
|
|
399
|
+
yield previous
|
|
400
|
+
}
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
let changed = false
|
|
310
404
|
for (const event of batch) {
|
|
311
405
|
const next = await apply(event, previous)
|
|
312
406
|
if (next === undefined) continue
|
|
313
407
|
if (isFnLivePatch(next)) {
|
|
408
|
+
if (changed) {
|
|
409
|
+
yield previous
|
|
410
|
+
changed = false
|
|
411
|
+
}
|
|
314
412
|
previous = next.state as TOutput
|
|
315
413
|
yield next.emit as TEmit
|
|
316
414
|
continue
|
|
317
415
|
}
|
|
318
416
|
previous = next
|
|
319
|
-
|
|
417
|
+
changed = true
|
|
320
418
|
}
|
|
419
|
+
if (changed) yield previous
|
|
321
420
|
}
|
|
322
421
|
}
|
|
323
422
|
|
|
@@ -326,15 +425,21 @@ type RuntimeParams = Record<string, unknown> & {
|
|
|
326
425
|
context: unknown
|
|
327
426
|
signal: AbortSignal | undefined
|
|
328
427
|
logger: FnLogger
|
|
428
|
+
call: (procedure: AnyProcedure, input: unknown) => Promise<unknown>
|
|
429
|
+
[RAW_INPUT]: unknown
|
|
329
430
|
}
|
|
330
431
|
|
|
331
432
|
/** `FnLiveConfig` with the generics erased: what the implementation reads. */
|
|
332
433
|
type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
|
|
333
434
|
name?: string
|
|
435
|
+
stateSchema?: AnySchema
|
|
436
|
+
emitSchema?: AnySchema
|
|
437
|
+
reauthorize?: AuthFn<Record<string, unknown>, unknown>
|
|
334
438
|
transformerFn?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
335
439
|
shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
336
440
|
safePublish?: boolean
|
|
337
441
|
coalesceMs?: number
|
|
442
|
+
maxBatchSize?: number
|
|
338
443
|
summary?: string
|
|
339
444
|
description?: string
|
|
340
445
|
}
|
|
@@ -367,6 +472,17 @@ export function createFnLive(runtime: {
|
|
|
367
472
|
} & Record<string, unknown>
|
|
368
473
|
) => {
|
|
369
474
|
const { live, handler, input, output, name, ...routeConfig } = options
|
|
475
|
+
if (live.transformerFn && !live.stateSchema)
|
|
476
|
+
throw new TypeError(
|
|
477
|
+
'orpc-fn: live transformers require a stateSchema for parsed state'
|
|
478
|
+
)
|
|
479
|
+
if (
|
|
480
|
+
live.coalesceMs !== undefined &&
|
|
481
|
+
(!Number.isFinite(live.coalesceMs) || live.coalesceMs < 0)
|
|
482
|
+
)
|
|
483
|
+
throw new RangeError('orpc-fn: coalesceMs must be finite and nonnegative')
|
|
484
|
+
if (live.maxBatchSize !== undefined)
|
|
485
|
+
positiveInteger(live.maxBatchSize, 'maxBatchSize')
|
|
370
486
|
const liveName = live.name ?? `${name}.live`
|
|
371
487
|
const liveSummary =
|
|
372
488
|
live.summary ?? `Live updates of ${routeConfig.summary ?? name}`
|
|
@@ -391,11 +507,6 @@ export function createFnLive(runtime: {
|
|
|
391
507
|
|
|
392
508
|
const runHandler = (params: RuntimeParams) =>
|
|
393
509
|
handler({ ...params, publish })
|
|
394
|
-
// Snapshots go through the output schema like the route's own result.
|
|
395
|
-
const runSnapshot = async (params: RuntimeParams) => {
|
|
396
|
-
const result = await runHandler(params)
|
|
397
|
-
return output ? output.parseAsync(result) : result
|
|
398
|
-
}
|
|
399
510
|
|
|
400
511
|
const procedure = fn({
|
|
401
512
|
...routeConfig,
|
|
@@ -405,12 +516,28 @@ export function createFnLive(runtime: {
|
|
|
405
516
|
handler: runHandler
|
|
406
517
|
})
|
|
407
518
|
|
|
408
|
-
|
|
519
|
+
const validate = async (schema: AnySchema, value: unknown) => {
|
|
520
|
+
const result = await schema['~standard'].validate(value)
|
|
521
|
+
if (result.issues)
|
|
522
|
+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
523
|
+
message: 'Invalid live state or patch',
|
|
524
|
+
cause: result.issues
|
|
525
|
+
})
|
|
526
|
+
return result.value
|
|
527
|
+
}
|
|
528
|
+
const runSnapshot = (params: RuntimeParams) =>
|
|
529
|
+
params.call(procedure, params[RAW_INPUT])
|
|
530
|
+
// Generated streams inherit access policies and metadata; tool adapters reject streams.
|
|
531
|
+
|
|
409
532
|
const guardOptions = Object.fromEntries(
|
|
410
533
|
Object.entries(routeConfig).filter(([option]) => isGuard(option))
|
|
411
534
|
)
|
|
412
535
|
const subscribe = fn({
|
|
413
536
|
...guardOptions,
|
|
537
|
+
guardResolvers: routeConfig.guardResolvers,
|
|
538
|
+
errors: routeConfig.errors,
|
|
539
|
+
meta: routeConfig.meta,
|
|
540
|
+
stream: true,
|
|
414
541
|
procedure: routeConfig.procedure,
|
|
415
542
|
tags: routeConfig.tags,
|
|
416
543
|
name: liveName,
|
|
@@ -424,9 +551,11 @@ export function createFnLive(runtime: {
|
|
|
424
551
|
const subscription = await channel.open({
|
|
425
552
|
input: params.input,
|
|
426
553
|
context: params.context,
|
|
427
|
-
signal: params.signal
|
|
554
|
+
signal: params.signal,
|
|
555
|
+
recoverGaps: true
|
|
428
556
|
})
|
|
429
557
|
try {
|
|
558
|
+
if (subscription.isClosed()) return
|
|
430
559
|
let initial: unknown
|
|
431
560
|
try {
|
|
432
561
|
initial = await runSnapshot(params)
|
|
@@ -435,24 +564,68 @@ export function createFnLive(runtime: {
|
|
|
435
564
|
throw toSnapshotError(err, liveName)
|
|
436
565
|
}
|
|
437
566
|
|
|
438
|
-
const rerun = (
|
|
439
|
-
|
|
567
|
+
const rerun = () => runSnapshot(params)
|
|
568
|
+
const authorize = async () => {
|
|
569
|
+
if (
|
|
570
|
+
live.reauthorize &&
|
|
571
|
+
live.reauthorize !== true &&
|
|
572
|
+
!(await live.reauthorize({
|
|
573
|
+
input: params.input,
|
|
574
|
+
ctx: params.context
|
|
575
|
+
}))
|
|
576
|
+
)
|
|
577
|
+
throw new ORPCError('FORBIDDEN')
|
|
578
|
+
}
|
|
440
579
|
const apply = async (event: unknown, previous: unknown) => {
|
|
580
|
+
await authorize()
|
|
581
|
+
if (event === LIVE_GAP) return rerun()
|
|
441
582
|
if (
|
|
442
583
|
live.shouldUpdate &&
|
|
443
584
|
!(await live.shouldUpdate({ ...params, event, previous }))
|
|
444
|
-
)
|
|
585
|
+
)
|
|
445
586
|
return undefined
|
|
587
|
+
if (!live.transformerFn) return rerun()
|
|
588
|
+
const next = await live.transformerFn({
|
|
589
|
+
...params,
|
|
590
|
+
event,
|
|
591
|
+
previous,
|
|
592
|
+
rerun
|
|
593
|
+
})
|
|
594
|
+
if (next === undefined) return undefined
|
|
595
|
+
if (isFnLivePatch(next)) {
|
|
596
|
+
if (!live.emitSchema)
|
|
597
|
+
throw new TypeError('orpc-fn: live patches require emitSchema')
|
|
598
|
+
return fnLivePatch(
|
|
599
|
+
await validate(live.stateSchema as AnySchema, next.state),
|
|
600
|
+
await validate(live.emitSchema, next.emit)
|
|
601
|
+
)
|
|
446
602
|
}
|
|
447
|
-
|
|
448
|
-
return live.transformerFn({ ...params, event, previous, rerun })
|
|
603
|
+
return validate(live.stateSchema as AnySchema, next)
|
|
449
604
|
}
|
|
605
|
+
const applyBatch = live.transformerFn
|
|
606
|
+
? undefined
|
|
607
|
+
: async (events: readonly unknown[], previous: unknown) => {
|
|
608
|
+
await authorize()
|
|
609
|
+
let invalidated = false
|
|
610
|
+
for (const event of events)
|
|
611
|
+
if (
|
|
612
|
+
event === LIVE_GAP ||
|
|
613
|
+
!live.shouldUpdate ||
|
|
614
|
+
(await live.shouldUpdate({ ...params, event, previous }))
|
|
615
|
+
)
|
|
616
|
+
invalidated = true
|
|
617
|
+
return invalidated ? rerun() : undefined
|
|
618
|
+
}
|
|
450
619
|
|
|
451
620
|
yield* streamLiveSnapshots({
|
|
452
621
|
source: subscription.events,
|
|
453
622
|
initial,
|
|
454
623
|
coalesceMs: live.coalesceMs,
|
|
455
|
-
|
|
624
|
+
...(live.maxBatchSize !== undefined
|
|
625
|
+
? { maxBatchSize: live.maxBatchSize }
|
|
626
|
+
: {}),
|
|
627
|
+
apply,
|
|
628
|
+
...(applyBatch ? { applyBatch } : {})
|
|
456
629
|
})
|
|
457
630
|
} finally {
|
|
458
631
|
subscription.close()
|
|
@@ -464,7 +637,9 @@ export function createFnLive(runtime: {
|
|
|
464
637
|
procedure,
|
|
465
638
|
subscribe,
|
|
466
639
|
publish,
|
|
467
|
-
getChannelName: channel.getChannelName
|
|
640
|
+
getChannelName: channel.getChannelName,
|
|
641
|
+
getSubscriptionChannelName: channel.getSubscriptionChannelName,
|
|
642
|
+
getPublishChannelName: channel.getPublishChannelName
|
|
468
643
|
}
|
|
469
644
|
}
|
|
470
645
|
}
|
package/src/live/index.ts
CHANGED
package/src/live/ioredis.ts
CHANGED
|
@@ -6,17 +6,20 @@ export interface IORedisLike {
|
|
|
6
6
|
subscribe(...channels: string[]): Promise<unknown>
|
|
7
7
|
unsubscribe(...channels: string[]): Promise<unknown>
|
|
8
8
|
duplicate(): IORedisLike
|
|
9
|
+
status?: string
|
|
9
10
|
disconnect(): void
|
|
10
11
|
on(
|
|
11
12
|
event: 'message',
|
|
12
13
|
listener: (channel: string, message: string) => void
|
|
13
14
|
): unknown
|
|
15
|
+
on(event: 'ready', listener: () => void): unknown
|
|
14
16
|
on(event: 'end', listener: () => void): unknown
|
|
15
17
|
on(event: 'error', listener: (error: Error) => void): unknown
|
|
16
18
|
off(
|
|
17
19
|
event: 'message',
|
|
18
20
|
listener: (channel: string, message: string) => void
|
|
19
21
|
): unknown
|
|
22
|
+
off(event: 'ready', listener: () => void): unknown
|
|
20
23
|
off(event: 'end', listener: () => void): unknown
|
|
21
24
|
off(event: 'error', listener: (error: Error) => void): unknown
|
|
22
25
|
}
|
|
@@ -41,11 +44,23 @@ export function ioredisTransport(
|
|
|
41
44
|
const onMessage = (channel: string, message: string) =>
|
|
42
45
|
handlers.onMessage(channel, message)
|
|
43
46
|
// Without a listener an ioredis 'error' event would crash the process.
|
|
44
|
-
const onError = (error: Error) =>
|
|
47
|
+
const onError = (error: Error) => {
|
|
48
|
+
try {
|
|
49
|
+
options.onError?.(error)
|
|
50
|
+
} catch {
|
|
51
|
+
/* Isolate telemetry. */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let readyOnce = subscriber.status === 'ready'
|
|
55
|
+
const onReady = () => {
|
|
56
|
+
if (readyOnce) handlers.onReconnect()
|
|
57
|
+
readyOnce = true
|
|
58
|
+
}
|
|
45
59
|
// 'end' means ioredis stopped reconnecting.
|
|
46
60
|
const onEnd = () =>
|
|
47
61
|
handlers.onLost(new Error('Redis subscriber connection ended'))
|
|
48
62
|
const ours = new Set<string>()
|
|
63
|
+
subscriber.on('ready', onReady)
|
|
49
64
|
subscriber.on('message', onMessage)
|
|
50
65
|
subscriber.on('error', onError)
|
|
51
66
|
subscriber.on('end', onEnd)
|
|
@@ -54,18 +69,23 @@ export function ioredisTransport(
|
|
|
54
69
|
ours.add(channel)
|
|
55
70
|
return subscriber.subscribe(channel)
|
|
56
71
|
},
|
|
72
|
+
resubscribe: (channel: string) => subscriber.subscribe(channel),
|
|
57
73
|
unsubscribe: (channel) => {
|
|
58
74
|
ours.delete(channel)
|
|
59
75
|
return subscriber.unsubscribe(channel)
|
|
60
76
|
},
|
|
61
77
|
close() {
|
|
62
78
|
// Detach first: a given subscriber outlives this transport's use.
|
|
79
|
+
subscriber.off('ready', onReady)
|
|
63
80
|
subscriber.off('message', onMessage)
|
|
64
81
|
subscriber.off('end', onEnd)
|
|
65
82
|
if (owned) return subscriber.disconnect()
|
|
66
83
|
subscriber.off('error', onError)
|
|
67
84
|
if (ours.size > 0) {
|
|
68
|
-
|
|
85
|
+
return subscriber
|
|
86
|
+
.unsubscribe(...ours)
|
|
87
|
+
.then(() => {})
|
|
88
|
+
.catch(() => {})
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Internal control message: live queries reload after loss; raw streams fail. */
|
|
2
|
+
export const LIVE_GAP = Symbol('orpc-fn.liveGap')
|
|
3
|
+
|
|
4
|
+
export function positiveInteger(value: number, name: string): number {
|
|
5
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
6
|
+
throw new RangeError(`orpc-fn: ${name} must be a positive safe integer`)
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function safely(callback: (() => unknown) | undefined): void {
|
|
11
|
+
try {
|
|
12
|
+
callback?.()
|
|
13
|
+
} catch {
|
|
14
|
+
/* Observability must not disrupt delivery. */
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Abort and timeout only stop waiting; callers release late transport results. */
|
|
19
|
+
export async function waitFor<T>(
|
|
20
|
+
promise: Promise<T>,
|
|
21
|
+
timeoutMs: number,
|
|
22
|
+
signal: AbortSignal
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
signal.throwIfAborted()
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
let abort = () => {}
|
|
27
|
+
try {
|
|
28
|
+
return await Promise.race([
|
|
29
|
+
promise,
|
|
30
|
+
new Promise<never>((_resolve, reject) => {
|
|
31
|
+
abort = () => reject(signal.reason)
|
|
32
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
33
|
+
timer = setTimeout(
|
|
34
|
+
() =>
|
|
35
|
+
reject(new Error('orpc-fn: subscription initialization timed out')),
|
|
36
|
+
timeoutMs
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
])
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(timer)
|
|
42
|
+
signal.removeEventListener('abort', abort)
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/live/memory.ts
CHANGED
|
@@ -12,6 +12,10 @@ export function memoryTransport(): PubSubTransport & {
|
|
|
12
12
|
const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
|
|
13
13
|
|
|
14
14
|
return {
|
|
15
|
+
async close() {
|
|
16
|
+
listeners.clear()
|
|
17
|
+
backlogs.clear()
|
|
18
|
+
},
|
|
15
19
|
async publish(messages, backlog) {
|
|
16
20
|
for (const { channel, payload } of messages) {
|
|
17
21
|
if (backlog) {
|