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.
- package/README.md +66 -28
- package/dist/assemble-procedure.d.ts.map +1 -1
- package/dist/assemble-procedure.js +3 -1
- package/dist/assemble-procedure.js.map +1 -1
- package/dist/client.d.ts +13 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +21 -1
- package/dist/client.js.map +1 -1
- package/dist/create-fn.d.ts +63 -5
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +45 -16
- package/dist/create-fn.js.map +1 -1
- package/dist/expected-client-error.d.ts +6 -0
- package/dist/expected-client-error.d.ts.map +1 -1
- package/dist/expected-client-error.js +16 -0
- package/dist/expected-client-error.js.map +1 -1
- package/dist/hono.d.ts +44 -22
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +51 -20
- package/dist/hono.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/issues.d.ts +12 -0
- package/dist/issues.d.ts.map +1 -0
- package/dist/issues.js +22 -0
- package/dist/issues.js.map +1 -0
- package/dist/json-schema.d.ts +3 -6
- package/dist/json-schema.d.ts.map +1 -1
- package/dist/json-schema.js +12 -7
- package/dist/json-schema.js.map +1 -1
- package/dist/live/codec.d.ts +2 -0
- package/dist/live/codec.d.ts.map +1 -1
- package/dist/live/codec.js +21 -16
- package/dist/live/codec.js.map +1 -1
- package/dist/live/fn-live.d.ts +6 -19
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +15 -23
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +2 -2
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js +1 -1
- package/dist/live/index.js.map +1 -1
- package/dist/live/memory.d.ts +11 -1
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +14 -1
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +50 -8
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +79 -26
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +6 -1
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +10 -1
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/scoped-change-bus.d.ts +3 -1
- package/dist/live/scoped-change-bus.d.ts.map +1 -1
- package/dist/live/scoped-change-bus.js +3 -0
- package/dist/live/scoped-change-bus.js.map +1 -1
- package/dist/mastra.d.ts +16 -3
- package/dist/mastra.d.ts.map +1 -1
- package/dist/mastra.js +15 -3
- package/dist/mastra.js.map +1 -1
- package/dist/mcp/core.d.ts +31 -14
- package/dist/mcp/core.d.ts.map +1 -1
- package/dist/mcp/core.js +39 -20
- package/dist/mcp/core.js.map +1 -1
- package/dist/mcp/sdk.d.ts +6 -2
- package/dist/mcp/sdk.d.ts.map +1 -1
- package/dist/mcp/sdk.js +9 -3
- package/dist/mcp/sdk.js.map +1 -1
- package/dist/mcp/server.d.ts +6 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +9 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp.d.ts +3 -7
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +4 -6
- package/dist/mcp.js.map +1 -1
- package/dist/meta.d.ts +14 -2
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +6 -2
- package/dist/meta.js.map +1 -1
- package/dist/otel.d.ts +16 -4
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +40 -10
- package/dist/otel.js.map +1 -1
- package/dist/react.d.ts +6 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -1
- package/dist/route-options.d.ts.map +1 -1
- package/dist/route-options.js +4 -0
- package/dist/route-options.js.map +1 -1
- package/dist/stream-call.d.ts +15 -0
- package/dist/stream-call.d.ts.map +1 -0
- package/dist/stream-call.js +13 -0
- package/dist/stream-call.js.map +1 -0
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +2 -0
- package/dist/timing.js.map +1 -1
- package/dist/tool-errors.d.ts +24 -0
- package/dist/tool-errors.d.ts.map +1 -0
- package/dist/tool-errors.js +36 -0
- package/dist/tool-errors.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vue.d.ts +47 -4
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +39 -9
- package/dist/vue.js.map +1 -1
- package/package.json +2 -2
- package/src/assemble-procedure.ts +3 -1
- package/src/client.ts +26 -2
- package/src/create-fn.ts +149 -19
- package/src/expected-client-error.ts +19 -0
- package/src/hono.ts +120 -41
- package/src/index.ts +3 -2
- package/src/issues.ts +35 -0
- package/src/json-schema.ts +16 -10
- package/src/live/codec.ts +21 -19
- package/src/live/fn-live.ts +18 -36
- package/src/live/index.ts +2 -2
- package/src/live/memory.ts +22 -1
- package/src/live/pub-sub.ts +142 -37
- package/src/live/redis-bun.ts +23 -2
- package/src/live/scoped-change-bus.ts +10 -1
- package/src/mastra.ts +25 -6
- package/src/mcp/core.ts +76 -29
- package/src/mcp/sdk.ts +27 -2
- package/src/mcp/server.ts +27 -2
- package/src/mcp.ts +5 -16
- package/src/meta.ts +28 -4
- package/src/otel.ts +41 -9
- package/src/react.ts +10 -3
- package/src/route-options.ts +8 -0
- package/src/stream-call.ts +15 -0
- package/src/timing.ts +3 -0
- package/src/tool-errors.ts +52 -0
- package/src/types.ts +2 -0
- package/src/vue.ts +92 -19
package/src/live/pub-sub.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
89
|
-
* a publish after a committed write cannot fail the request. No
|
|
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
|
-
|
|
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
|
-
/**
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 (!
|
|
672
|
+
if (!safePublish) return send(operation, items)
|
|
625
673
|
try {
|
|
626
674
|
await send(operation, items)
|
|
627
675
|
} catch (error) {
|
|
628
|
-
logger.
|
|
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
|
-
|
|
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
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
getChannelName
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/live/redis-bun.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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:
|
|
47
|
-
) {
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
}
|