orpc-fn 0.7.0 → 0.9.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 (144) hide show
  1. package/README.md +66 -28
  2. package/dist/assemble-procedure.d.ts.map +1 -1
  3. package/dist/assemble-procedure.js +3 -1
  4. package/dist/assemble-procedure.js.map +1 -1
  5. package/dist/client.d.ts +13 -3
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +21 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/create-fn.d.ts +63 -5
  10. package/dist/create-fn.d.ts.map +1 -1
  11. package/dist/create-fn.js +45 -16
  12. package/dist/create-fn.js.map +1 -1
  13. package/dist/expected-client-error.d.ts +6 -0
  14. package/dist/expected-client-error.d.ts.map +1 -1
  15. package/dist/expected-client-error.js +16 -0
  16. package/dist/expected-client-error.js.map +1 -1
  17. package/dist/hono.d.ts +44 -22
  18. package/dist/hono.d.ts.map +1 -1
  19. package/dist/hono.js +51 -20
  20. package/dist/hono.js.map +1 -1
  21. package/dist/index.d.ts +1 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/issues.d.ts +12 -0
  26. package/dist/issues.d.ts.map +1 -0
  27. package/dist/issues.js +22 -0
  28. package/dist/issues.js.map +1 -0
  29. package/dist/json-schema.d.ts +3 -6
  30. package/dist/json-schema.d.ts.map +1 -1
  31. package/dist/json-schema.js +12 -7
  32. package/dist/json-schema.js.map +1 -1
  33. package/dist/live/codec.d.ts +2 -0
  34. package/dist/live/codec.d.ts.map +1 -1
  35. package/dist/live/codec.js +21 -16
  36. package/dist/live/codec.js.map +1 -1
  37. package/dist/live/fn-live.d.ts +6 -19
  38. package/dist/live/fn-live.d.ts.map +1 -1
  39. package/dist/live/fn-live.js +15 -23
  40. package/dist/live/fn-live.js.map +1 -1
  41. package/dist/live/index.d.ts +2 -2
  42. package/dist/live/index.d.ts.map +1 -1
  43. package/dist/live/index.js +1 -1
  44. package/dist/live/index.js.map +1 -1
  45. package/dist/live/memory.d.ts +11 -1
  46. package/dist/live/memory.d.ts.map +1 -1
  47. package/dist/live/memory.js +14 -1
  48. package/dist/live/memory.js.map +1 -1
  49. package/dist/live/pub-sub.d.ts +50 -8
  50. package/dist/live/pub-sub.d.ts.map +1 -1
  51. package/dist/live/pub-sub.js +79 -26
  52. package/dist/live/pub-sub.js.map +1 -1
  53. package/dist/live/redis-bun.d.ts +6 -1
  54. package/dist/live/redis-bun.d.ts.map +1 -1
  55. package/dist/live/redis-bun.js +10 -1
  56. package/dist/live/redis-bun.js.map +1 -1
  57. package/dist/live/scoped-change-bus.d.ts +3 -1
  58. package/dist/live/scoped-change-bus.d.ts.map +1 -1
  59. package/dist/live/scoped-change-bus.js +3 -0
  60. package/dist/live/scoped-change-bus.js.map +1 -1
  61. package/dist/mastra.d.ts +16 -3
  62. package/dist/mastra.d.ts.map +1 -1
  63. package/dist/mastra.js +15 -3
  64. package/dist/mastra.js.map +1 -1
  65. package/dist/mcp/core.d.ts +31 -14
  66. package/dist/mcp/core.d.ts.map +1 -1
  67. package/dist/mcp/core.js +39 -20
  68. package/dist/mcp/core.js.map +1 -1
  69. package/dist/mcp/sdk.d.ts +6 -2
  70. package/dist/mcp/sdk.d.ts.map +1 -1
  71. package/dist/mcp/sdk.js +9 -3
  72. package/dist/mcp/sdk.js.map +1 -1
  73. package/dist/mcp/server.d.ts +6 -2
  74. package/dist/mcp/server.d.ts.map +1 -1
  75. package/dist/mcp/server.js +9 -3
  76. package/dist/mcp/server.js.map +1 -1
  77. package/dist/mcp.d.ts +3 -7
  78. package/dist/mcp.d.ts.map +1 -1
  79. package/dist/mcp.js +4 -6
  80. package/dist/mcp.js.map +1 -1
  81. package/dist/meta.d.ts +14 -2
  82. package/dist/meta.d.ts.map +1 -1
  83. package/dist/meta.js +6 -2
  84. package/dist/meta.js.map +1 -1
  85. package/dist/otel.d.ts +16 -4
  86. package/dist/otel.d.ts.map +1 -1
  87. package/dist/otel.js +40 -10
  88. package/dist/otel.js.map +1 -1
  89. package/dist/react.d.ts +6 -1
  90. package/dist/react.d.ts.map +1 -1
  91. package/dist/react.js +5 -1
  92. package/dist/react.js.map +1 -1
  93. package/dist/route-options.d.ts.map +1 -1
  94. package/dist/route-options.js +4 -0
  95. package/dist/route-options.js.map +1 -1
  96. package/dist/stream-call.d.ts +15 -0
  97. package/dist/stream-call.d.ts.map +1 -0
  98. package/dist/stream-call.js +13 -0
  99. package/dist/stream-call.js.map +1 -0
  100. package/dist/timing.d.ts +2 -0
  101. package/dist/timing.d.ts.map +1 -1
  102. package/dist/timing.js +2 -0
  103. package/dist/timing.js.map +1 -1
  104. package/dist/tool-errors.d.ts +24 -0
  105. package/dist/tool-errors.d.ts.map +1 -0
  106. package/dist/tool-errors.js +36 -0
  107. package/dist/tool-errors.js.map +1 -0
  108. package/dist/types.d.ts +2 -0
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js.map +1 -1
  111. package/dist/vue.d.ts +47 -4
  112. package/dist/vue.d.ts.map +1 -1
  113. package/dist/vue.js +39 -9
  114. package/dist/vue.js.map +1 -1
  115. package/package.json +2 -2
  116. package/src/assemble-procedure.ts +3 -1
  117. package/src/client.ts +26 -2
  118. package/src/create-fn.ts +149 -19
  119. package/src/expected-client-error.ts +19 -0
  120. package/src/hono.ts +120 -41
  121. package/src/index.ts +3 -2
  122. package/src/issues.ts +35 -0
  123. package/src/json-schema.ts +16 -10
  124. package/src/live/codec.ts +21 -19
  125. package/src/live/fn-live.ts +18 -36
  126. package/src/live/index.ts +2 -2
  127. package/src/live/memory.ts +22 -1
  128. package/src/live/pub-sub.ts +142 -37
  129. package/src/live/redis-bun.ts +23 -2
  130. package/src/live/scoped-change-bus.ts +10 -1
  131. package/src/mastra.ts +25 -6
  132. package/src/mcp/core.ts +76 -29
  133. package/src/mcp/sdk.ts +27 -2
  134. package/src/mcp/server.ts +27 -2
  135. package/src/mcp.ts +5 -16
  136. package/src/meta.ts +28 -4
  137. package/src/otel.ts +41 -9
  138. package/src/react.ts +10 -3
  139. package/src/route-options.ts +8 -0
  140. package/src/stream-call.ts +15 -0
  141. package/src/timing.ts +3 -0
  142. package/src/tool-errors.ts +52 -0
  143. package/src/types.ts +2 -0
  144. package/src/vue.ts +92 -19
