orpc-fn 0.3.0 → 0.5.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 (95) hide show
  1. package/README.md +146 -5
  2. package/dist/client.d.ts +29 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +49 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/create-fn.d.ts.map +1 -1
  7. package/dist/create-fn.js +2 -8
  8. package/dist/create-fn.js.map +1 -1
  9. package/dist/hono.d.ts +12 -1
  10. package/dist/hono.d.ts.map +1 -1
  11. package/dist/hono.js +15 -1
  12. package/dist/hono.js.map +1 -1
  13. package/dist/live/fn-live.d.ts +1 -6
  14. package/dist/live/fn-live.d.ts.map +1 -1
  15. package/dist/live/fn-live.js +2 -18
  16. package/dist/live/fn-live.js.map +1 -1
  17. package/dist/live/index.d.ts +3 -1
  18. package/dist/live/index.d.ts.map +1 -1
  19. package/dist/live/index.js +2 -0
  20. package/dist/live/index.js.map +1 -1
  21. package/dist/live/invalidation-stream.d.ts +52 -0
  22. package/dist/live/invalidation-stream.d.ts.map +1 -0
  23. package/dist/live/invalidation-stream.js +98 -0
  24. package/dist/live/invalidation-stream.js.map +1 -0
  25. package/dist/live/ioredis.d.ts +1 -0
  26. package/dist/live/ioredis.d.ts.map +1 -1
  27. package/dist/live/ioredis.js +1 -0
  28. package/dist/live/ioredis.js.map +1 -1
  29. package/dist/live/memory.d.ts +2 -1
  30. package/dist/live/memory.d.ts.map +1 -1
  31. package/dist/live/memory.js +6 -1
  32. package/dist/live/memory.js.map +1 -1
  33. package/dist/live/pub-sub.d.ts +5 -0
  34. package/dist/live/pub-sub.d.ts.map +1 -1
  35. package/dist/live/pub-sub.js +36 -15
  36. package/dist/live/pub-sub.js.map +1 -1
  37. package/dist/live/redis-bun.d.ts +3 -0
  38. package/dist/live/redis-bun.d.ts.map +1 -1
  39. package/dist/live/redis-bun.js +1 -0
  40. package/dist/live/redis-bun.js.map +1 -1
  41. package/dist/live/redis.d.ts +4 -1
  42. package/dist/live/redis.d.ts.map +1 -1
  43. package/dist/live/redis.js +14 -2
  44. package/dist/live/redis.js.map +1 -1
  45. package/dist/live/scoped-change-bus.d.ts +45 -0
  46. package/dist/live/scoped-change-bus.d.ts.map +1 -0
  47. package/dist/live/scoped-change-bus.js +38 -0
  48. package/dist/live/scoped-change-bus.js.map +1 -0
  49. package/dist/live/transport.d.ts +11 -0
  50. package/dist/live/transport.d.ts.map +1 -1
  51. package/dist/live/transport.js.map +1 -1
  52. package/dist/otel.d.ts +5 -0
  53. package/dist/otel.d.ts.map +1 -1
  54. package/dist/otel.js +7 -0
  55. package/dist/otel.js.map +1 -1
  56. package/dist/procedure-cli.d.ts +25 -0
  57. package/dist/procedure-cli.d.ts.map +1 -0
  58. package/dist/procedure-cli.js +67 -0
  59. package/dist/procedure-cli.js.map +1 -0
  60. package/dist/react.d.ts +116 -0
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +156 -0
  63. package/dist/react.js.map +1 -0
  64. package/dist/realtime.d.ts +114 -0
  65. package/dist/realtime.d.ts.map +1 -0
  66. package/dist/realtime.js +316 -0
  67. package/dist/realtime.js.map +1 -0
  68. package/dist/vue/testing.d.ts +67 -0
  69. package/dist/vue/testing.d.ts.map +1 -0
  70. package/dist/vue/testing.js +225 -0
  71. package/dist/vue/testing.js.map +1 -0
  72. package/dist/vue.d.ts +211 -0
  73. package/dist/vue.d.ts.map +1 -0
  74. package/dist/vue.js +312 -0
  75. package/dist/vue.js.map +1 -0
  76. package/package.json +51 -4
  77. package/src/client.ts +57 -0
  78. package/src/create-fn.ts +2 -7
  79. package/src/hono.ts +22 -2
  80. package/src/live/fn-live.ts +3 -22
  81. package/src/live/index.ts +12 -1
  82. package/src/live/invalidation-stream.ts +142 -0
  83. package/src/live/ioredis.ts +2 -0
  84. package/src/live/memory.ts +11 -2
  85. package/src/live/pub-sub.ts +48 -17
  86. package/src/live/redis-bun.ts +3 -0
  87. package/src/live/redis.ts +33 -6
  88. package/src/live/scoped-change-bus.ts +98 -0
  89. package/src/live/transport.ts +12 -0
  90. package/src/otel.ts +8 -0
  91. package/src/procedure-cli.ts +99 -0
  92. package/src/react.ts +355 -0
  93. package/src/realtime.ts +416 -0
  94. package/src/vue/testing.ts +296 -0
  95. package/src/vue.ts +783 -0
