orpc-fn 0.2.0 → 0.4.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 (55) hide show
  1. package/README.md +28 -8
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +56 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/create-fn.d.ts +3 -3
  7. package/dist/create-fn.d.ts.map +1 -1
  8. package/dist/create-fn.js +2 -7
  9. package/dist/create-fn.js.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/live/fn-live.d.ts +9 -5
  15. package/dist/live/fn-live.d.ts.map +1 -1
  16. package/dist/live/fn-live.js.map +1 -1
  17. package/dist/live/pub-sub.d.ts +5 -1
  18. package/dist/live/pub-sub.d.ts.map +1 -1
  19. package/dist/live/pub-sub.js +29 -14
  20. package/dist/live/pub-sub.js.map +1 -1
  21. package/dist/mcp/core.d.ts +41 -0
  22. package/dist/mcp/core.d.ts.map +1 -0
  23. package/dist/mcp/core.js +61 -0
  24. package/dist/mcp/core.js.map +1 -0
  25. package/dist/mcp/sdk.d.ts +8 -16
  26. package/dist/mcp/sdk.d.ts.map +1 -1
  27. package/dist/mcp/sdk.js +10 -78
  28. package/dist/mcp/sdk.js.map +1 -1
  29. package/dist/mcp/server.d.ts +11 -0
  30. package/dist/mcp/server.d.ts.map +1 -0
  31. package/dist/mcp/server.js +13 -0
  32. package/dist/mcp/server.js.map +1 -0
  33. package/dist/otel.d.ts +5 -0
  34. package/dist/otel.d.ts.map +1 -1
  35. package/dist/otel.js +7 -0
  36. package/dist/otel.js.map +1 -1
  37. package/dist/router.d.ts +8 -0
  38. package/dist/router.d.ts.map +1 -1
  39. package/dist/router.js +13 -0
  40. package/dist/router.js.map +1 -1
  41. package/dist/types.d.ts +16 -6
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/package.json +15 -3
  45. package/src/cli.ts +68 -0
  46. package/src/create-fn.ts +5 -10
  47. package/src/index.ts +2 -1
  48. package/src/live/fn-live.ts +9 -5
  49. package/src/live/pub-sub.ts +38 -19
  50. package/src/mcp/core.ts +102 -0
  51. package/src/mcp/sdk.ts +20 -102
  52. package/src/mcp/server.ts +35 -0
  53. package/src/otel.ts +8 -0
  54. package/src/router.ts +19 -0
  55. package/src/types.ts +24 -42
@@ -66,10 +66,14 @@ export type FnLiveConfig<
66
66
  ChannelQueueOptions & {
67
67
  /** Subscribe route name; default `${name}.live`. */
68
68
  name?: string
69
- /** Validates parsed reducer state without reapplying an output transform. Required with a transformer. */
70
- stateSchema?: Schema<TOutput, TOutput>
69
+ /**
70
+ * Validates reducer state without reapplying an output transform. Required with a
71
+ * transformer. Its input may be looser than its output (e.g. branded IDs); it is
72
+ * checked at runtime.
73
+ */
74
+ stateSchema?: Schema<unknown, TOutput>
71
75
  /** Validates patch payloads. Required when a transformer returns fnLivePatch. */
72
- emitSchema?: Schema<TEmit, TEmit>
76
+ emitSchema?: Schema<unknown, TEmit>
73
77
  /** Optional access check before each update; idle streams keep their initial authorization. */
74
78
  reauthorize?: AuthFn<TInput, FnContext<TDef, TKey>>
75
79
  eventSchema: TEventSchema
@@ -122,10 +126,10 @@ export type FnLiveConfig<
122
126
  TEmit,
123
127
  TErrors
124
128
  >
125
- stateSchema: Schema<TOutput, TOutput>
129
+ stateSchema: Schema<unknown, TOutput>
126
130
  }
127
131
  ) &
128
- ([TEmit] extends [never] ? object : { emitSchema: Schema<TEmit, TEmit> })
132
+ ([TEmit] extends [never] ? object : { emitSchema: Schema<unknown, TEmit> })
129
133
 
130
134
  type FnLiveConfigTransformer<
131
135
  TDef extends FnDefinition,
@@ -2,7 +2,12 @@ import type { Schema } from '@orpc/contract'
2
2
  import { ORPCError } from '@orpc/server'
