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.
Files changed (141) hide show
  1. package/README.md +48 -32
  2. package/dist/assemble-procedure.d.ts +13 -0
  3. package/dist/assemble-procedure.d.ts.map +1 -0
  4. package/dist/assemble-procedure.js +29 -0
  5. package/dist/assemble-procedure.js.map +1 -0
  6. package/dist/bound-call.d.ts +1 -1
  7. package/dist/bound-call.d.ts.map +1 -1
  8. package/dist/bound-call.js.map +1 -1
  9. package/dist/client.d.ts +4 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/compatibility.d.ts +16 -0
  14. package/dist/compatibility.d.ts.map +1 -0
  15. package/dist/compatibility.js +4 -0
  16. package/dist/compatibility.js.map +1 -0
  17. package/dist/create-fn.d.ts +30 -34
  18. package/dist/create-fn.d.ts.map +1 -1
  19. package/dist/create-fn.js +177 -121
  20. package/dist/create-fn.js.map +1 -1
  21. package/dist/expo.d.ts +11 -25
  22. package/dist/expo.d.ts.map +1 -1
  23. package/dist/expo.js +25 -30
  24. package/dist/expo.js.map +1 -1
  25. package/dist/hono.d.ts +12 -4
  26. package/dist/hono.d.ts.map +1 -1
  27. package/dist/hono.js +29 -12
  28. package/dist/hono.js.map +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/json-schema.d.ts +2 -0
  34. package/dist/json-schema.d.ts.map +1 -1
  35. package/dist/json-schema.js +11 -0
  36. package/dist/json-schema.js.map +1 -1
  37. package/dist/live/codec.d.ts +6 -1
  38. package/dist/live/codec.d.ts.map +1 -1
  39. package/dist/live/codec.js +14 -3
  40. package/dist/live/codec.js.map +1 -1
  41. package/dist/live/fn-live.d.ts +63 -22
  42. package/dist/live/fn-live.d.ts.map +1 -1
  43. package/dist/live/fn-live.js +98 -16
  44. package/dist/live/fn-live.js.map +1 -1
  45. package/dist/live/index.d.ts +1 -1
  46. package/dist/live/index.d.ts.map +1 -1
  47. package/dist/live/index.js.map +1 -1
  48. package/dist/live/ioredis.d.ts +4 -1
  49. package/dist/live/ioredis.d.ts.map +1 -1
  50. package/dist/live/ioredis.js +21 -2
  51. package/dist/live/ioredis.js.map +1 -1
  52. package/dist/live/lifecycle.d.ts +7 -0
  53. package/dist/live/lifecycle.d.ts.map +1 -0
  54. package/dist/live/lifecycle.js +36 -0
  55. package/dist/live/lifecycle.js.map +1 -0
  56. package/dist/live/memory.d.ts.map +1 -1
  57. package/dist/live/memory.js +4 -0
  58. package/dist/live/memory.js.map +1 -1
  59. package/dist/live/pub-sub.d.ts +76 -12
  60. package/dist/live/pub-sub.d.ts.map +1 -1
  61. package/dist/live/pub-sub.js +429 -125
  62. package/dist/live/pub-sub.js.map +1 -1
  63. package/dist/live/redis-bun.d.ts +1 -1
  64. package/dist/live/redis-bun.d.ts.map +1 -1
  65. package/dist/live/redis-bun.js +3 -5
  66. package/dist/live/redis-bun.js.map +1 -1
  67. package/dist/live/redis.d.ts +2 -2
  68. package/dist/live/redis.d.ts.map +1 -1
  69. package/dist/live/redis.js +36 -9
  70. package/dist/live/redis.js.map +1 -1
  71. package/dist/live/transport.d.ts +5 -1
  72. package/dist/live/transport.d.ts.map +1 -1
  73. package/dist/live/transport.js.map +1 -1
  74. package/dist/logger.d.ts +2 -0
  75. package/dist/logger.d.ts.map +1 -1
  76. package/dist/logger.js +21 -0
  77. package/dist/logger.js.map +1 -1
  78. package/dist/mastra.d.ts.map +1 -1
  79. package/dist/mastra.js +16 -7
  80. package/dist/mastra.js.map +1 -1
  81. package/dist/mcp/sdk.d.ts +20 -0
  82. package/dist/mcp/sdk.d.ts.map +1 -0
  83. package/dist/mcp/sdk.js +82 -0
  84. package/dist/mcp/sdk.js.map +1 -0
  85. package/dist/mcp.d.ts +3 -2
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +22 -7
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/meta.d.ts +15 -7
  90. package/dist/meta.d.ts.map +1 -1
  91. package/dist/meta.js +9 -2
  92. package/dist/meta.js.map +1 -1
  93. package/dist/otel.d.ts.map +1 -1
  94. package/dist/otel.js +77 -6
  95. package/dist/otel.js.map +1 -1
  96. package/dist/route-options.d.ts +22 -0
  97. package/dist/route-options.d.ts.map +1 -0
  98. package/dist/route-options.js +67 -0
  99. package/dist/route-options.js.map +1 -0
  100. package/dist/router.d.ts +5 -1
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +22 -0
  103. package/dist/router.js.map +1 -1
  104. package/dist/timing.d.ts +8 -0
  105. package/dist/timing.d.ts.map +1 -0
  106. package/dist/timing.js +4 -0
  107. package/dist/timing.js.map +1 -0
  108. package/dist/types.d.ts +86 -41
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js +6 -2
  111. package/dist/types.js.map +1 -1
  112. package/package.json +15 -3
  113. package/src/assemble-procedure.ts +42 -0
  114. package/src/bound-call.ts +4 -3
  115. package/src/client.ts +9 -2
  116. package/src/compatibility.ts +39 -0
  117. package/src/create-fn.ts +297 -183
  118. package/src/expo.ts +49 -44
  119. package/src/hono.ts +36 -17
  120. package/src/index.ts +6 -2
  121. package/src/json-schema.ts +16 -0
  122. package/src/live/codec.ts +22 -3
  123. package/src/live/fn-live.ts +244 -69
  124. package/src/live/index.ts +2 -0
  125. package/src/live/ioredis.ts +22 -2
  126. package/src/live/lifecycle.ts +44 -0
  127. package/src/live/memory.ts +4 -0
  128. package/src/live/pub-sub.ts +608 -166
  129. package/src/live/redis-bun.ts +7 -5
  130. package/src/live/redis.ts +37 -11
  131. package/src/live/transport.ts +4 -1
  132. package/src/logger.ts +20 -0
  133. package/src/mastra.ts +15 -7
  134. package/src/mcp/sdk.ts +118 -0
  135. package/src/mcp.ts +48 -20
  136. package/src/meta.ts +40 -24
  137. package/src/otel.ts +77 -10
  138. package/src/route-options.ts +100 -0
  139. package/src/router.ts +39 -1
  140. package/src/timing.ts +5 -0
  141. package/src/types.ts +204 -62