@@ -63,7 +63,12 @@ export type FilterFn<TInput, TEventData> = (params: {
63
63
  * everyone; a function returns false (or throws) to refuse with FORBIDDEN.
64
64
  */
65
65
  export type AuthFn<TInput, TContext> =
66
- | ((params: { input: TInput; ctx: TContext }) => MaybePromise<boolean>)
66
+ | ((params: {
67
+ input: TInput
68
+ context: TContext
69
+ /** @deprecated Use `context`. */
70
+ ctx: TContext
71
+ }) => MaybePromise<boolean>)
67
72
  | true
68
73
 
69
74
  export type PublisherConfig = {
@@ -85,8 +90,9 @@ export type PublisherConfig = {
85
90
  */
86
91
  mirrorChannel?: string
87
92
  /**
88
- * Log publish failures (validation or broker) instead of throwing them, so
89
- * a publish after a committed write cannot fail the request. No retry.
93
+ * Log publish failures (validation or broker) at `warn` instead of throwing
94
+ * them, so a publish after a committed write cannot fail the request. No
95
+ * retry. Default: `createFn({ pubsub: { safePublish } })`, else false.
90
96
  */
91
97
  safePublish?: boolean
92
98
  }
@@ -128,7 +134,11 @@ export type PubSubOptions<
128
134
  /** What gets published. */
129
135
  eventSchema: TEventSchema
130
136
  filterFn?: FilterFn<z.output<TInputSchema>, z.output<TEventSchema>>
131
- authFn?: AuthFn<z.output<TInputSchema>, FnContext<TDef, TKey>>
137
+ /**
138
+ * Who may subscribe. Required: `true` says on purpose that anyone who
139
+ * passes the procedure builder may.
140
+ */
141
+ authFn: AuthFn<z.output<TInputSchema>, FnContext<TDef, TKey>>
132
142
  /**
133
143
  * When a slow subscriber's queue overflows (drop-oldest), enqueue this
134
144
  * marker INSTEAD of the new event so the subscriber resyncs from its
@@ -167,11 +177,24 @@ export type PubSub<
167
177
  context: FnContext<TDef, TKey>
168
178
  }) => string
169
179
  getPublishChannelName: (event: z.output<TEventSchema>) => string
170
- /** @deprecated Prefer the separate required-input resolver methods. */
171
- getChannelName: (
172
- params: Partial<z.output<TInputSchema>> | Partial<z.output<TEventSchema>>,
173
- context?: FnContext<TDef, TKey>
174
- ) => string
180
+ /**
181
+ * Read the bus on the server, without the route, its guards or `authFn`.
182
+ * Resolves once subscribed; the events end when `signal` aborts.
183
+ */
184
+ listen: (
185
+ input: z.output<TInputSchema>,
186
+ options?: ListenOptions<FnContext<TDef, TKey>>
187
+ ) => Promise<AsyncGenerator<z.output<TEventSchema>, void, unknown>>
188
+ /** The parsed backlog of the channel an event goes to, oldest first. */
189
+ readBacklog: (
190
+ event: Partial<z.output<TEventSchema>>
191
+ ) => Promise<z.output<TEventSchema>[]>
192
+ }
193
+
194
+ export type ListenOptions<TContext = unknown> = {
195
+ signal?: AbortSignal
196
+ /** For a channel resolver that reads the context. */
197
+ context?: TContext
175
198
  }
176
199
 
177
200
  export type CreatePubSub<TDef extends FnDefinition> = <
@@ -201,6 +224,15 @@ export type Publisher<TEventSchema extends ZodType> = {
201
224
  publish: (data: z.input<TEventSchema>) => Promise<void>
202
225
  publishMany: (items: readonly z.input<TEventSchema>[]) => Promise<void>
203
226
  getChannelName: (params: Partial<z.output<TEventSchema>>) => string
227
+ /** Read the channel on the server. Resolves once subscribed. */
228
+ listen: (
229
+ params: Partial<z.output<TEventSchema>>,
230
+ options?: Omit<ListenOptions, 'context'>
231
+ ) => Promise<AsyncGenerator<z.output<TEventSchema>, void, unknown>>
232
+ /** The channel's parsed backlog, oldest first. */
233
+ readBacklog: (
234
+ params: Partial<z.output<TEventSchema>>
235
+ ) => Promise<z.output<TEventSchema>[]>
204
236
  }
205
237
 
206
238
  /**
@@ -236,6 +268,14 @@ export type PubSubRuntimeOptions = {
236
268
  namespace?: string
237
269
  /** Close this transport only when its ownership is explicitly transferred. */
238
270
  ownsTransport?: boolean
271
+ /** Default `safePublish` for every definition of this factory. */
272
+ safePublish?: boolean
273
+ /**
274
+ * End route streams this long after they opened (plus up to 10% jitter),
275
+ * so a client reconnects and its context, and with it its authorization,
276
+ * is built again. The client helpers reconnect on their own. Default: never.
277
+ */
278
+ maxStreamAgeMs?: number
239
279
  }
240
280
 
241
281
  /** @internal */
@@ -463,9 +503,12 @@ export type Channel = {
463
503
  context: unknown
464
504
  signal: AbortSignal | undefined
465
505
  recoverGaps?: boolean
506
+ /** An in-process reader: no `authFn`, no maximum age. */
507
+ server?: boolean
466
508
  }) => Promise<LiveSubscription>