3
3
  import type { ZodObject, ZodRawShape, ZodType, z } from 'zod'
4
4
  import type { FnLogger } from '../logger.js'
5
- import { errorMessageOf, type SpanLike, type Tracing } from '../otel.js'
5
+ import {
6
+ errorAttributes,
7
+ errorMessageOf,
8
+ type SpanLike,
9
+ type Tracing
10
+ } from '../otel.js'
6
11
  import type {
7
12
  GuardOptions,
8
13
  FnContext,
@@ -204,7 +209,11 @@ export type CreatePublisher = <TEventSchema extends ZodType>(
204
209
  export type PubSubRuntimeOptions = {
205
210
  /** Queue depths, drops, successful reconnects and parse failures. */
206
211
  onMetric?: (event: PubSubMetric) => void
207
- transport: PubSubTransport
212
+ /**
213
+ * The broker, or a function creating it on first use so importing routes
214
+ * opens no connection: `transport: () => bunRedisTransport(getRedis())`.
215
+ */
216
+ transport: PubSubTransport | (() => PubSubTransport)
208
217
  /**
209
218
  * Called per event dropped from ingress, replay or delivery queues.
210
219
  * Delivery drops count events before subscriber filtering.
@@ -525,13 +534,19 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
525
534
  // the client's backoff reconnects to the new deployment.
526
535
  const activeSubscriberCleanups = new Set<() => void>()
527
536
 
537
+ let transport: PubSubTransport | undefined
528
538
  const requireTransport = () => {
529
539
  if (!options.pubsub) {
530
540
  throw new Error(
531
541
  'orpc-fn: live routes need a transport, pass createFn({ pubsub: { transport } })'
532
542
  )
533
543
  }
534
- return options.pubsub.transport
544
+ if (transport) return transport
545
+ // A lazy transport is never created during or after shutdown.
546
+ assertOpen()
547
+ const configured = options.pubsub.transport
548
+ transport = typeof configured === 'function' ? configured() : configured
549
+ return transport
535
550
  }
536
551
 
537
552
  /**
@@ -696,8 +711,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
696
711
  name: config.name,
697
712
  channel
698
713
  })
699
- logger.warn(`Dropped malformed message on ${channel}`, {
700
- error: errorMessageOf(error)
714
+ logger.warn('pubsub.message_dropped', {
715
+ channel,
716
+ ...errorAttributes(error)
701
717
  })
702
718
  }
703
719
  }
@@ -711,7 +727,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
711
727
  if (closed || timer) return
712
728
  const delayMs = getResubscribeDelayMs(attempt)
713
729
  attempt++
714
- logger.warn(`Scheduling resubscribe for ${channel}`, {
730
+ logger.warn('pubsub.resubscribe_scheduled', {
731
+ channel,
715
732
  reason,
716
733
  attempt,
717
734
  delayMs
@@ -739,8 +756,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
739
756
  connected = false
740
757
  markPending()
741
758
  }
742
- logger.error(`Subscription to ${channel} lost`, {
743
- error: error.message
759
+ logger.error('pubsub.subscription_lost', {
760
+ channel,
761
+ ...errorAttributes(error)
744
762
  })
745
763
  scheduleResubscribe('lost')
746
764
  },
@@ -774,10 +792,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
774
792
  connectedOnce = true
775
793
  markReady()
776
794
  attempt = 0
777
- logger.info(`Subscribed to ${channel}`)
795
+ logger.debug('pubsub.subscribed', { channel })
778
796
  } catch (error) {
779
- logger.error(`Failed to subscribe to ${channel}`, {
780
- error: errorMessageOf(error)
797
+ logger.error('pubsub.subscribe_failed', {
798
+ channel,
799
+ ...errorAttributes(error)
781
800
  })
782
801
  scheduleResubscribe('subscribe-failed')
783
802
  }
@@ -916,7 +935,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
916
935
  throw error
917
936
  }
918
937
 
919
- subscriberLogger.info(`Subscribing to ${channelName}`)
938
+ subscriberLogger.debug('pubsub.subscribing', { channel: channelName })
920
939
 
921
940
  let messageCount = 0
922
941
  let droppedCount = 0
@@ -1041,7 +1060,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1041
1060
  span?.setAttribute('pubsub.message_count', messageCount)
1042
1061
  span?.setAttribute('pubsub.dropped_count', droppedCount)
1043
1062
  tracing.end(span, ...error)
1044
- subscriberLogger.info(`Unsubscribed from ${channelName}`, {
1063
+ subscriberLogger.debug('pubsub.unsubscribed', {
1064
+ channel: channelName,
1045
1065
  messageCount,
1046
1066
  droppedCount,
1047
1067
  durationMs: Math.round(durationMs)
@@ -1096,10 +1116,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1096
1116
  if (items.length > maxReplay) replayGap = true
1097
1117
  } catch (error) {
1098
1118
  replayGap = true
1099
- subscriberLogger.warn(
1100
- `Failed to replay backlog for ${channelName}`,
1101
- { error: errorMessageOf(error) }
1102
- )
1119
+ subscriberLogger.warn('pubsub.replay_failed', {
1120
+ channel: channelName,
1121
+ ...errorAttributes(error)
1122
+ })
1103
1123
  }
1104
1124
  }
1105
1125
  for (const { data, id } of replaying ?? [])
@@ -1241,8 +1261,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1241
1261
  shutdownPromise = (async () => {
1242
1262
  await Promise.allSettled([...pendingOpens])
1243
1263
  await Promise.all([...releases])
1244
- if (options.pubsub?.ownsTransport)
1245
- await options.pubsub.transport.close?.()
1264
+ if (options.pubsub?.ownsTransport) await transport?.close?.()
1246
1265
  })()
1247
1266
  return shutdownPromise
1248
1267
  },
@@ -0,0 +1,102 @@
1
+ import {
2
+ type AnyRouter,
3
+ type InferRouterInitialContext,
4
+ call
5
+ } from '@orpc/server'
6
+ import { isObjectJsonSchema } from '../json-schema.js'
7
+ import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
8
+
9
+ /** Text-only tool result: the shape both MCP SDK majors accept. */
10
+ export type TextToolResult = {
11
+ content: { type: 'text'; text: string }[]
12
+ isError?: boolean
13
+ }
14
+
15
+ export type RegisterMcpToolsOptions<R extends AnyRouter, TResult> = {
16
+ /** Tool exposure must be an explicit application choice. */
17
+ filter: ProcedureFilter
18
+ context: (request: {
19
+ name: string
20
+ signal: AbortSignal
21
+ }) => InferRouterInitialContext<R> | Promise<InferRouterInitialContext<R>>
22
+ readOnly?: ProcedureFilter
23
+ name?: NonNullable<Parameters<typeof listTools>[1]>['name']
24
+ formatResult?: (value: unknown, tool: McpTool) => TResult | Promise<TResult>
25
+ }
26
+
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 }] }
36
+ }
37
+
38
+ const errorResult = (text: string): TextToolResult => ({
39
+ content: [{ type: 'text', text }],
40
+ isError: true
41
+ })
42
+
43
+ /** @internal SDK-independent `tools/list` and `tools/call` behind both adapters. */
44
+ export function createToolHandlers<R extends AnyRouter, TResult>(
45
+ router: R,
46
+ options: RegisterMcpToolsOptions<R, TResult>
47
+ ) {
48
+ const definitions = listTools(router, {
49
+ filter: options.filter,
50
+ ...(options.readOnly ? { readOnly: options.readOnly } : {}),
51
+ ...(options.name ? { name: options.name } : {})
52
+ })
53
+ const tools = new Map(definitions.map((tool) => [tool.name, tool]))
54
+ const descriptions = definitions.map((tool) => {
55
+ const inputSchema = tool.config.inputSchema?.[
56
+ '~standard'
57
+ ].jsonSchema.input() ?? { type: 'object', properties: {} }
58
+ if (!isObjectJsonSchema(inputSchema))
59
+ throw new TypeError(
60
+ `orpc-fn: MCP tool ${tool.name} needs an object input`
61
+ )
62
+ // MCP requires a top-level object; object unions retain their constraints.
63
+ return {
64
+ name: tool.name,
65
+ ...tool.config,
66
+ inputSchema: { ...inputSchema, type: 'object' as const }
67
+ }
68
+ })
69
+ return {
70
+ names: [...tools.keys()] as readonly string[],
71
+ list: () => ({ tools: descriptions }),
72
+ call: async (
73
+ name: string,
74
+ args: Record<string, unknown> | undefined,
75
+ signal: AbortSignal
76
+ ): Promise<TResult | TextToolResult> => {
77
+ const tool = tools.get(name)
78
+ if (!tool) return errorResult(`Unknown tool: ${name}`)
79
+ try {
80
+ signal.throwIfAborted()
81
+ const validation = await tool.config.inputSchema?.[
82
+ '~standard'
83
+ ].validate(args ?? {})
84
+ if (validation?.issues)
85
+ return errorResult(
86
+ validation.issues.map((issue) => issue.message).join('; ')
87
+ )
88
+ const context = await options.context({ name: tool.name, signal })
89
+ const result = await call(tool.procedure, validation?.value, {
90
+ context,
91
+ signal
92
+ })
93
+ return await (options.formatResult ?? formatMcpResult)(result, tool)
94
+ } catch (error) {
95
+ if (signal.aborted) throw error
96
+ return errorResult(
97
+ error instanceof Error ? error.message : String(error)
98
+ )
99
+ }
100
+ }
101
+ }
102
+ }
package/src/mcp/sdk.ts CHANGED
@@ -4,115 +4,33 @@ import {
4
4
  ListToolsRequestSchema,
5
5
  type CallToolResult
6
6
  } from '@modelcontextprotocol/sdk/types.js'
