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
package/src/mcp/sdk.ts CHANGED
@@ -8,16 +8,23 @@ import type { AnyRouter } from '@orpc/server'
8
8
  import {
9
9
  createToolHandlers,
10
10
  formatMcpResult,
11
+ type McpCallOptions,
12
+ type PreparedMcpTools,
13
+ prepareMcpTools,
11
14
  type RegisterMcpToolsOptions as CoreOptions
12
15
  } from './core.js'
13
16
 
14
- export { formatMcpResult }
17
+ export { formatMcpResult, prepareMcpTools, type PreparedMcpTools }
15
18
 
16
19
  export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
17
20
  R,
18
21
  CallToolResult
19
22
  >
20
23
 
24
+ /** Options for registering tools from {@link prepareMcpTools}. */
25
+ export type RegisterPreparedMcpToolsOptions<R extends AnyRouter> =
26
+ McpCallOptions<R, CallToolResult>
27
+
21
28
  /**
22
29
  * Register on the SDK v1 low-level Server, or on McpServer.server. For SDK v2
23
30
  * (`@modelcontextprotocol/server`) use `orpc-fn/mcp/server`.
@@ -27,7 +34,25 @@ export function registerMcpTools<R extends AnyRouter>(
27
34
  router: R,
28
35
  options: RegisterMcpToolsOptions<R>
29
36
  ): readonly string[] {
30
- const handlers = createToolHandlers(router, options)
37
+ return install(
38
+ server,
39
+ createToolHandlers(prepareMcpTools(router, options), options)
40
+ )
41
+ }
42
+
43
+ /** Register tools listed once with {@link prepareMcpTools}, e.g. on a server built per request. */
44
+ export function registerPreparedMcpTools<R extends AnyRouter>(
45
+ server: Pick<Server, 'setRequestHandler'>,
46
+ tools: PreparedMcpTools<R>,
47
+ options: RegisterPreparedMcpToolsOptions<R>
48
+ ): readonly string[] {
49
+ return install(server, createToolHandlers(tools, options))
50
+ }
51
+
52
+ function install(
53
+ server: Pick<Server, 'setRequestHandler'>,
54
+ handlers: ReturnType<typeof createToolHandlers<AnyRouter, CallToolResult>>
55
+ ): readonly string[] {
31
56
  server.setRequestHandler(ListToolsRequestSchema, handlers.list)
32
57
  server.setRequestHandler(CallToolRequestSchema, (request, extra) =>
33
58
  handlers.call(request.params.name, request.params.arguments, extra.signal)
package/src/mcp/server.ts CHANGED
@@ -3,16 +3,23 @@ import type { AnyRouter } from '@orpc/server'
3
3
  import {
4
4
  createToolHandlers,
5
5
  formatMcpResult,
6
+ type McpCallOptions,
7
+ type PreparedMcpTools,
8
+ prepareMcpTools,
6
9
  type RegisterMcpToolsOptions as CoreOptions
7
10
  } from './core.js'
8
11
 
9
- export { formatMcpResult }
12
+ export { formatMcpResult, prepareMcpTools, type PreparedMcpTools }
10
13
 
11
14
  export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
12
15
  R,
13
16
  CallToolResult
14
17
  >
15
18
 
19
+ /** Options for registering tools from {@link prepareMcpTools}. */
20
+ export type RegisterPreparedMcpToolsOptions<R extends AnyRouter> =
21
+ McpCallOptions<R, CallToolResult>
22
+
16
23
  /**
17
24
  * Register on the SDK v2 (`@modelcontextprotocol/server`) low-level Server, or
18
25
  * on McpServer.server. Declare `capabilities: { tools: {} }` on the Server.
@@ -22,7 +29,25 @@ export function registerMcpTools<R extends AnyRouter>(
22
29
  router: R,
23
30
  options: RegisterMcpToolsOptions<R>
24
31
  ): readonly string[] {
25
- const handlers = createToolHandlers(router, options)
32
+ return install(
33
+ server,
34
+ createToolHandlers(prepareMcpTools(router, options), options)
35
+ )
36
+ }
37
+
38
+ /** Register tools listed once with {@link prepareMcpTools}, e.g. on a server built per request. */
39
+ export function registerPreparedMcpTools<R extends AnyRouter>(
40
+ server: Pick<Server, 'setRequestHandler'>,
41
+ tools: PreparedMcpTools<R>,
42
+ options: RegisterPreparedMcpToolsOptions<R>
43
+ ): readonly string[] {
44
+ return install(server, createToolHandlers(tools, options))
45
+ }
46
+
47
+ function install(
48
+ server: Pick<Server, 'setRequestHandler'>,
49
+ handlers: ReturnType<typeof createToolHandlers<AnyRouter, CallToolResult>>
50
+ ): readonly string[] {
26
51
  server.setRequestHandler('tools/list', handlers.list)
27
52
  server.setRequestHandler('tools/call', (request, ctx) =>
28
53
  handlers.call(
package/src/mcp.ts CHANGED
@@ -6,16 +6,11 @@ import {
6
6
  } from '@orpc/server'
7
7
  import { procedureDefinition } from './compatibility.js'
8
8
  import { rawInputSchema, type ToolSchema } from './json-schema.js'
9
- import { readFnMeta, toToolName } from './meta.js'
9
+ import { hasTag, type ProcedureFilter, readFnMeta, toToolName } from './meta.js'
10
10
 
11
- export { toToolName }
11
+ export { hasTag, toToolName }
12
12
  export { rawInputSchema as toolInputSchema }
13
- export type { ToolSchema }
14
-
15
- /** Picks procedures; also fits oRPC's `OpenAPIHandler({ filter })`. */
16
- export type ProcedureFilter = (
17
- options: TraverseContractProcedureCallbackOptions
18
- ) => boolean
13
+ export type { ProcedureFilter, ToolSchema }
19
14
 
20
15
  export type McpTool = {
21
16
  /** Tool name: the sanitized `fn` name (`user.me` becomes `user-me`). */
@@ -32,12 +27,6 @@ export type McpTool = {
32
27
  }
33
28
  }
34
29
 
35
- /** Procedures whose route has `tag`: `listTools(router, { filter: hasTag('external') })`. */
36
- export const hasTag =
37
- (tag: string): ProcedureFilter =>
38
- ({ contract }) =>
39
- procedureDefinition(contract).route?.tags?.includes(tag) ?? false
40
-
41
30
  const isGet: ProcedureFilter = ({ contract }) =>
42
31
  procedureDefinition(contract).route?.method === 'GET'
43
32
 
@@ -80,8 +69,8 @@ export function listTools(
80
69
  `orpc-fn: MCP tool name collision: ${name}; provide a name override`
81
70
  )
82
71
  names.set(name, procedure)
83
- const { inputSchema, route = {} } = procedureDefinition(procedure)
84
- const description = route.description ?? route.summary
72
+ const { inputSchema } = procedureDefinition(procedure)
73
+ const description = meta.description || undefined
85
74
  tools.push({
86
75
  name,
87
76
  procedure,
package/src/meta.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  import { getEventIteratorSchemaDetails, type AnySchema } from '@orpc/contract'
2
2
  import { type LibraryMeta, procedureDefinition } from './compatibility.js'
3
- import type { AnyProcedure } from '@orpc/server'
3
+ import type {
4
+ AnyProcedure,
5
+ TraverseContractProcedureCallbackOptions
6
+ } from '@orpc/server'
7
+
8
+ /** Picks procedures; also fits oRPC's `OpenAPIHandler({ filter })`. */
9
+ export type ProcedureFilter = (
10
+ options: TraverseContractProcedureCallbackOptions
11
+ ) => boolean
12
+
13
+ /** Procedures whose route has `tag`: `listTools(router, { filter: hasTag('external') })`. */
14
+ export const hasTag =
15
+ (tag: string): ProcedureFilter =>
16
+ ({ contract }) =>
17
+ procedureDefinition(contract).route?.tags?.includes(tag) ?? false
4
18
 
5
19
  /**
6
20
  * Key under which `fn()` stores its metadata in oRPC's procedure meta
@@ -18,6 +32,10 @@ export type StoredFnMeta<
18
32
  procedure: TKey
19
33
  meta: TMeta
20
34
  stream: boolean
35
+ /** Guard values set on the route. */
36
+ guards?: Record<string, unknown>
37
+ /** Guards decided per call through `guardResolvers`. */
38
+ dynamicGuards?: readonly string[]
21
39
  }
22
40
 
23
41
  export type FnMeta<
@@ -30,7 +48,7 @@ export type FnMeta<
30
48
  procedure: TKey | undefined
31
49
  /** The app's typed meta (`createFn({ meta })`) declared on this route. */
32
50
  meta: Partial<TMeta>
33
- /** Route summary, falling back to description, falling back to ''. */
51
+ /** Route description, falling back to summary, falling back to ''. */
34
52
  description: string
35
53
  summary: string | undefined
36
54
  method: string | undefined
@@ -39,6 +57,10 @@ export type FnMeta<
39
57
  tags: readonly string[]
40
58
  inputSchema: AnySchema | undefined
41
59
  outputSchema: AnySchema | undefined
60
+ /** Guard values set on the route (`permission: 'deal.read'`). */
61
+ guards: Record<string, unknown>
62
+ /** Guards the route decides per call through `guardResolvers`. */
63
+ dynamicGuards: readonly string[]
42
64
  }
43
65
 
44
66
  /** Read what `fn()` (and oRPC) recorded on a procedure. */
@@ -69,7 +91,7 @@ export function readFnMeta<T extends AnyProcedure>(
69
91
  name: stored?.name,
70
92
  procedure: stored?.procedure as KeyFrom<T> | undefined,
71
93
  meta: (stored?.meta ?? {}) as Partial<MetaFrom<T>>,
72
- description: route.summary ?? route.description ?? '',
94
+ description: route.description ?? route.summary ?? '',
73
95
  summary: route.summary,
74
96
  method: route.method,
75
97
  path: route.path,
@@ -78,7 +100,9 @@ export function readFnMeta<T extends AnyProcedure>(
78
100
  stored?.stream === true ||
79
101
  getEventIteratorSchemaDetails(def.outputSchema) !== undefined,
80
102
  inputSchema: def.inputSchema,
81
- outputSchema: def.outputSchema
103
+ outputSchema: def.outputSchema,
104
+ guards: stored?.guards ?? {},
105
+ dynamicGuards: stored?.dynamicGuards ?? []
82
106
  }
83
107
  }
84
108
 
package/src/otel.ts CHANGED
@@ -83,7 +83,15 @@ export type Tracing = {
83
83
  end(span: SpanLike | undefined, error?: unknown): void
84
84
  }
85
85
 
86
- export function createTracing(otel: OtelApiLike | undefined): Tracing {
86
+ export function createTracing(
87
+ otel: OtelApiLike | undefined,
88
+ /**
89
+ * Errors the caller drives (4xx, aborts). Their span keeps an unset status
90
+ * and no exception event, as OpenTelemetry asks of server spans, so the
91
+ * caller's text stays out of the trace; `fn.error_code` still names them.
92
+ */
93
+ isExpectedError: (error: unknown) => boolean = () => false
94
+ ): Tracing {
87
95
  let tracer: TracerLike<SpanLike> | undefined
88
96
  try {
89
97
  tracer = otel?.trace.getTracer('orpc-fn')
@@ -96,11 +104,21 @@ export function createTracing(otel: OtelApiLike | undefined): Tracing {
96
104
  if (error.length === 0) {
97
105
  span.setStatus({ code: otel.SpanStatusCode.OK })
98
106
  } else {
99
- span.setStatus({
100
- code: otel.SpanStatusCode.ERROR,
101
- message: errorMessageOf(error[0])
102
- })
103
- if (error[0] instanceof Error) span.recordException(error[0])
107
+ const code = errorCodeOf(error[0])
108
+ if (code !== undefined) span.setAttribute('fn.error_code', code)
109
+ let expected = false
110
+ try {
111
+ expected = isExpectedError(error[0])
112
+ } catch {
113
+ /* A failing classifier treats the error as unexpected. */
114
+ }
115
+ if (!expected) {
116
+ span.setStatus({
117
+ code: otel.SpanStatusCode.ERROR,
118
+ message: errorMessageOf(error[0])
119
+ })
120
+ if (error[0] instanceof Error) span.recordException(error[0])
121
+ }
104
122
  }
105
123
  span.end()
106
124
  }
@@ -195,11 +213,25 @@ export function errorMessageOf(error: unknown): string {
195
213
  return error instanceof Error ? error.message : String(error)
196
214
  }
197
215
 
198
- /** Error fields for a log line, named like the `fn.completed` ones. */
199
- export function errorAttributes(error: unknown) {
216
+ /** An error's string `code` (`ORPCError`'s `NOT_FOUND`, Node's `ECONNRESET`). */
217
+ export function errorCodeOf(error: unknown): string | undefined {
218
+ const code =
219
+ typeof error === 'object' && error !== null
220
+ ? (error as { code?: unknown }).code
221
+ : undefined
222
+ return typeof code === 'string' ? code : undefined
223
+ }
224
+
225
+ /**
226
+ * Error fields for a log line, named like the `fn.completed` ones. Without
227
+ * `message`, the error's text (written for the caller) is left out.
228
+ */
229
+ export function errorAttributes(error: unknown, message = true) {
230
+ const code = errorCodeOf(error)
200
231
  return {
201
232
  error_type: error instanceof Error ? error.name : typeof error,
202
- error_message: errorMessageOf(error)
233
+ ...(code === undefined ? {} : { error_code: code }),
234
+ ...(message ? { error_message: errorMessageOf(error) } : {})
203
235
  }
204
236
  }
205
237
 
package/src/react.ts CHANGED
@@ -18,13 +18,17 @@ import {
18
18
  type SubscriptionRunner,
19
19
  seedQueryFn
20
20
  } from './realtime.js'
21
+ import { streamCall } from './stream-call.js'
21
22
 
22
23
  export type { InvalidationDomain, LiveConnectionStatus } from './realtime.js'
23
24
 
24
- /** An oRPC client procedure fits: `client.order.watch`. */
25
+ /**
26
+ * An oRPC client procedure fits: `client.order.watch`. It gets `streamCall`
27
+ * as its client context, which keeps it out of `createRpcLink`'s batches.
28
+ */
25
29
  export type SubscribeFn<TInput, TEvent> = (
26
30
  input: TInput,
27
- options: { signal: AbortSignal }
31
+ options: { signal: AbortSignal; context: typeof streamCall }
28
32
  ) => Promise<AsyncIterable<TEvent>>
29
33
 
30
34
  export type UseOrpcSubscriptionOptions<TInput, TEvent> = {
@@ -78,7 +82,10 @@ export function useOrpcSubscription<TInput, TEvent>(
78
82
  runnerRef.current = createSubscriptionRunner<TEvent>({
79
83
  open: (signal) => {
80
84
  openedInput = latest.current.input
81
- return latest.current.subscribe(openedInput, { signal })
85
+ return latest.current.subscribe(openedInput, {
86
+ signal,
87
+ context: streamCall
88
+ })
82
89
  },
83
90
  canConnect: () => latest.current.shouldRun,
84
91
  shouldReconnect: () => latest.current.shouldRun,
@@ -72,6 +72,14 @@ export function normalizeRoute(
72
72
  throw new TypeError(
73
73
  'orpc-fn: an event iterator schema cannot declare stream: false'
74
74
  )
75
+ if (
76
+ stream !== true &&
77
+ !iterator &&
78
+ handler.constructor?.name === 'AsyncGeneratorFunction'
79
+ )
80
+ throw new TypeError(
81
+ `orpc-fn: ${name} has an async generator handler; declare stream: true`
82
+ )
75
83
  for (const [guard, resolver] of Object.entries(guardResolvers)) {
76
84
  if (!isGuard(guard))
77
85
  throw new TypeError(`orpc-fn: unknown guard resolver "${guard}"`)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Client-context key the `orpc-fn/vue` and `orpc-fn/react` stream helpers set,
3
+ * so `createRpcLink` keeps the call out of a batch without a stream manifest.
4
+ * Shared across package copies.
5
+ */
6
+ export const STREAM_CALL: unique symbol = Symbol.for('orpc-fn.streamCall')
7
+
8
+ /**
9
+ * Pass as the client context of a stream opened by hand:
10
+ * `client.metrics.run(input, { signal, context: streamCall })`.
11
+ */
12
+ export const streamCall = { [STREAM_CALL]: true } as const
13
+
14
+ export const isStreamCall = (context: object) =>
15
+ (context as { [STREAM_CALL]?: unknown })[STREAM_CALL] === true
package/src/timing.ts CHANGED
@@ -3,3 +3,6 @@ export const REQUEST_TIMING = Symbol('orpc-fn.requestTiming')
3
3
  export type InvocationTiming = { procedureMs: number; calls: number }
4
4
 
5
5
  export const RAW_INPUT = Symbol('orpc-fn.rawInput')
6
+
7
+ /** Extras a `createTestCall` replaces, carried through nested calls. */
8
+ export const EXTRAS_OVERRIDE = Symbol('orpc-fn.extrasOverride')
@@ -0,0 +1,52 @@
1
+ import { isExpectedClientError } from './expected-client-error.js'
2
+ import { formatIssue, issuesOf } from './issues.js'
3
+ import { errorCodeOf, errorMessageOf } from './otel.js'
4
+
5
+ /** How a tool adapter turns a failed call into text for the model. */
6
+ export type ToolErrorOptions = {
7
+ /**
8
+ * Errors the model may read. Default: `isExpectedClientError` (4xx
9
+ * `ORPCError`, aborts, and whatever the factory's `isExpectedError` accepted).
10
+ */
11
+ isExpectedError?: (error: unknown) => boolean
12
+ /** Unexpected errors, whose text the model does not see. Report them here. */
13
+ onError?: (error: unknown, info: { tool: string }) => void
14
+ /** Replace the text the model reads. */
15
+ formatError?: (
16
+ error: unknown,
17
+ info: { tool: string; expected: boolean }
18
+ ) => string
19
+ }
20
+
21
+ /**
22
+ * An expected error reads `CODE: message`, then one `path: message` line per
23
+ * validation issue, so the model can fix its arguments. Anything else reads
24
+ * as a fixed sentence: its text may hold internals and goes to `onError`.
25
+ */
26
+ export function toolErrorText(
27
+ error: unknown,
28
+ tool: string,
29
+ options: ToolErrorOptions
30
+ ): string {
31
+ let expected = false
32
+ try {
33
+ expected = (options.isExpectedError ?? isExpectedClientError)(error)
34
+ } catch {
35
+ /* A failing classifier hides the error. */
36
+ }
37
+ if (!expected) {
38
+ try {
39
+ options.onError?.(error, { tool })
40
+ } catch {
41
+ /* Reporting cannot change the tool's answer. */
42
+ }
43
+ }
44
+ if (options.formatError) return options.formatError(error, { tool, expected })
45
+ if (!expected) return `${tool} failed unexpectedly. The error was reported.`
46
+ const code = errorCodeOf(error)
47
+ const message = errorMessageOf(error)
48
+ return [
49
+ code ? `${code}: ${message}` : message,
50
+ ...issuesOf(error).map(formatIssue)
51
+ ].join('\n')
52
+ }
package/src/types.ts CHANGED
@@ -122,6 +122,8 @@ export type BuildProcedure = (options: {
122
122
  procedure: string | undefined
123
123
  route: Record<string, unknown>
124
124
  meta: Record<string, unknown>
125
+ guards?: Record<string, unknown>
126
+ dynamicGuards?: readonly string[]
125
127
  input?: AnySchema | undefined
126
128
  output?: AnySchema | undefined
127
129
  stream?: boolean
package/src/vue.ts CHANGED
@@ -30,12 +30,13 @@ import {
30
30
  type MaybeRefOrGetter,
31
31
  onScopeDispose,
32
32
  type Ref,
33
+ reactive,
33
34
  ref,
34
35
  shallowRef,
35
36
  toValue,
36
37
  watch
37
38
  } from 'vue'
38
- import { userFacingErrorMessage } from './client.js'
39
+ import { streamCall, userFacingErrorMessage } from './client.js'
39
40
  import {
40
41
  createInvalidationCoordinator,
41
42
  createStreamScope,
@@ -93,17 +94,23 @@ const missingOrpc = () =>
93
94
  'orpc-fn/vue: no oRPC client provided. Call provideOrpc(app, { client, utils }) or pass `orpc` to the composable.'
94
95
  )
95
96
 
96
- /** The client and utils from {@link provideOrpc}. */
97
- export function useOrpc(): {
97
+ type RegisteredOrpc = {
98
98
  client: RegisteredOrpcClient
99
99
  utils: RegisteredOrpcUtils
100
- } {
100
+ }
101
+
102
+ /**
103
+ * The client and utils from {@link provideOrpc}. Throws when none was
104
+ * provided, unless `optional`, which returns null instead.
105
+ */
106
+ export function useOrpc(options: { optional: true }): RegisteredOrpc | null
107
+ export function useOrpc(options?: { optional?: false }): RegisteredOrpc
108
+ export function useOrpc(options?: {
109
+ optional?: boolean
110
+ }): RegisteredOrpc | null {
101
111
  const provided = hasInjectionContext() ? inject(orpcKey, null) : null
102
- if (!provided) throw missingOrpc()
103
- return provided as {
104
- client: RegisteredOrpcClient
105
- utils: RegisteredOrpcUtils
106
- }
112
+ if (!provided && !options?.optional) throw missingOrpc()
113
+ return provided as RegisteredOrpc | null
107
114
  }
108
115
 
109
116
  /** Resolve the provided utils now; fail only when they are needed and missing. */
@@ -123,7 +130,7 @@ let documentVisible: Ref<boolean> | null = null
123
130
  * Whether the page is visible, from TanStack's `focusManager` (which follows
124
131
  * `visibilitychange` in browsers). Live streams pause while it is false.
125
132
  */
126
- function useDocumentVisible(): Ref<boolean> {
133
+ export function useDocumentVisible(): Ref<boolean> {
127
134
  if (!documentVisible) {
128
135
  const visible = ref(focusManager.isFocused())
129
136
  focusManager.subscribe((focused) => {
@@ -142,13 +149,15 @@ function resolveQueryEnabled(enabled: unknown): boolean {
142
149
 
143
150
  /**
144
151
  * Picks the stream to open from the utils. A procedure's `call` fits as is:
145
- * `(o) => o.order.watch.call`. The composable passes the input and an abort signal.
152
+ * `(o) => o.order.watch.call`. The composable passes the input, an abort
153
+ * signal and `streamCall` as the client context, which keeps the call out of
154
+ * `createRpcLink`'s batches.
146
155
  */
147
156
  export type OrpcSubscribeFn<TInput, TEvent, TOrpc = RegisteredOrpcUtils> = (
148
157
  orpc: TOrpc
149
158
  ) => (
150
159
  input: TInput,
151
- options: { signal: AbortSignal }
160
+ options: { signal: AbortSignal; context: typeof streamCall }
152
161
  ) => Promise<AsyncIterable<TEvent>>
153
162
 
154
163
  export interface UseSubscriptionOptions<TEvent, TOrpc = RegisteredOrpcUtils> {
@@ -226,7 +235,8 @@ export function useOrpcSubscription<
226
235
  const error = ref<Error | null>(null)
227
236
 
228
237
  const runner = createSubscriptionRunner<TEvent>({
229
- open: (signal) => subscribeFn(getUtils())(toValue(input), { signal }),
238
+ open: (signal) =>
239
+ subscribeFn(getUtils())(toValue(input), { signal, context: streamCall }),
230
240
  canConnect: () => isStreamAllowed.value,
231
241
  shouldReconnect: () => autoReconnectOnClose && shouldAutoConnect.value,
232
242
  onStatus: (next) => {
@@ -393,7 +403,9 @@ function useBoundApiClient<TOrpc>(orpc: MaybeRefOrGetter<TOrpc> | undefined) {
393
403
 
394
404
  /**
395
405
  * With `initialInput`, also returns `inputData`, a ref that `mutate()` and
396
- * `mutateAsync()` use when called without variables (form state).
406
+ * `mutateAsync()` use when called without variables (form state). With
407
+ * `invalidates`, a success invalidates those keys, awaited before the
408
+ * options' own `onSuccess`: `{ invalidates: (o) => [o.order.key()] }`.
397
409
  */
398
410
  // The input type comes from the route alone; `initialInput` is only checked
399
411
  // against it. `unknown`, not `void`, lets `initialInput` pass the first
@@ -407,7 +419,10 @@ function useBoundApiClient<TOrpc>(orpc: MaybeRefOrGetter<TOrpc> | undefined) {
407
419
  optionsFn: (
408
420
  orpc: TOrpc
409
421
  ) => MutationObserverOptions<TData, TError, TVariables, TContext>,
410
- config: { initialInput: NoInfer<TVariables> }
422
+ config: {
423
+ initialInput: NoInfer<TVariables>
424
+ invalidates?: (orpc: TOrpc) => readonly QueryKey[]
425
+ }
411
426
  ): UseMutationReturnType<TData, TError, TVariables, TContext> & {
412
427
  inputData: Ref<TVariables>
413
428
  mutate: (variables?: TVariables) => void
@@ -421,17 +436,41 @@ function useBoundApiClient<TOrpc>(orpc: MaybeRefOrGetter<TOrpc> | undefined) {
421
436
  >(
422
437
  optionsFn: (
423
438
  orpc: TOrpc
424
- ) => MutationObserverOptions<TData, TError, TVariables, TContext>
439
+ ) => MutationObserverOptions<TData, TError, TVariables, TContext>,
440
+ config?: { invalidates?: (orpc: TOrpc) => readonly QueryKey[] }
425
441
  ): UseMutationReturnType<TData, TError, TVariables, TContext>
426
442
  function useMutation<TData, TError, TVariables, TContext>(
427
443
  optionsFn: (
428
444
  orpc: TOrpc
429
445
  ) => MutationObserverOptions<TData, TError, TVariables, TContext>,
430
- config?: { initialInput: TVariables }
446
+ config?: {
447
+ initialInput?: TVariables
448
+ invalidates?: (orpc: TOrpc) => readonly QueryKey[]
449
+ }
431
450
  ) {
432
- const mutation = useVueMutation(computed(() => optionsFn(getO())))
451
+ const invalidates = config?.invalidates
452
+ const mutation = useVueMutation(
453
+ computed(() => {
454
+ const options = optionsFn(getO())
455
+ if (!invalidates) return options
456
+ return {
457
+ ...options,
458
+ onSuccess: async (
459
+ ...args: Parameters<NonNullable<typeof options.onSuccess>>
460
+ ) => {
461
+ await Promise.all(
462
+ invalidates(getO()).map((queryKey) =>
463
+ queryClient.invalidateQueries({ queryKey })
464
+ )
465
+ )
466
+ return options.onSuccess?.(...args)
467
+ }
468
+ }
469
+ })
470
+ )
433
471
  if (config?.initialInput === undefined) return mutation
434
- const inputData = ref(config.initialInput) as Ref<TVariables>
472
+ const initialInput = config.initialInput
473
+ const inputData = ref(initialInput) as Ref<TVariables>
435
474
  return {
436
475
  ...mutation,
437
476
  inputData,
@@ -796,3 +835,37 @@ export function useQueryErrorMessage(
796
835
  return err ? userFacingErrorMessage(err, toValue(fallback)) : null
797
836
  })
798
837
  }
838
+
839
+ /** The parts of a query `useQueryState` reads. */
840
+ export type QueryStateSource = {
841
+ status: Ref<'pending' | 'error' | 'success'>
842
+ error: Ref<unknown>
843
+ isFetching: Ref<boolean>
844
+ failureCount: Ref<number>
845
+ refetch: () => Promise<unknown>
846
+ }
847
+
848
+ /**
849
+ * A query as the props a list or loading component takes, in one reactive
850
+ * object: `<ListStates v-bind="useQueryState(query, { fallback })" />`.
851
+ * `errorMessage` follows {@link useQueryErrorMessage}.
852
+ */
853
+ export function useQueryState(
854
+ query: QueryStateSource,
855
+ options: { fallback: MaybeRefOrGetter<string> }
856
+ ) {
857
+ const errorMessage = useQueryErrorMessage(query.error, options.fallback)
858
+ return reactive({
859
+ status: computed(() => query.status.value),
860
+ error: computed(() => query.error.value),
861
+ errorMessage,
862
+ isRetrying: computed(
863
+ () =>
864
+ query.isFetching.value &&
865
+ (query.status.value === 'error' || query.failureCount.value > 0)
866
+ ),
867
+ retry: () => {
868
+ void query.refetch()
869
+ }
870
+ })
871
+ }