467
509
  publish: (data: unknown) => Promise<void>
468
510
  publishMany: (items: readonly unknown[]) => Promise<void>
511
+ readBacklog: (event: Input) => Promise<unknown[]>
469
512
  getChannelName: (params: Input, context?: unknown) => string
470
513
  getSubscriptionChannelName: (params: {
471
514
  input: Input
@@ -512,6 +555,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
512
555
  options.pubsub?.initializationTimeoutMs ?? 10000,
513
556
  'initializationTimeoutMs'
514
557
  )
558
+ const maxStreamAge =
559
+ options.pubsub?.maxStreamAgeMs === undefined
560
+ ? undefined
561
+ : positiveInteger(options.pubsub.maxStreamAgeMs, 'maxStreamAgeMs')
515
562
  const metric = (event: PubSubMetric) =>
516
563
  safely(() => options.pubsub?.onMetric?.(event))
517
564
  const namespace = options.pubsub?.namespace
@@ -617,15 +664,16 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
617
664
  }
618
665
  })
619
666
 
667
+ const safePublish = config.safePublish ?? options.pubsub?.safePublish
620
668
  const guarded = async (
621
669
  operation: 'publish' | 'publishMany',
622
670
  items: readonly unknown[]
623
671
  ) => {
624
- if (!config.safePublish) return send(operation, items)
672
+ if (!safePublish) return send(operation, items)
625
673
  try {
626
674
  await send(operation, items)
627
675
  } catch (error) {
628
- logger.error('pubsub.publish_failed', {
676
+ logger.warn('pubsub.publish_failed', {
629
677
  name,
630
678
  ...errorAttributes(error)
631
679
  })
@@ -910,7 +958,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
910
958
  input,
911
959
  context,
912
960
  signal,
913
- recoverGaps = false
961
+ recoverGaps = false,
962
+ server = false
914
963
  }) => {
915
964
  assertOpen()
916
965
  signal?.throwIfAborted()
@@ -937,10 +986,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
937
986
 
938
987
  try {
939
988
  if (
989
+ !server &&
940
990
  authFn !== undefined &&
941
991
  authFn !== true &&
942
992
  !(await waitFor(
943
- Promise.resolve(authFn({ input, ctx: context })),
993
+ Promise.resolve(authFn({ input, context, ctx: context })),
944
994
  initializationTimeout,
945
995
  initializing.signal
946
996
  ))
@@ -1006,16 +1056,14 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1006
1056
  let replaying: Array<{ data: unknown; id: string | undefined }> | null =
1007
1057
  useBacklog ? [] : null
1008
1058
  let replayGap = false
1009
- const seen = new Set<string>()
1059
+ // Overlap exists only between the backlog and live events that arrive
1060
+ // while it replays, so ids are recorded only then: at most twice
1061
+ // `maxReplay`, and nothing at all without a backlog.
1062
+ const seen = useBacklog ? new Set<string>() : null
1010
1063
  const dedupe = (id: string | undefined) => {
1011
- if (!id) return false
1064
+ if (!id || !seen) return false
1012
1065
  if (seen.has(id)) return true
1013
- seen.add(id)
1014
- if (
1015
- seen.size >
1016
- Math.max(maxReplay, maxQueue, channelOptions.backlogSize ?? 50) * 2
1017
- )
1018
- seen.delete(seen.values().next().value as string)
1066
+ if (replaying) seen.add(id)
1019
1067
  return false
1020
1068
  }
1021
1069
  const push = (data: unknown, id?: string) => {
@@ -1067,9 +1115,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1067
1115
  // stream finishing, or `close()`. Releases everything at once, even
1068
1116
  // while the stream is parked or not yet read.
1069
1117
  let closed = false
1118
+ let ageTimer: ReturnType<typeof setTimeout> | undefined
1070
1119
  const close = (...error: [] | [unknown]) => {
1071
1120
  if (closed) return
1072
1121
  closed = true
1122
+ clearTimeout(ageTimer)
1073
1123
  initializing.abort(error[0] ?? new Error('Subscription closed'))
1074
1124
  delivery.close(error[0])
1075
1125
  activeSubscriberCleanups.delete(stop)
@@ -1097,6 +1147,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1097
1147
  activeSubscriberCleanups.add(stop)
1098
1148
  signal?.addEventListener('abort', stop)
1099
1149
  if (signal?.aborted) close()
1150
+ if (maxStreamAge !== undefined && !server && !closed) {
1151
+ ageTimer = setTimeout(stop, maxStreamAge * (1 + Math.random() * 0.1))
1152
+ ;(ageTimer as { unref?: () => void }).unref?.()
1153
+ }
1100
1154
 
1101
1155
  try {
1102
1156
  await waitFor(local.ready, initializationTimeout, initializing.signal)
@@ -1182,6 +1236,25 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1182
1236
  getChannelName: getPublishChannel
1183
1237
  })
1184
1238
 
1239
+ const readBacklog = async (event: Input) => {
1240
+ const channelName = getPublishChannel(event)
1241
+ const events: unknown[] = []
1242
+ for (const raw of await requireTransport().readBacklog(channelName)) {
1243
+ try {
1244
+ events.push(await eventSchema.parseAsync(decodeMessage(raw).value))
1245
+ } catch (error) {
1246
+ metric({
1247
+ type: 'parseError',
1248
+ stage: 'replay',
1249
+ error,
1250
+ name,
1251
+ channel: channelName
1252
+ })
1253
+ }
1254
+ }
1255
+ return events
1256
+ }
1257
+
1185
1258
  const open: Channel['open'] = (params) => {
1186
1259
  const task = openInternal(params)
1187
1260
  pendingOpens.add(task)
@@ -1195,6 +1268,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1195
1268
  open,
1196
1269
  publish,
1197
1270
  publishMany,
1271
+ readBacklog,
1198
1272
  getChannelName,
1199
1273
  getSubscriptionChannelName: ({ input, context }) =>
1200
1274
  getChannelName(input, context),
@@ -1212,6 +1286,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1212
1286
  }
1213
1287
  ) {
1214
1288
  const { name } = pubsubOptions
1289
+ if (pubsubOptions.authFn === undefined)
1290
+ throw new TypeError(
1291
+ `orpc-fn: pub/sub ${name} needs an authFn; pass authFn: true to let every caller of its procedure subscribe`
1292
+ )
1215
1293
  const channel = createChannel(pubsubOptions)
1216
1294
 
1217
1295
  const {
@@ -1251,27 +1329,37 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1251
1329
  }
1252
1330
  })
1253
1331
 
1254
- const { publish, publishMany, getChannelName } = channel
1255
1332
  return {
1256
1333
  subscribe,
1257
- publish,
1258
- publishMany,
1259
- getChannelName,
1334
+ publish: channel.publish,
1335
+ publishMany: channel.publishMany,
1260
1336
  getSubscriptionChannelName: channel.getSubscriptionChannelName,
1261
- getPublishChannelName: channel.getPublishChannelName
1337
+ getPublishChannelName: channel.getPublishChannelName,
1338
+ listen: listener(channel),
1339
+ readBacklog: channel.readBacklog
1262
1340
  }
1263
1341
  }
1264
1342
 
1265
1343
  function createPublisher(publisherOptions: PublisherOptions<ZodType>) {
1266
- const getChannelName = (params: Input) =>
1267
- qualify(resolveChannel(publisherOptions.channel, params, undefined, true))
1268
- const { publish, publishMany } = createChannelPublisher({
1269
- ...publisherOptions,
1270
- getChannelName
1271
- })
1272
- return { publish, publishMany, getChannelName }
1344
+ const channel = createChannel(publisherOptions)
1345
+ return {
1346
+ publish: channel.publish,
1347
+ publishMany: channel.publishMany,
1348
+ getChannelName: channel.getPublishChannelName,
1349
+ listen: (params: Input, listenOptions: { signal?: AbortSignal } = {}) =>
1350
+ listener(channel)(params, listenOptions),
1351
+ readBacklog: channel.readBacklog
1352
+ }
1273
1353
  }
1274
1354
 
1355
+ const listener =
1356
+ (channel: Channel) =>
1357
+ async (
1358
+ input: Input,
1359
+ { signal, context }: { signal?: AbortSignal; context?: unknown } = {}
1360
+ ) =>
1361
+ (await channel.open({ input, context, signal, server: true })).events
1362
+
1275
1363
  return {
1276
1364
  createChannel,
1277
1365
  shutdown: () => {
@@ -1291,10 +1379,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1291
1379
  /** Open subscriptions of this instance (drain progress, gauges). */
1292
1380
  activePubSubSubscriberCount: () => activeSubscriberCleanups.size,
1293
1381
  /** End every open subscription; returns how many were closed. */
1294
- drainPubSubSubscribers: () => {
1382
+ drainPubSubSubscribers: (drain?: {
1383
+ overMs: number
1384
+ }): number | Promise<number> => {
1295
1385
  const cleanups = [...activeSubscriberCleanups]
1296
- activeSubscriberCleanups.clear()
1297
- for (const cleanup of cleanups) {
1386
+ const run = (cleanup: () => void) => {
1298
1387
  try {
1299
1388
  cleanup()
1300
1389
  } catch (error) {
@@ -1304,7 +1393,23 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1304
1393
  })
1305
1394
  }
1306
1395
  }
1307
- return cleanups.length
1396
+ if (drain === undefined) {
1397
+ activeSubscriberCleanups.clear()
1398
+ for (const cleanup of cleanups) run(cleanup)
1399
+ return cleanups.length
1400
+ }
1401
+ const { overMs } = drain
1402
+ if (!Number.isFinite(overMs) || overMs < 0)
1403
+ throw new RangeError('orpc-fn: overMs must be finite and nonnegative')
1404
+ const step = overMs / Math.max(1, cleanups.length - 1)
1405
+ return (async () => {
1406
+ for (const [index, cleanup] of cleanups.entries()) {
1407
+ if (index > 0)
1408
+ await new Promise((resolve) => setTimeout(resolve, step))
1409
+ run(cleanup)
1410
+ }
1411
+ return cleanups.length
1412
+ })()
1308
1413
  }
1309
1414
  }
1310
1415
  }
@@ -15,6 +15,13 @@ export interface BunRedisClientLike {
15
15
  onclose: ((this: never, error: Error) => void) | null
16
16
  }
17
17
 
18
+ /**
19
+ * What the command client must offer: `send`, and `duplicate` unless a
20
+ * `subscriber` is given. A wrapper with timeouts around Bun's client fits.
21
+ */
22
+ export type BunRedisCommandClientLike = Pick<BunRedisClientLike, 'send'> &
23
+ Partial<Pick<BunRedisClientLike, 'duplicate'>>
24
+
18
25
  /**
19
26
  * `PubSubTransport` for Bun's built-in `RedisClient`. Subscriptions use a
20
27
  * dedicated connection (`client.duplicate()` unless `subscriber` is given)
@@ -24,14 +31,15 @@ export interface BunRedisClientLike {
24
31
  * Call `close()` on shutdown to close the subscriber connection it created.
25
32
  */
26
33
  export function bunRedisTransport(
27
- client: BunRedisClientLike,
34
+ client: BunRedisCommandClientLike,
28
35
  options: { subscriber?: BunRedisClientLike } = {}
29
36
  ) {
37
+ const openSubscriber = subscriberSource(client, options.subscriber)
30
38
  return createRedisTransport({
31
39
  send: (command, args) => client.send(command, args),
32
40
  async connectSubscriber(handlers) {
33
41
  const owned = !options.subscriber
34
- const subscriber = options.subscriber ?? (await client.duplicate())
42
+ const subscriber = await openSubscriber()
35
43
  const dispatch: Listener = (message, channel) =>
36
44
  handlers.onMessage(channel, message)
37
45
  const ours = new Set<string>()
@@ -79,3 +87,16 @@ export function bunRedisTransport(
79
87
  }
80
88
  })
81
89
  }
90
+
91
+ function subscriberSource(
92
+ client: BunRedisCommandClientLike,
93
+ given: BunRedisClientLike | undefined
94
+ ): () => Promise<BunRedisClientLike> {
95
+ if (given) return async () => given
96
+ const { duplicate } = client
97
+ if (typeof duplicate !== 'function')
98
+ throw new TypeError(
99
+ 'orpc-fn: bunRedisTransport needs a client with duplicate() or a subscriber'
100
+ )
101
+ return () => duplicate.call(client)
102
+ }
@@ -50,6 +50,8 @@ const noInput = z.object({})
50
50
  * subscriber in a scope "something changed". Each bus then names only its
51
51
  * channel prefix: events go to `${prefix}:${scope}`. Publishing is safe by
52
52
  * default (`safePublish: true`), because buses publish after committed writes.
53
+ * The scope is the access decision (a subscriber hears only its own scope),
54
+ * so `authFn` defaults to `true`.
53
55
  *
54
56
  * ```ts
55
57
  * const defineChangeBus = defineScopedChangeBus(createPubSub, {
@@ -75,8 +77,14 @@ export function defineScopedChangeBus<TDef extends FnDefinition, TScopeEvent>(
75
77
  >(
76
78
  options: Omit<
77
79
  PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>>,
78
- 'channel' | 'inputSchema'
80
+ 'channel' | 'inputSchema' | 'authFn'
79
81
  > &
82
+ Partial<
83
+ Pick<
84
+ PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>>,
85
+ 'authFn'
86
+ >
87
+ > &
80
88
  GuardOptions<TDef['guards']> &
81
89
  Pick<
82
90
  FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
@@ -98,6 +106,7 @@ export function defineScopedChangeBus<TDef extends FnDefinition, TScopeEvent>(
98
106
  // signature of createPubSub cannot see.
99
107
  const bus = (createPubSub as (options: object) => object)({
100
108
  safePublish: true,
109
+ authFn: true,
101
110
  ...pubsubOptions,
102
111
  inputSchema: pubsubOptions.inputSchema ?? noInput,
103
112
  channel
package/src/mastra.ts CHANGED
@@ -8,14 +8,15 @@ import {
8
8
  type ToolSchema
9
9
  } from './json-schema.js'
10
10
  import { readFnMeta, toToolName } from './meta.js'
11
+ import { type ToolErrorOptions, toolErrorText } from './tool-errors.js'
11
12
  import type { ProcedureInput, ProcedureOutput } from './types.js'
12
13
 
13
- export { toToolName }
14
+ export { toToolName, type ToolErrorOptions }
14
15
 
15
- export type CreateMastraToolOptions = {
16
+ export type CreateMastraToolOptions = ToolErrorOptions & {
16
17
  /** Override the tool id (defaults to the sanitized `fn` name). */
17
18
  id?: string
18
- /** Override the tool description (defaults to the route summary). */
19
+ /** Override the tool description (defaults to the route description, else its summary). */
19
20
  description?: string
20
21
  /** Gate execution behind the AI SDK tool-approval flow. */
21
22
  requireApproval?: boolean
@@ -31,6 +32,13 @@ export type CreateMastraToolOptions = {
31
32
  }) => void
32
33
  /** Key holding the oRPC context in Mastra's `RequestContext`. Default 'orpcContext'. */
33
34
  contextKey?: string
35
+ /**
36
+ * Reads the oRPC context from the run's request context, in place of
37
+ * `contextKey`. Mastra writes plain request-context values into run
38
+ * snapshots and spans, so an app whose context holds a session keeps it
39
+ * in a slot that serializes to nothing and reads it here.
40
+ */
41
+ context?: (requestContext: RequestContext) => unknown
34
42
  }
35
43
 
36
44
  /** What the tool takes: the procedure's raw input, or `{}` without a schema. */
@@ -59,6 +67,10 @@ type ObjectInputCheck<TProc> =
59
67
  * Mastra checks the input against the procedure's schema (with dates coerced
60
68
  * from strings) but passes the raw input on for the procedure to parse;
61
69
  * the output is the procedure's parsed result, passed through unchanged.
70
+ *
71
+ * A failed call throws an `Error` whose message is what the model reads:
72
+ * `CODE: message` with validation paths for an expected error, a fixed
73
+ * sentence for anything else (reported to `onError`).
62
74
  */
63
75
  export function createMastraTool<TProc extends AnyProcedure>(
64
76
  procedure: TProc & ObjectInputCheck<TProc>,
@@ -110,10 +122,16 @@ export function createMastraTool<TProc extends AnyProcedure>(
110
122
  : {}),
111
123
  execute: async (input, ctx) => {
112
124
  const requestContext = ctx?.requestContext as RequestContext | undefined
113
- const context = requestContext?.get(contextKey)
125
+ const context =
126
+ requestContext &&
127
+ (options.context
128
+ ? options.context(requestContext)
129
+ : requestContext.get(contextKey))
114
130
  if (!context) {
115
131
  throw new Error(
116
- `createMastraTool execution requires ${contextKey} in the Mastra request context`
132
+ options.context
133
+ ? 'createMastraTool execution found no oRPC context through its context option'
134
+ : `createMastraTool execution requires ${contextKey} in the Mastra request context`
117
135
  )
118
136
  }
119
137
  const startedAt = performance.now()
@@ -138,7 +156,8 @@ export function createMastraTool<TProc extends AnyProcedure>(
138
156
  return result
139
157
  } catch (error) {
140
158
  finish('error')
141
- throw error
159
+ if (ctx?.abortSignal?.aborted) throw error
160
+ throw new Error(toolErrorText(error, id, options))
142
161
  }
143
162
  }
144
163
  })
package/src/mcp/core.ts CHANGED
@@ -3,8 +3,10 @@ import {
3
3
  type InferRouterInitialContext,
4
4
  call
5
5
  } from '@orpc/server'
6
+ import { formatIssue } from '../issues.js'
6
7
  import { isObjectJsonSchema } from '../json-schema.js'
7
8
  import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
9
+ import { type ToolErrorOptions, toolErrorText } from '../tool-errors.js'
8
10
 
9
11
  /** Text-only tool result: the shape both MCP SDK majors accept. */
10
12
  export type TextToolResult = {
@@ -12,46 +14,60 @@ export type TextToolResult = {
12
14
  isError?: boolean
13
15
  }
14
16
 
15
- export type RegisterMcpToolsOptions<R extends AnyRouter, TResult> = {
17
+ /** Which procedures become tools, and their names and hints. */
18
+ export type McpToolSelection = {
16
19
  /** Tool exposure must be an explicit application choice. */
17
20
  filter: ProcedureFilter
21
+ readOnly?: ProcedureFilter
22
+ name?: NonNullable<Parameters<typeof listTools>[1]>['name']
23
+ }
24
+
25
+ /** How a registered tool is called and how its outcome reads. */
26
+ export type McpCallOptions<R extends AnyRouter, TResult> = ToolErrorOptions & {
18
27
  context: (request: {
19
28
  name: string
20
29
  signal: AbortSignal
21
30
  }) => InferRouterInitialContext<R> | Promise<InferRouterInitialContext<R>>
22
- readOnly?: ProcedureFilter
23
- name?: NonNullable<Parameters<typeof listTools>[1]>['name']
24
31
  formatResult?: (value: unknown, tool: McpTool) => TResult | Promise<TResult>
25
32
  }
26
33
 
27
- /** Default protocol result: JSON/text. Provide formatResult for other MCP content. */
28
- export function formatMcpResult(value: unknown): TextToolResult {
29
- const text =
30
- typeof value === 'string'
31
- ? value
32
- : (JSON.stringify(value, (_key, item) =>
33
- typeof item === 'bigint' ? item.toString() : item
34
- ) ?? 'null')
35
- return { content: [{ type: 'text', text }] }
34
+ export type RegisterMcpToolsOptions<
35
+ R extends AnyRouter,
36
+ TResult
37
+ > = McpToolSelection & McpCallOptions<R, TResult>
38
+
39
+ type ToolDescription = Omit<McpTool['config'], 'inputSchema'> & {
40
+ name: string
41
+ inputSchema: Record<string, unknown> & { type: 'object' }
36
42
  }
37
43
 
38
- const errorResult = (text: string): TextToolResult => ({
39
- content: [{ type: 'text', text }],
40
- isError: true
41
- })
44
+ /** Tools listed and converted once, then registered per server or request. */
45
+ export type PreparedMcpTools<R extends AnyRouter> = {
46
+ readonly router: R
47
+ readonly names: readonly string[]
48
+ readonly tools: readonly McpTool[]
49
+ /** @internal The `tools/list` answer. */
50
+ readonly descriptions: readonly ToolDescription[]
51
+ }
42
52
 
43
- /** @internal SDK-independent `tools/list` and `tools/call` behind both adapters. */
44
- export function createToolHandlers<R extends AnyRouter, TResult>(
53
+ /**
54
+ * List and convert a router's tools once, for an app that builds an MCP
55
+ * server per request: `registerPreparedMcpTools(server, prepared, { context })`.
56
+ */
57
+ export function prepareMcpTools<R extends AnyRouter>(
45
58
  router: R,
46
- options: RegisterMcpToolsOptions<R, TResult>
47
- ) {
48
- const definitions = listTools(router, {
59
+ options: McpToolSelection
60
+ ): PreparedMcpTools<R> {
61
+ if (typeof options.filter !== 'function')
62
+ throw new TypeError(
63
+ 'orpc-fn: MCP tools need a filter; tool exposure is an explicit choice'
64
+ )
65
+ const tools = listTools(router, {
49
66
  filter: options.filter,
50
67
  ...(options.readOnly ? { readOnly: options.readOnly } : {}),
51
68
  ...(options.name ? { name: options.name } : {})
52
69
  })
53
- const tools = new Map(definitions.map((tool) => [tool.name, tool]))
54
- const descriptions = definitions.map((tool) => {
70
+ const descriptions = tools.map((tool) => {
55
71
  const inputSchema = tool.config.inputSchema?.[
56
72
  '~standard'
57
73
  ].jsonSchema.input() ?? { type: 'object', properties: {} }
@@ -67,8 +83,38 @@ export function createToolHandlers<R extends AnyRouter, TResult>(
67
83
  }
68
84
  })
69
85
  return {
70
- names: [...tools.keys()] as readonly string[],
71
- list: () => ({ tools: descriptions }),
86
+ router,
87
+ names: tools.map((tool) => tool.name),
88
+ tools,
89
+ descriptions
90
+ }
91
+ }
92
+
93
+ /** Default protocol result: JSON/text. Provide formatResult for other MCP content. */
94
+ export function formatMcpResult(value: unknown): TextToolResult {
95
+ const text =
96
+ typeof value === 'string'
97
+ ? value
98
+ : (JSON.stringify(value, (_key, item) =>
99
+ typeof item === 'bigint' ? item.toString() : item
100
+ ) ?? 'null')
101
+ return { content: [{ type: 'text', text }] }
102
+ }
103
+
104
+ const errorResult = (text: string): TextToolResult => ({
105
+ content: [{ type: 'text', text }],
106
+ isError: true
107
+ })
108
+
109
+ /** @internal SDK-independent `tools/list` and `tools/call` behind both adapters. */
110
+ export function createToolHandlers<R extends AnyRouter, TResult>(
111
+ prepared: PreparedMcpTools<R>,
112
+ options: McpCallOptions<R, TResult>
113
+ ) {
114
+ const tools = new Map(prepared.tools.map((tool) => [tool.name, tool]))
115
+ return {
116
+ names: prepared.names,
117
+ list: () => ({ tools: [...prepared.descriptions] }),
72
118
  call: async (
73
119
  name: string,
74
120
  args: Record<string, unknown> | undefined,
@@ -83,7 +129,10 @@ export function createToolHandlers<R extends AnyRouter, TResult>(
83
129
  ].validate(args ?? {})
84
130
  if (validation?.issues)
85
131
  return errorResult(
86
- validation.issues.map((issue) => issue.message).join('; ')
132
+ [
133
+ 'BAD_REQUEST: Invalid tool input',
134
+ ...validation.issues.map(formatIssue)
135
+ ].join('\n')
87
136
  )
88
137
  const context = await options.context({ name: tool.name, signal })
89
138
  const result = await call(tool.procedure, validation?.value, {
@@ -93,9 +142,7 @@ export function createToolHandlers<R extends AnyRouter, TResult>(
93
142
  return await (options.formatResult ?? formatMcpResult)(result, tool)
94
143
  } catch (error) {
95
144
  if (signal.aborted) throw error
96
- return errorResult(
97
- error instanceof Error ? error.message : String(error)
98
- )
145
+ return errorResult(toolErrorText(error, tool.name, options))
99
146
  }
100
147
  }
101
148
  }