7
+ import type { AnyRouter } from '@orpc/server'
7
8
  import {
8
- type AnyRouter,
9
- type InferRouterInitialContext,
10
- call
11
- } from '@orpc/server'
12
- import { isObjectJsonSchema } from '../json-schema.js'
13
- import { listTools, type McpTool, type ProcedureFilter } from '../mcp.js'
9
+ createToolHandlers,
10
+ formatMcpResult,
11
+ type RegisterMcpToolsOptions as CoreOptions
12
+ } from './core.js'
14
13
 
15
- export type RegisterMcpToolsOptions<R extends AnyRouter> = {
16
- /** Tool exposure must be an explicit application choice. */
17
- filter: ProcedureFilter
18
- context: (request: {
19
- name: string
20
- signal: AbortSignal
21
- }) => InferRouterInitialContext<R> | Promise<InferRouterInitialContext<R>>
22
- readOnly?: ProcedureFilter
23
- name?: NonNullable<Parameters<typeof listTools>[1]>['name']
24
- formatResult?: (
25
- value: unknown,
26
- tool: McpTool
27
- ) => CallToolResult | Promise<CallToolResult>
28
- }
14
+ export { formatMcpResult }
29
15
 
30
- /** Default protocol result: JSON/text. Provide formatResult for other MCP content. */
31
- export function formatMcpResult(value: unknown): CallToolResult {
32
- const text =
33
- typeof value === 'string'
34
- ? value
35
- : (JSON.stringify(value, (_key, item) =>
36
- typeof item === 'bigint' ? item.toString() : item
37
- ) ?? 'null')
38
- return { content: [{ type: 'text', text }] }
39
- }
16
+ export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
17
+ R,
18
+ CallToolResult
19
+ >
40
20
 