@@ -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,
@@ -79,6 +84,11 @@ export type PublisherConfig = {
79
84
  * channel means no duplicate work.
80
85
  */
81
86
  mirrorChannel?: string
87
+ /**
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.
90
+ */
91
+ safePublish?: boolean
82
92
  }
83
93
 
84
94
  /** Per-definition overrides of the factory queue bounds. */
@@ -607,10 +617,25 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
607
617
  }
608
618
  })
609
619
 
620
+ const guarded = async (
621
+ operation: 'publish' | 'publishMany',
622
+ items: readonly unknown[]
623
+ ) => {
624
+ if (!config.safePublish) return send(operation, items)
625
+ try {
626
+ await send(operation, items)
627
+ } catch (error) {
628
+ logger.error('pubsub.publish_failed', {
629
+ name,
630
+ ...errorAttributes(error)
631
+ })
632
+ }
633
+ }
634
+
610
635
  return {
611
- publish: (data: unknown) => send('publish', [data]),
636
+ publish: (data: unknown) => guarded('publish', [data]),
612
637
  publishMany: async (items: readonly unknown[]) => {
613
- if (items.length > 0) await send('publishMany', items)
638
+ if (items.length > 0) await guarded('publishMany', items)
614
639
  }
615
640
  }
616
641
  }
@@ -706,8 +731,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
706
731
  name: config.name,
707
732
  channel
708
733
  })
709
- logger.warn(`Dropped malformed message on ${channel}`, {
710
- error: errorMessageOf(error)
734
+ logger.warn('pubsub.message_dropped', {
735
+ channel,
736
+ ...errorAttributes(error)
711
737
  })
712
738
  }
713
739
  }
@@ -721,7 +747,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
721
747
  if (closed || timer) return
722
748
  const delayMs = getResubscribeDelayMs(attempt)
723
749
  attempt++