@@ -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
- > = Omit<PublisherConfig, 'name'> & {
62
- /** Subscribe route name; default `${name}.live`. */
63
- name?: string
64
- eventSchema: TEventSchema
65
- channel: ChannelDefinition<
66
- TInput,
67
- z.output<TEventSchema>,
68
- FnContext<TDef, TKey>
69
- >
70
- /** Fold an event into the snapshot; without it every event re-runs the handler. */
71
- transformerFn?: (
72
- params: HandlerParams<TDef, TInput, TKey> & {
73
- event: z.output<TEventSchema>
74
- previous: TOutput | undefined
75
- rerun: (input?: TInput) => Promise<TOutput>
76
- }
77
- ) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
78
- /** Return false to skip an event. */
79
- shouldUpdate?: (
80
- params: HandlerParams<TDef, TInput, TKey> & {
81
- event: z.output<TEventSchema>
82
- previous: TOutput | undefined
83
- }
84
- ) => MaybePromise<boolean>
85
- authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
86
- /** Log publish failures instead of throwing them into the publishing route. */
87
- safePublish?: boolean
88
- /** Batch events arriving within this window into one snapshot update. */
89
- coalesceMs?: number
90
- /**
91
- * When a slow subscriber's queue overflows (drop-oldest), this marker is
92
- * enqueued INSTEAD of the dropped event so the client resyncs. Return
93
- * `null` to skip.
94
- */
95
- overflowMarker?: (input: TInput) => z.output<TEventSchema> | null
96
- summary?: string
97
- description?: string
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<TDef, z.output<TInput>, NoInfer<TKey>> & {
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<TDef, z.output<TInput>, NoInfer<TKey>> & {
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
- yield previous
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
- // Guards run on the subscribe route too; route meta stays on `procedure`.
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 = (nextInput?: Record<string, unknown>) =>
439
- runSnapshot({ ...params, input: nextInput ?? params.input })
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
- if (!live.transformerFn) return runSnapshot(params)
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
- apply
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
@@ -11,6 +11,8 @@ export {
11
11
  export {
12
12
  type AuthFn,
13
13
  type ChannelDefinition,
14
+ type ChannelQueueOptions,
15
+ type PubSubMetric,
14
16
  type CreatePublisher,
15
17
  type CreatePubSub,
16
18
  createBoundedEventQueue,
@@ -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) => options.onError?.(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
- void subscriber.unsubscribe(...ours).catch(() => {})
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
+ }
@@ -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) {