41
- /** Register on the SDK's low-level Server, or on McpServer.server. */
21
+ /**
22
+ * Register on the SDK v1 low-level Server, or on McpServer.server. For SDK v2
23
+ * (`@modelcontextprotocol/server`) use `orpc-fn/mcp/server`.
24
+ */
42
25
  export function registerMcpTools<R extends AnyRouter>(
43
26
  server: Pick<Server, 'setRequestHandler'>,
44
27
  router: R,
45
28
  options: RegisterMcpToolsOptions<R>
46
29
  ): readonly string[] {
47
- const definitions = listTools(router, {
48
- filter: options.filter,
49
- ...(options.readOnly ? { readOnly: options.readOnly } : {}),
50
- ...(options.name ? { name: options.name } : {})
51
- })
52
- const tools = new Map(definitions.map((tool) => [tool.name, tool]))
53
- const descriptions = definitions.map((tool) => {
54
- const inputSchema = tool.config.inputSchema?.[
55
- '~standard'
56
- ].jsonSchema.input() ?? { type: 'object', properties: {} }
57
- if (!isObjectJsonSchema(inputSchema))
58
- throw new TypeError(
59
- `orpc-fn: MCP tool ${tool.name} needs an object input`
60
- )
61
- // MCP requires a top-level object; object unions retain their constraints.
62
- return {
63
- name: tool.name,
64
- ...tool.config,
65
- inputSchema: { ...inputSchema, type: 'object' as const }
66
- }
67
- })
68
- server.setRequestHandler(ListToolsRequestSchema, () => ({
69
- tools: descriptions
70
- }))
71
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
72
- const tool = tools.get(request.params.name)
73
- if (!tool)
74
- return {
75
- content: [
76
- { type: 'text', text: `Unknown tool: ${request.params.name}` }
77
- ],
78
- isError: true
79
- }
80
- try {
81
- extra.signal.throwIfAborted()
82
- const validation = await tool.config.inputSchema?.['~standard'].validate(
83
- request.params.arguments ?? {}
84
- )
85
- if (validation?.issues)
86
- return {
87
- content: [
88
- {
89
- type: 'text',
90
- text: validation.issues.map((issue) => issue.message).join('; ')
91
- }
92
- ],
93
- isError: true
94
- }
95
- const context = await options.context({
96
- name: tool.name,
97
- signal: extra.signal
98
- })
99
- const result = await call(tool.procedure, validation?.value, {
100
- context,
101
- signal: extra.signal
102
- })
103
- return await (options.formatResult ?? formatMcpResult)(result, tool)
104
- } catch (error) {
105
- if (extra.signal.aborted) throw error
106
- return {
107
- content: [
108
- {
109
- type: 'text',
110
- text: error instanceof Error ? error.message : String(error)
111
- }
112
- ],
113
- isError: true
114
- }
115
- }
116
- })
117
- return [...tools.keys()]
30
+ const handlers = createToolHandlers(router, options)
31
+ server.setRequestHandler(ListToolsRequestSchema, handlers.list)
32
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) =>
33
+ handlers.call(request.params.name, request.params.arguments, extra.signal)
34
+ )
35
+ return handlers.names
118
36
  }