724
- logger.warn(`Scheduling resubscribe for ${channel}`, {
750
+ logger.warn('pubsub.resubscribe_scheduled', {
751
+ channel,
725
752
  reason,
726
753
  attempt,
727
754
  delayMs
@@ -749,8 +776,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
749
776
  connected = false
750
777
  markPending()
751
778
  }
752
- logger.error(`Subscription to ${channel} lost`, {
753
- error: error.message
779
+ logger.error('pubsub.subscription_lost', {
780
+ channel,
781
+ ...errorAttributes(error)
754
782
  })
755
783
  scheduleResubscribe('lost')
756
784
  },
@@ -784,10 +812,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
784
812
  connectedOnce = true
785
813
  markReady()
786
814
  attempt = 0
787
- logger.info(`Subscribed to ${channel}`)
815
+ logger.debug('pubsub.subscribed', { channel })
788
816
  } catch (error) {
789
- logger.error(`Failed to subscribe to ${channel}`, {
790
- error: errorMessageOf(error)
817
+ logger.error('pubsub.subscribe_failed', {
818
+ channel,
819
+ ...errorAttributes(error)
791
820
  })
792
821
  scheduleResubscribe('subscribe-failed')
793
822
  }
@@ -926,7 +955,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
926
955
  throw error
927
956
  }
928
957
 
929
- subscriberLogger.info(`Subscribing to ${channelName}`)
958
+ subscriberLogger.debug('pubsub.subscribing', { channel: channelName })
930
959
 
931
960
  let messageCount = 0
932
961
  let droppedCount = 0
@@ -1051,7 +1080,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1051
1080
  span?.setAttribute('pubsub.message_count', messageCount)
1052
1081
  span?.setAttribute('pubsub.dropped_count', droppedCount)
1053
1082
  tracing.end(span, ...error)
1054
- subscriberLogger.info(`Unsubscribed from ${channelName}`, {
1083
+ subscriberLogger.debug('pubsub.unsubscribed', {
1084
+ channel: channelName,
1055
1085
  messageCount,
1056
1086
  droppedCount,
1057
1087
  durationMs: Math.round(durationMs)
@@ -1106,10 +1136,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1106
1136
  if (items.length > maxReplay) replayGap = true
1107
1137
  } catch (error) {
1108
1138
  replayGap = true
1109
- subscriberLogger.warn(
1110
- `Failed to replay backlog for ${channelName}`,
1111
- { error: errorMessageOf(error) }
1112
- )
1139
+ subscriberLogger.warn('pubsub.replay_failed', {
1140
+ channel: channelName,
1141
+ ...errorAttributes(error)
1142
+ })
1113
1143
  }
1114
1144
  }
1115
1145
  for (const { data, id } of replaying ?? [])
@@ -1195,6 +1225,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
1195
1225
  backlogSize: _backlogSize,
1196
1226
  backlogTtl: _backlogTtl,
1197
1227
  mirrorChannel: _mirrorChannel,
1228
+ safePublish: _safePublish,
1198
1229
  maxQueueSize: _maxQueueSize,
1199
1230
  maxIngressSize: _maxIngressSize,
1200
1231
  maxReplaySize: _maxReplaySize,
@@ -9,6 +9,8 @@ export interface BunRedisClientLike {
9
9
  unsubscribe(channel: string, listener?: Listener): Promise<unknown>
10
10
  duplicate(): Promise<BunRedisClientLike>
11
11
  close(): void
12
+ /** Bun's `RedisClient.connected`; read by `stats()` when present. */
13
+ readonly connected?: boolean
12
14
  onconnect: ((this: never) => void) | null
13
15
  onclose: ((this: never, error: Error) => void) | null
14
16
  }
@@ -58,6 +60,7 @@ export function bunRedisTransport(
58
60
  ours.delete(channel)
59
61
  return subscriber.unsubscribe(channel, dispatch)
60
62
  },
63
+ connected: () => subscriber.connected !== false,
61
64
  close() {
62
65
  // No-ops, not null: Bun calls `onclose` without checking it.
63
66
  subscriber.onconnect = () => {}
package/src/live/redis.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { errorMessageOf } from '../otel.js'
2
- import { backlogKey, type PubSubTransport } from './transport.js'
2
+ import {
3
+ backlogKey,
4
+ type PubSubTransport,
5
+ type PubSubTransportStats
6
+ } from './transport.js'
3
7
 
4
8
  export type RedisSend = (command: string, args: string[]) => Promise<unknown>
5
9
 
@@ -77,6 +81,8 @@ export type SubscriberConnection = {
77
81
  resubscribe?(channel: string): Promise<unknown>
78
82
  /** Stop delivering and release the connection (if the driver created it). */
79
83
  close(): void | Promise<void>
84
+ /** The driver reports the connection open; omitted means open until lost. */
85
+ connected?(): boolean
80
86
  }
81
87
 
82
88
  /** Callbacks for one connection; they do nothing once it is replaced. */
@@ -115,7 +121,10 @@ export function createRedisTransport(driver: {
115
121
  connectSubscriber: (
116
122
  handlers: SubscriberHandlers
117
123
  ) => Promise<SubscriberConnection>
118
- }): PubSubTransport & { close(): Promise<void> } {
124
+ }): PubSubTransport & {
125
+ close(): Promise<void>
126
+ stats(): PubSubTransportStats
127
+ } {
119
128
  const publishScript = createLuaScript(driver.send, PUBLISH_LUA)
120
129
  const channels = new Map<string, ChannelState>()
121
130
  // An UNSUBSCRIBE still in flight would cancel a SUBSCRIBE sent after it.
@@ -124,6 +133,8 @@ export function createRedisTransport(driver: {
124
133
  connection: Promise<SubscriberConnection>
125
134
  retire: () => Promise<void>
126
135
  } | null = null
136
+ // The open connection of the current generation, once it resolves.
137
+ let open: SubscriberConnection | null = null
127
138
 
128
139
  const connect = () => {
129
140
  let retired = false
@@ -131,7 +142,10 @@ export function createRedisTransport(driver: {
131
142
  const retire = (error?: Error) => {
132
143
  if (retired) return closing ?? Promise.resolve()
133
144
  retired = true
134
- if (current?.connection === connection) current = null
145
+ if (current?.connection === connection) {
146
+ current = null
147
+ open = null
148
+ }
135
149
  closing = connection.then((open) => open.close()).catch(() => {})
136
150
  if (!error) return closing
137
151
  const lost = [...channels.values()].flatMap((state) => [...state.entries])
@@ -182,9 +196,14 @@ export function createRedisTransport(driver: {
182
196
  onLost: (error) => retire(error)
183
197
  }
184
198
  const connection = driver.connectSubscriber(handlers)
185
- connection.catch(() => {
186
- if (current?.connection === connection) current = null
187
- })
199
+ connection.then(
200
+ (resolved) => {
201
+ if (!retired) open = resolved
202
+ },
203
+ () => {
204
+ if (current?.connection === connection) current = null
205
+ }
206
+ )
188
207
  current = { connection, retire: () => retire() }
189
208
  return connection
190
209
  }
@@ -268,6 +287,14 @@ export function createRedisTransport(driver: {
268
287
  await done
269
288
  }
270
289
  },
290
+ stats: () => ({
291
+ channels: channels.size,
292
+ listeners: [...channels.values()].reduce(
293
+ (sum, state) => sum + state.entries.size,
294
+ 0
295
+ ),
296
+ subscriberConnected: open ? (open.connected?.() ?? true) : false
297
+ }),
271
298
  async close() {
272
299
  const closing = current?.retire()
273
300
  channels.clear()
@@ -0,0 +1,98 @@
1
+ import type { ZodType, z } from 'zod'
2
+ import type {
3
+ AnyFnContext,
4
+ FnDefinition,
5
+ FnRouteOptions,
6
+ GuardOptions,
7
+ ProcedureKey,
8
+ ProcedureOption
9
+ } from '../types.js'
10
+ import type {
11
+ CreatePubSub,
12
+ ObjectSchema,
13
+ PubSub,
14
+ PubSubOptions
15
+ } from './pub-sub.js'
16
+
17
+ /** Where a change bus partitions its channels, e.g. one per organization. */
18
+ export type ChangeBusScope<TContext, TEvent> = {
19
+ /** The subscriber's scope key, from its context. Throw to refuse the subscription. */
20
+ subscribe: (context: TContext) => string
21
+ /** The event's scope key, carried in the event itself. */
22
+ publish: (event: TEvent) => string
23
+ }
24
+
25
+ /** A bus's channel definition; pass it to `fnLive` to share the bus's events. */
26
+ export type ChangeBusChannel<TContext, TEvent> = {
27
+ subscribe: (params: { context: TContext }) => string
28
+ publish: (event: TEvent) => string
29
+ }
30
+
31
+ export type ScopedChangeBus<
32
+ TDef extends FnDefinition,
33
+ TInputSchema extends ObjectSchema,
34
+ TEventSchema extends ZodType,
35
+ TKey extends ProcedureKey<TDef>,
36
+ TScopeEvent
37
+ > = PubSub<TDef, TInputSchema, TEventSchema, TKey> & {
38
+ channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent>
39
+ }
40
+
41
+ /**
42
+ * Binds the app's `createPubSub` to one scope rule, for buses that tell every
43
+ * subscriber in a scope "something changed". Each bus then names only its
44
+ * channel prefix: events go to `${prefix}:${scope}`. Publishing is safe by
45
+ * default (`safePublish: true`), because buses publish after committed writes.
46
+ *
47
+ * ```ts
48
+ * const defineChangeBus = defineScopedChangeBus(createPubSub, {
49
+ * subscribe: (context) => activeOrganizationId(context),
50
+ * publish: (event: { organizationId: string }) => event.organizationId
51
+ * })
52
+ * export const dealChanges = defineChangeBus({
53
+ * name: 'deal.subscribe',
54
+ * prefix: 'deal:change',
55
+ * inputSchema: z.object({}),
56
+ * eventSchema: z.object({ organizationId: z.string(), dealId: z.string() })
57
+ * })
58
+ * // fnLive({ live: { channel: dealChanges.channel, ... } }) shares the channel.
59
+ * ```
60
+ */
61
+ export function defineScopedChangeBus<TDef extends FnDefinition, TScopeEvent>(
62
+ createPubSub: CreatePubSub<TDef>,
63
+ scope: ChangeBusScope<AnyFnContext<TDef>, TScopeEvent>
64
+ ) {
65
+ return <
66
+ TInputSchema extends ObjectSchema,
67
+ TEventSchema extends ZodType<TScopeEvent>,
68
+ TKey extends ProcedureKey<TDef> = TDef['default']
69
+ >(
70
+ options: Omit<
71
+ PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>>,
72
+ 'channel'
73
+ > &
74
+ GuardOptions<TDef['guards']> &
75
+ Pick<
76
+ FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
77
+ 'meta' | 'guardResolvers'
78
+ > &
79
+ ProcedureOption<TDef, TKey> & {
80
+ /** Channel prefix; the scope key is appended as `${prefix}:${scope}`. */
81
+ prefix: string
82
+ }
83
+ ): ScopedChangeBus<TDef, TInputSchema, TEventSchema, TKey, TScopeEvent> => {
84
+ const { prefix, ...pubsubOptions } = options
85
+ const channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent> = {
86
+ subscribe: ({ context }) => `${prefix}:${scope.subscribe(context)}`,
87
+ publish: (event) => `${prefix}:${scope.publish(event)}`
88
+ }
89
+ // The bus's context is one member of AnyFnContext, which the generic
90
+ // signature of createPubSub cannot see.
91
+ const bus = (createPubSub as (options: object) => object)({
92
+ safePublish: true,
93
+ ...pubsubOptions,
94
+ channel
95
+ }) as PubSub<TDef, TInputSchema, TEventSchema, TKey>
96
+ return { ...bus, channel }
97
+ }
98
+ }
@@ -1,6 +1,16 @@
1
1
  /** One payload for one channel. */
2
2
  export type PubSubMessage = { channel: string; payload: string }
3
3
 
4
+ /** What a transport holds open on this instance, for gauges and health checks. */
5
+ export type PubSubTransportStats = {
6
+ /** Broker channels subscribed (one per channel, however many listeners). */
7
+ channels: number
8
+ /** Local listeners across those channels. */
9
+ listeners: number
10
+ /** The subscriber connection is open; false before the first subscribe. */
11
+ subscriberConnected: boolean
12
+ }
13
+
4
14
  export type BacklogOptions = {
5
15
  /** Items kept per channel (oldest dropped). */
6
16
  size: number
@@ -39,6 +49,8 @@ export interface PubSubTransport {
39
49
  onLost?: (error: Error) => void,
40
50
  options?: { onReconnect?: () => void }
41
51
  ): Promise<() => Promise<void>>
52
+ /** Current subscription counts and connection state. */
53
+ stats?(): PubSubTransportStats
42
54
  }
43
55
 
44
56
  export const backlogKey = (channel: string) => `${channel}:backlog`
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) {
@@ -0,0 +1,99 @@
1
+ import { type AnyProcedure, call, isProcedure, ORPCError } from '@orpc/server'
2
+ import {
3
+ CliError,
4
+ type Command,
5
+ type CommandGroup,
6
+ defineCommand
7
+ } from 'hanzio/cli'
8
+ import { z } from 'zod'
9
+ import { type FnMeta, readFnMeta } from './meta.js'
10
+
11
+ export type ProcedureCommandsOptions<C> = {
12
+ /**
13
+ * Which procedures become commands. Default: those declared with
14
+ * `meta: { cli: true }` (add `cli?: boolean` to the app's meta type).
15
+ */
16
+ filter?: (meta: FnMeta) => boolean
17
+ /**
18
+ * The oRPC context a command calls its procedure with, from the CLI's
19
+ * context (`defineCli({ context })`). Default: that context as-is. It gets
20
+ * the procedure's meta, so it can refuse early with a `CliError`, e.g. a
21
+ * protected procedure run without a user.
22
+ */
23
+ context?: (cliContext: C, meta: FnMeta) => unknown
24
+ }
25
+
26
+ const optedIn = (meta: FnMeta) => (meta.meta as { cli?: unknown }).cli === true
27
+
28
+ const kebab = (key: string) =>
29
+ key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
30
+
31
+ /**
32
+ * The opted-in `fn()` procedures of a router as hanzio/cli commands, nested
33
+ * like the router: `supportLearning.syncRun` is `support-learning sync-run`.
34
+ * Flags come from the procedure's input schema, help from its summary. A
35
+ * command calls the procedure through oRPC, so middleware, guards, tracing
36
+ * and errors run as they do over HTTP.
37
+ */
38
+ export function procedureCommands<C = unknown>(
39
+ router: object,
40
+ options: ProcedureCommandsOptions<C> = {}
41
+ ): CommandGroup<C> {
42
+ const filter = options.filter ?? optedIn
43
+ const commands: Record<string, Command<C> | CommandGroup<C>> = {}
44
+
45
+ for (const [key, value] of Object.entries(router)) {
46
+ if (isProcedure(value)) {
47
+ const meta = readFnMeta(value)
48
+ if (filter(meta)) commands[kebab(key)] = toCommand(value, meta, options)
49
+ } else if (value && typeof value === 'object') {
50
+ const group = procedureCommands(value, options)
51
+ if (Object.keys(group).length > 0) commands[kebab(key)] = group
52
+ }
53
+ }
54
+
55
+ return commands
56
+ }
57
+
58
+ function toCommand<C>(
59
+ procedure: AnyProcedure,
60
+ meta: FnMeta,
61
+ options: ProcedureCommandsOptions<C>
62
+ ): Command<C> {
63
+ const name = meta.name ?? 'procedure'
64
+ if (meta.stream) {
65
+ throw new TypeError(`orpc-fn: ${name} streams and cannot be a CLI command`)
66
+ }
67
+ const input = meta.inputSchema
68
+ if (input !== undefined && !(input instanceof z.ZodObject)) {
69
+ throw new TypeError(
70
+ `orpc-fn: ${name} must take a z.object() input to be a CLI command`
71
+ )
72
+ }
73
+
74
+ return defineCommand<z.ZodObject, unknown, C>({
75
+ description: meta.description,
76
+ input: input ?? z.object({}),
77
+ run: async (parsed, ctx) => {
78
+ const context = options.context
79
+ ? await options.context(ctx.context, meta)
80
+ : ctx.context
81
+ try {
82
+ // The flags were validated against this schema; the procedure parses
83
+ // them again, like any nested call with a parsed value.
84
+ return await call(procedure, input ? parsed : undefined, {
85
+ context: context as Record<never, never>
86
+ })
87
+ } catch (error) {
88
+ // A refusal the caller drives (auth, permission, not found) reads as
89
+ // a message, not a crash with a stack trace.
90
+ if (error instanceof ORPCError && error.status < 500) {
91
+ throw new CliError(`${error.code}: ${error.message}`, {
92
+ cause: error
93
+ })
94
+ }
95
+ throw error
96
+ }
97
+ }
98
+ })
99
+ }