@@ -0,0 +1,35 @@
1
+ import type { CallToolResult, Server } from '@modelcontextprotocol/server'
2
+ import type { AnyRouter } from '@orpc/server'
3
+ import {
4
+ createToolHandlers,
5
+ formatMcpResult,
6
+ type RegisterMcpToolsOptions as CoreOptions
7
+ } from './core.js'
8
+
9
+ export { formatMcpResult }
10
+
11
+ export type RegisterMcpToolsOptions<R extends AnyRouter> = CoreOptions<
12
+ R,
13
+ CallToolResult
14
+ >
15
+
16
+ /**
17
+ * Register on the SDK v2 (`@modelcontextprotocol/server`) low-level Server, or
18
+ * on McpServer.server. Declare `capabilities: { tools: {} }` on the Server.
19
+ */
20
+ export function registerMcpTools<R extends AnyRouter>(
21
+ server: Pick<Server, 'setRequestHandler'>,
22
+ router: R,
23
+ options: RegisterMcpToolsOptions<R>
24
+ ): readonly string[] {
25
+ const handlers = createToolHandlers(router, options)
26
+ server.setRequestHandler('tools/list', handlers.list)
27
+ server.setRequestHandler('tools/call', (request, ctx) =>
28
+ handlers.call(
29
+ request.params.name,
30
+ request.params.arguments,
31
+ ctx.mcpReq.signal
32
+ )
33
+ )
34
+ return handlers.names
35
+ }
package/src/otel.ts CHANGED
@@ -195,6 +195,14 @@ export function errorMessageOf(error: unknown): string {
195
195
  return error instanceof Error ? error.message : String(error)
196
196
  }
197
197
 
198
+ /** Error fields for a log line, named like the `fn.completed` ones. */
199
+ export function errorAttributes(error: unknown) {
200
+ return {
201
+ error_type: error instanceof Error ? error.name : typeof error,
202
+ error_message: errorMessageOf(error)
203
+ }
204
+ }
205
+
198
206
  function protectSpan<T extends SpanLike>(span: T): T {
199
207
  return new Proxy(span, {
200
208
  get(target, key) {
package/src/router.ts CHANGED
@@ -51,3 +51,22 @@ export async function createStreamManifestAsync(
51
51
  )
52
52
  return paths
53
53
  }
54
+
55
+ /**
56
+ * Deterministic module source for a stream manifest: paths sorted, `json`
57
+ * as an array, `ts` as `export const <name>`. Used by `orpc-fn stream-manifest`.
58
+ */
59
+ export function renderStreamManifest(
60
+ paths: readonly (readonly string[])[],
61
+ options: { format: 'json' | 'ts'; name?: string | undefined }
62
+ ): string {
63
+ const lines = paths
64
+ .map(
65
+ (path) =>
66
+ `\t[${path.map((segment) => JSON.stringify(segment)).join(', ')}]`
67
+ )
68
+ .sort()
69
+ const array = lines.length ? `[\n${lines.join(',\n')}\n]` : '[]'
70
+ if (options.format === 'json') return `${array}\n`
71
+ return `// Generated by \`orpc-fn stream-manifest\`. Do not edit.\nexport const ${options.name ?? 'streamPaths'}: readonly (readonly string[])[] = ${array}\n`
72
+ }
package/src/types.ts CHANGED
@@ -140,6 +140,17 @@ export type RejectKeys<T, K extends PropertyKey, TMessage extends string> = {
140
140
  [P in Extract<keyof T, K>]: TMessage
141
141
  }
142
142
 
143
+ /** Params every handler receives; extras may not reuse these names. */
144
+ type HandlerParamKey =
145
+ | 'input'
146
+ | 'context'
147
+ | 'call'
148
+ | 'signal'
149
+ | 'span'
150
+ | 'logger'
151
+ | 'errors'
152
+ | 'lastEventId'
153
+
143
154
  /**
144
155
  * Handler extras must have known keys: an index signature would claim every
145
156
  * handler param. Intersected into `createFn`'s options to reject it.
@@ -149,17 +160,7 @@ export type FiniteExtras<T> = T extends unknown
149
160
  ? { extras: 'orpc-fn: extras need known keys, not an index signature' }
150
161
  : number extends keyof T
151
162
  ? { extras: 'orpc-fn: extras need known keys, not an index signature' }
152
- : Extract<
153
- keyof T,
154
- | 'input'
155
- | 'context'
156
- | 'call'
157
- | 'signal'
158
- | 'span'
159
- | 'logger'
160
- | 'errors'
161
- | 'lastEventId'
162
- > extends never
163
+ : Extract<keyof T, HandlerParamKey> extends never
163
164
  ? unknown
164
165
  : { extras: 'orpc-fn: extras cannot shadow handler parameters' }
165
166
  : never
@@ -272,14 +273,7 @@ export type HandlerParams<
272
273
  : object
273
274
  : object
274
275
  >,
275
- | 'input'
276
- | 'context'
277
- | 'call'
278
- | 'signal'
279
- | 'span'
280
- | 'logger'
281
- | 'errors'
282
- | 'lastEventId'
276
+ HandlerParamKey
283
277
  > & {
284
278
  input: TInput
285
279
  context: FnContext<TDef, TKey>
@@ -506,26 +500,14 @@ export type CompletedHookEvent<
506
500
  )
507
501
  }[keyof TProcedures & string]
508
502
 
509
- export type FiniteScopedExtras<T> = {
510
- [K in keyof T]: string extends keyof T[K]
511
- ? K
512
- : number extends keyof T[K]
513
- ? K
514
- : Extract<
515
- keyof T[K],
516
- | 'input'
517
- | 'context'
518
- | 'call'
519
- | 'signal'
520
- | 'span'
521
- | 'logger'
522
- | 'errors'
523
- | 'lastEventId'
524
- > extends never
525
- ? never
526
- : K
527
- }[keyof T] extends never
528
- ? unknown
529
- : {
530
- extrasByProcedure: 'orpc-fn: scoped extras need finite keys and cannot shadow handler parameters'
531
- }
503
+ /**
504
+ * Intersected into each scoped extras function's return type: rejects index
505
+ * signatures and handler-parameter names at the offending property. A
506
+ * top-level conditional over the whole map broke inference when zero-argument
507
+ * and context-reading functions were mixed.
508
+ */
509
+ export type ScopedExtrasGuard<T> = (string extends keyof T
510
+ ? { 'orpc-fn: scoped extras need finite keys': never }
511
+ : number extends keyof T
512
+ ? { 'orpc-fn: scoped extras need finite keys': never }
513
+ : unknown) & { [K in HandlerParamKey]?: never }