orpc-fn 0.1.0 → 0.3.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 (156) hide show
  1. package/README.md +70 -33
  2. package/dist/assemble-procedure.d.ts +13 -0
  3. package/dist/assemble-procedure.d.ts.map +1 -0
  4. package/dist/assemble-procedure.js +29 -0
  5. package/dist/assemble-procedure.js.map +1 -0
  6. package/dist/bound-call.d.ts +1 -1
  7. package/dist/bound-call.d.ts.map +1 -1
  8. package/dist/bound-call.js.map +1 -1
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +56 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.d.ts +4 -2
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +4 -2
  16. package/dist/client.js.map +1 -1
  17. package/dist/compatibility.d.ts +16 -0
  18. package/dist/compatibility.d.ts.map +1 -0
  19. package/dist/compatibility.js +4 -0
  20. package/dist/compatibility.js.map +1 -0
  21. package/dist/create-fn.d.ts +30 -34
  22. package/dist/create-fn.d.ts.map +1 -1
  23. package/dist/create-fn.js +177 -121
  24. package/dist/create-fn.js.map +1 -1
  25. package/dist/expo.d.ts +11 -25
  26. package/dist/expo.d.ts.map +1 -1
  27. package/dist/expo.js +25 -30
  28. package/dist/expo.js.map +1 -1
  29. package/dist/hono.d.ts +12 -4
  30. package/dist/hono.d.ts.map +1 -1
  31. package/dist/hono.js +29 -12
  32. package/dist/hono.js.map +1 -1
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/json-schema.d.ts +2 -0
  38. package/dist/json-schema.d.ts.map +1 -1
  39. package/dist/json-schema.js +11 -0
  40. package/dist/json-schema.js.map +1 -1
  41. package/dist/live/codec.d.ts +6 -1
  42. package/dist/live/codec.d.ts.map +1 -1
  43. package/dist/live/codec.js +14 -3
  44. package/dist/live/codec.js.map +1 -1
  45. package/dist/live/fn-live.d.ts +67 -22
  46. package/dist/live/fn-live.d.ts.map +1 -1
  47. package/dist/live/fn-live.js +98 -16
  48. package/dist/live/fn-live.js.map +1 -1
  49. package/dist/live/index.d.ts +1 -1
  50. package/dist/live/index.d.ts.map +1 -1
  51. package/dist/live/index.js.map +1 -1
  52. package/dist/live/ioredis.d.ts +4 -1
  53. package/dist/live/ioredis.d.ts.map +1 -1
  54. package/dist/live/ioredis.js +21 -2
  55. package/dist/live/ioredis.js.map +1 -1
  56. package/dist/live/lifecycle.d.ts +7 -0
  57. package/dist/live/lifecycle.d.ts.map +1 -0
  58. package/dist/live/lifecycle.js +36 -0
  59. package/dist/live/lifecycle.js.map +1 -0
  60. package/dist/live/memory.d.ts.map +1 -1
  61. package/dist/live/memory.js +4 -0
  62. package/dist/live/memory.js.map +1 -1
  63. package/dist/live/pub-sub.d.ts +81 -13
  64. package/dist/live/pub-sub.d.ts.map +1 -1
  65. package/dist/live/pub-sub.js +437 -126
  66. package/dist/live/pub-sub.js.map +1 -1
  67. package/dist/live/redis-bun.d.ts +1 -1
  68. package/dist/live/redis-bun.d.ts.map +1 -1
  69. package/dist/live/redis-bun.js +3 -5
  70. package/dist/live/redis-bun.js.map +1 -1
  71. package/dist/live/redis.d.ts +2 -2
  72. package/dist/live/redis.d.ts.map +1 -1
  73. package/dist/live/redis.js +36 -9
  74. package/dist/live/redis.js.map +1 -1
  75. package/dist/live/transport.d.ts +5 -1
  76. package/dist/live/transport.d.ts.map +1 -1
  77. package/dist/live/transport.js.map +1 -1
  78. package/dist/logger.d.ts +2 -0
  79. package/dist/logger.d.ts.map +1 -1
  80. package/dist/logger.js +21 -0
  81. package/dist/logger.js.map +1 -1
  82. package/dist/mastra.d.ts.map +1 -1
  83. package/dist/mastra.js +16 -7
  84. package/dist/mastra.js.map +1 -1
  85. package/dist/mcp/core.d.ts +41 -0
  86. package/dist/mcp/core.d.ts.map +1 -0
  87. package/dist/mcp/core.js +61 -0
  88. package/dist/mcp/core.js.map +1 -0
  89. package/dist/mcp/sdk.d.ts +12 -0
  90. package/dist/mcp/sdk.d.ts.map +1 -0
  91. package/dist/mcp/sdk.js +14 -0
  92. package/dist/mcp/sdk.js.map +1 -0
  93. package/dist/mcp/server.d.ts +11 -0
  94. package/dist/mcp/server.d.ts.map +1 -0
  95. package/dist/mcp/server.js +13 -0
  96. package/dist/mcp/server.js.map +1 -0
  97. package/dist/mcp.d.ts +3 -2
  98. package/dist/mcp.d.ts.map +1 -1
  99. package/dist/mcp.js +22 -7
  100. package/dist/mcp.js.map +1 -1
  101. package/dist/meta.d.ts +15 -7
  102. package/dist/meta.d.ts.map +1 -1
  103. package/dist/meta.js +9 -2
  104. package/dist/meta.js.map +1 -1
  105. package/dist/otel.d.ts.map +1 -1
  106. package/dist/otel.js +77 -6
  107. package/dist/otel.js.map +1 -1
  108. package/dist/route-options.d.ts +22 -0
  109. package/dist/route-options.d.ts.map +1 -0
  110. package/dist/route-options.js +67 -0
  111. package/dist/route-options.js.map +1 -0
  112. package/dist/router.d.ts +13 -1
  113. package/dist/router.d.ts.map +1 -1
  114. package/dist/router.js +35 -0
  115. package/dist/router.js.map +1 -1
  116. package/dist/timing.d.ts +8 -0
  117. package/dist/timing.d.ts.map +1 -0
  118. package/dist/timing.js +4 -0
  119. package/dist/timing.js.map +1 -0
  120. package/dist/types.d.ts +96 -41
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +6 -2
  123. package/dist/types.js.map +1 -1
  124. package/package.json +28 -3
  125. package/src/assemble-procedure.ts +42 -0
  126. package/src/bound-call.ts +4 -3
  127. package/src/cli.ts +68 -0
  128. package/src/client.ts +9 -2
  129. package/src/compatibility.ts +39 -0
  130. package/src/create-fn.ts +295 -182
  131. package/src/expo.ts +49 -44
  132. package/src/hono.ts +36 -17
  133. package/src/index.ts +7 -2
  134. package/src/json-schema.ts +16 -0
  135. package/src/live/codec.ts +22 -3
  136. package/src/live/fn-live.ts +248 -69
  137. package/src/live/index.ts +2 -0
  138. package/src/live/ioredis.ts +22 -2
  139. package/src/live/lifecycle.ts +44 -0
  140. package/src/live/memory.ts +4 -0
  141. package/src/live/pub-sub.ts +619 -168
  142. package/src/live/redis-bun.ts +7 -5
  143. package/src/live/redis.ts +37 -11
  144. package/src/live/transport.ts +4 -1
  145. package/src/logger.ts +20 -0
  146. package/src/mastra.ts +15 -7
  147. package/src/mcp/core.ts +102 -0
  148. package/src/mcp/sdk.ts +36 -0
  149. package/src/mcp/server.ts +35 -0
  150. package/src/mcp.ts +48 -20
  151. package/src/meta.ts +40 -24
  152. package/src/otel.ts +77 -10
  153. package/src/route-options.ts +100 -0
  154. package/src/router.ts +58 -1
  155. package/src/timing.ts +5 -0
  156. package/src/types.ts +186 -62
package/src/create-fn.ts CHANGED
@@ -1,5 +1,11 @@
1
- import type { AnySchema } from '@orpc/contract'
2
- import type { AnyProcedure } from '@orpc/server'
1
+ import {
2
+ type BuilderChain,
3
+ createProcedureAssembler
4
+ } from './assemble-procedure.js'
5
+ import { isAsyncIteratorObject } from './stream.js'
6
+ import { normalizeRoute } from './route-options.js'
7
+ import { type AnyProcedure, ORPCError, os } from '@orpc/server'
8
+ import { procedureDefinition } from './compatibility.js'
3
9
  import { type BoundCall, createBoundCall } from './bound-call.js'
4
10
  import { isExpectedClientError } from './expected-client-error.js'
5
11
  import { createFnLive, type FnLive } from './live/fn-live.js'
@@ -11,15 +17,11 @@ import {
11
17
  } from './live/pub-sub.js'
12
18
  import {
13
19
  createDefaultLogger,
20
+ protectLogger,
14
21
  type FnLogger,
15
22
  type LogAttributes
16
23
  } from './logger.js'
17
- import {
18
- FN_META_KEY,
19
- type FnMeta,
20
- readFnMeta,
21
- type StoredFnMeta
22
- } from './meta.js'
24
+ import { readFnMeta } from './meta.js'
23
25
  import {
24
26
  type AttributeValue,
25
27
  createTracing,
@@ -28,12 +30,15 @@ import {
28
30
  type SpanLike,
29
31
  type SpanOf
30
32
  } from './otel.js'
33
+ import { RAW_INPUT, REQUEST_TIMING, type InvocationTiming } from './timing.js'
31
34
  import { createRouter } from './router.js'
32
35
  import {
33
- type AnyFnContext,
36
+ type ProcedureHookParams,
37
+ type CompletedHookEvent,
38
+ type CurrentContextOf,
34
39
  type BuilderLike,
35
- type BuildProcedure,
36
40
  type FiniteExtras,
41
+ type ScopedExtrasGuard,
37
42
  type Fn,
38
43
  type FnCompletedEvent,
39
44
  type FnDefinition,
@@ -42,24 +47,18 @@ import {
42
47
  type MaybePromise,
43
48
  type RejectKeys,
44
49
  RESERVED_OPTION_KEYS,
45
- ROUTE_OPTION_KEYS,
46
50
  type ReservedOptionKey
47
51
  } from './types.js'
48
52
 
49
- /** Builder methods `createFn` calls; `BuilderLike` is the public constraint. */
50
- type BuilderChain = {
51
- route(route: Record<string, unknown>): BuilderChain
52
- meta(meta: Record<string, unknown>): BuilderChain
53
- input(schema: AnySchema): BuilderChain
54
- output(schema: AnySchema): BuilderChain
55
- handler(handler: Parameters<BuildProcedure>[0]['handler']): AnyProcedure
56
- }
57
-
58
53
  /** `CreateFnOptions` with the generics erased: what the implementation reads. */
59
54
  type RuntimeOptions = {
60
55
  procedures: Record<string, BuilderChain>
61
56
  default?: string
62
57
  extras?: (params: Record<string, unknown>) => unknown
58
+ extrasByProcedure?: Record<
59
+ string,
60
+ (params: Record<string, unknown>) => unknown
61
+ >
63
62
  guards?: Record<
64
63
  string,
65
64
  (value: unknown, params: GuardParams<unknown, unknown>) => unknown
@@ -77,18 +76,6 @@ type RuntimeOptions = {
77
76
  pubsub?: PubSubRuntimeOptions
78
77
  }
79
78
 
80
- type UnionContext<TProcedures extends Record<string, BuilderLike>> =
81
- AnyFnContext<{
82
- procedures: TProcedures
83
- default: string
84
- extras: object
85
- guards: object
86
- meta: object
87
- tag: string
88
- span: SpanLike
89
- logger: FnLogger
90
- }>
91
-
92
79
  export type CreateFnOptions<
93
80
  TProcedures extends Record<string, BuilderLike>,
94
81
  TDefault extends keyof TProcedures & string,
@@ -97,7 +84,11 @@ export type CreateFnOptions<
97
84
  TMeta extends object,
98
85
  TTag extends string,
99
86
  TOtel extends OtelApiLike | undefined,
100
- TLogger extends FnLogger
87
+ TLogger extends FnLogger,
88
+ TScoped extends Partial<Record<keyof TProcedures, object>> = Record<
89
+ never,
90
+ never
91
+ >
101
92
  > = {
102
93
  /** Named oRPC builders, e.g. `{ public: os.$context<Ctx>(), protected: authed }`. */
103
94
  procedures: TProcedures
@@ -107,52 +98,65 @@ export type CreateFnOptions<
107
98
  */
108
99
  default?: TDefault
109
100
  /** Per-call values merged into every handler's params; may be async. */
110
- extras?: (params: {
111
- context: UnionContext<TProcedures>
112
- span: SpanOf<TOtel> | undefined
113
- signal: AbortSignal | undefined
114
- name: string
115
- procedure: string
116
- }) => MaybePromise<TExtras>
101
+ extras?: (
102
+ params: ProcedureHookParams<
103
+ TProcedures,
104
+ {
105
+ span: SpanOf<TOtel> | undefined
106
+ signal: AbortSignal | undefined
107
+ name: string
108
+ }
109
+ >
110
+ ) => MaybePromise<TExtras>
111
+ extrasByProcedure?: {
112
+ [K in keyof TScoped & keyof TProcedures]: (params: {
113
+ context: CurrentContextOf<TProcedures[K]>
114
+ span: SpanOf<TOtel> | undefined
115
+ signal: AbortSignal | undefined
116
+ name: string
117
+ procedure: K
118
+ }) => MaybePromise<TScoped[K] & ScopedExtrasGuard<NoInfer<TScoped[K]>>>
119
+ }
117
120
  /**
118
121
  * Checks run before the handler; each key becomes a typed `fn()` option.
119
- * Keys must not reuse route options or meta keys.
122
+ * Keys must not reuse route options. False denies, true or void allows.
120
123
  */
121
124
  guards?: TGuards &
122
125
  Record<
123
126
  string,
124
- Guard<never, GuardParams<UnionContext<TProcedures>, TMeta>>
127
+ Guard<
128
+ never,
129
+ ProcedureHookParams<
130
+ TProcedures,
131
+ Omit<GuardParams<unknown, TMeta>, 'context' | 'procedure'>
132
+ >
133
+ >
125
134
  > &
126
135
  RejectKeys<
127
136
  NoInfer<TGuards>,
128
- ReservedOptionKey | keyof NoInfer<TMeta>,
129
- 'orpc-fn: this guard name is a route option or meta key'
137
+ ReservedOptionKey,
138
+ 'orpc-fn: this guard name is a route option'
130
139
  >
131
140
  /**
132
- * Typed route metadata: `meta: {} as { readOnly?: boolean }`. Keys become
133
- * `fn()` options and must not reuse route options.
141
+ * Typed route metadata contract: `meta: defineMeta<AppMeta>()`.
142
+ * Route declarations place values inside their own `meta` object.
134
143
  */
135
- meta?: TMeta &
136
- RejectKeys<
137
- NoInfer<TMeta>,
138
- ReservedOptionKey,
139
- 'orpc-fn: this meta key is a route option'
140
- >
144
+ meta?: TMeta
141
145
  /** Allowed route tags: `tags: ['internal', 'external']`. */
142
146
  tags?: readonly TTag[]
143
147
  /** `import * as otel from '@opentelemetry/api'`. Omit for no tracing. */
144
148
  otel?: TOtel
145
149
  logger?: (scope: string, span: SpanOf<TOtel> | undefined) => TLogger
146
150
  /** Extra span attributes per call; undefined values are skipped. */
147
- spanAttributes?: (params: {
148
- context: UnionContext<TProcedures>
149
- name: string
150
- procedure: string
151
- meta: Partial<TMeta>
152
- }) => Record<string, AttributeValue | undefined>
153
- /** Called after every handler; returned attributes join the `fn.completed` log. */
151
+ spanAttributes?: (
152
+ params: ProcedureHookParams<
153
+ TProcedures,
154
+ { name: string; meta: Partial<TMeta> }
155
+ >
156
+ ) => Record<string, AttributeValue | undefined>
157
+ /** Called after the procedure (or stream) settles; returned attributes join the `fn.completed` log. */
154
158
  onCompleted?: (
155
- event: FnCompletedEvent<UnionContext<TProcedures>, SpanOf<TOtel>>
159
+ event: CompletedHookEvent<TProcedures, SpanOf<TOtel>>
156
160
  // biome-ignore lint/suspicious/noConfusingVoidType: lets hooks that return nothing type-check
157
161
  ) => LogAttributes | undefined | void
158
162
  /** Errors that are the caller's fault, logged at `warn`. Default: 4xx `ORPCError` or `AbortError`. */
@@ -169,11 +173,16 @@ type Definition<
169
173
  TMeta extends object,
170
174
  TTag extends string,
171
175
  TOtel,
172
- TLogger extends FnLogger
176
+ TLogger extends FnLogger,
177
+ TScoped extends Partial<Record<keyof TProcedures, object>> = Record<
178
+ never,
179
+ never
180
+ >
173
181
  > = {
174
182
  procedures: TProcedures
175
183
  default: TDefault
176
184
  extras: TExtras
185
+ extrasByProcedure: TScoped
177
186
  guards: TGuards
178
187
  meta: TMeta
179
188
  tag: TTag
@@ -187,7 +196,7 @@ export type FnFactory<TDef extends FnDefinition> = {
187
196
  createPubSub: CreatePubSub<TDef>
188
197
  createPublisher: CreatePublisher
189
198
  createRouter: typeof createRouter
190
- readMeta: (procedure: AnyProcedure) => FnMeta<TDef['meta']>
199
+ readMeta: typeof readFnMeta
191
200
  /**
192
201
  * A `call` outside any handler (seeders, scripts, tests), traced with this
193
202
  * instance's OpenTelemetry.
@@ -196,7 +205,8 @@ export type FnFactory<TDef extends FnDefinition> = {
196
205
  context: TContext,
197
206
  signal?: AbortSignal
198
207
  ) => BoundCall<TContext>
199
- /** End every open subscription of this instance; returns how many closed. */
208
+ /** Terminal, idempotent shutdown: close streams and await owned resources. */
209
+ shutdown: () => Promise<void>
200
210
  drainPubSubSubscribers: () => number
201
211
  activePubSubSubscriberCount: () => number
202
212
  }
@@ -213,7 +223,11 @@ export function createFn<
213
223
  TMeta extends object = Record<never, never>,
214
224
  const TTag extends string = string,
215
225
  TOtel extends OtelApiLike | undefined = undefined,
216
- TLogger extends FnLogger = FnLogger
226
+ TLogger extends FnLogger = FnLogger,
227
+ TScoped extends Partial<Record<keyof TProcedures, object>> = Record<
228
+ never,
229
+ never
230
+ >
217
231
  >(
218
232
  options: CreateFnOptions<
219
233
  TProcedures,
@@ -223,7 +237,8 @@ export function createFn<
223
237
  TMeta,
224
238
  TTag,
225
239
  TOtel,
226
- TLogger
240
+ TLogger,
241
+ TScoped
227
242
  > &
228
243
  FiniteExtras<NoInfer<TExtras>>
229
244
  ): FnFactory<
@@ -235,28 +250,42 @@ export function createFn<
235
250
  TMeta,
236
251
  TTag,
237
252
  TOtel,
238
- TLogger
253
+ TLogger,
254
+ TScoped
239
255
  >
240
256
  > {
241
257
  const runtime = options as unknown as RuntimeOptions
242
258
  const tracing = createTracing(runtime.otel)
243
- const defaultLogger = createDefaultLogger('fn')
244
- const createLogger =
245
- runtime.logger ??
246
- ((scope: string) =>
247
- scope === 'fn' ? defaultLogger : createDefaultLogger(scope))
259
+ const defaultLogger = protectLogger(createDefaultLogger('fn'))
260
+ const createLogger = (
261
+ scope: string,
262
+ span: SpanLike | undefined
263
+ ): FnLogger => {
264
+ try {
265
+ return protectLogger(
266
+ runtime.logger?.(scope, span) ??
267
+ (scope === 'fn' ? defaultLogger : createDefaultLogger(scope))
268
+ )
269
+ } catch {
270
+ return defaultLogger
271
+ }
272
+ }
248
273
  const isExpectedError = runtime.isExpectedError ?? isExpectedClientError
249
274
  const guards = runtime.guards ?? {}
250
275
  const isGuard = (option: string) => Object.hasOwn(guards, option)
251
276
  for (const guard of Object.keys(guards)) {
277
+ if (typeof guards[guard] !== 'function')
278
+ throw new TypeError(`orpc-fn: guard ${guard} must be a function`)
252
279
  if ((RESERVED_OPTION_KEYS as readonly string[]).includes(guard)) {
253
280
  throw new Error(
254
281
  `orpc-fn: guard "${guard}" reuses a route option name; rename it`
255
282
  )
256
283
  }
257
284
  }
285
+ if (runtime.default && !Object.hasOwn(runtime.procedures, runtime.default))
286
+ throw new TypeError(`orpc-fn: unknown default procedure ${runtime.default}`)
258
287
  for (const [key, builder] of Object.entries(runtime.procedures)) {
259
- const def = (builder as { '~orpc'?: Record<string, unknown> })['~orpc']
288
+ const def = procedureDefinition(builder)
260
289
  if (def?.inputSchema !== undefined || def?.outputSchema !== undefined) {
261
290
  throw new Error(
262
291
  `orpc-fn: procedure "${key}" sets an input or output schema; set schemas on routes instead`
@@ -274,139 +303,204 @@ export function createFn<
274
303
  return key
275
304
  }
276
305
 
277
- const buildProcedure: BuildProcedure = (route) => {
278
- const key = resolveKey(route.procedure, route.name)
279
- let builder = runtime.procedures[key]
280
- if (!builder) throw new Error(`orpc-fn: unknown procedure "${key}"`)
281
- const stored: StoredFnMeta = {
282
- name: route.name,
283
- procedure: key,
284
- meta: route.meta
285
- }
286
- builder = builder
287
- .route({
288
- operationId: route.name,
289
- ...Object.fromEntries(
290
- Object.entries(route.route).filter(([, value]) => value !== undefined)
291
- )
292
- })
293
- .meta({ [FN_META_KEY]: stored })
294
- if (route.input) builder = builder.input(route.input)
295
- if (route.output) builder = builder.output(route.output)
296
- return builder.handler(route.handler)
297
- }
306
+ const buildProcedure = createProcedureAssembler(
307
+ runtime.procedures,
308
+ resolveKey
309
+ )
298
310
 
299
- // biome-ignore lint/suspicious/noExplicitAny: the overloads in `Fn` carry the types
300
- const fn = (routeOptions: any): AnyProcedure => {
301
- const { handler, input, output, name, procedure, ...rest } = routeOptions
311
+ const invocation = Symbol('orpc-fn.invocation')
312
+ type Invocation = {
313
+ span: SpanLike | undefined
314
+ logger: FnLogger
315
+ input: unknown
316
+ context: unknown
317
+ handlerStarted: boolean
318
+ rawInput: unknown
319
+ }
320
+ // The overload-to-runtime boundary is adapted once at the returned factory.
321
+ const fn = (routeOptions: Record<string, unknown>): AnyProcedure => {
322
+ const {
323
+ handler,
324
+ input,
325
+ output,
326
+ name,
327
+ procedure,
328
+ meta,
329
+ stream,
330
+ errors,
331
+ guardResolvers,
332
+ route,
333
+ guardChecks
334
+ } = normalizeRoute(routeOptions, isGuard)
302
335
  const key = resolveKey(procedure, name)
303
- const route: Record<string, unknown> = {}
304
- const guardChecks: Array<[string, unknown]> = []
305
- const meta: Record<string, unknown> = {}
306
- for (const [option, value] of Object.entries(rest)) {
307
- if (value === undefined) continue
308
- if ((ROUTE_OPTION_KEYS as readonly string[]).includes(option))
309
- route[option] = value
310
- else if (isGuard(option)) guardChecks.push([option, value])
311
- else meta[option] = value
312
- }
313
-
314
- return buildProcedure({
336
+ const built = buildProcedure({
315
337
  name,
316
338
  procedure: key,
317
339
  route,
318
340
  meta,
319
341
  input,
320
342
  output,
321
- handler: (params) => {
343
+ stream,
344
+ ...(errors ? { errors } : {}),
345
+ handler: async (params) => {
322
346
  const { context, signal } = params
323
- const startTime = performance.now()
324
- const elapsedMs = () =>
325
- Math.round((performance.now() - startTime) * 100) / 100
326
- let logger: FnLogger = defaultLogger
327
- return tracing.inSpan(
347
+ const state = context[invocation] as Invocation
348
+ const { [invocation]: _invocation, ...observedContext } = context
349
+ state.context = observedContext
350
+ state.input = params.input
351
+ state.handlerStarted = true
352
+ try {
353
+ const attributes = runtime.spanAttributes?.({
354
+ context,
355
+ name,
356
+ procedure: key,
357
+ meta
358
+ })
359
+ for (const [attribute, value] of Object.entries(attributes ?? {}))
360
+ if (value !== undefined) state.span?.setAttribute(attribute, value)
361
+ } catch (error) {
362
+ state.logger.warn('spanAttributes hook failed', {
363
+ error: errorMessageOf(error)
364
+ })
365
+ }
366
+ const resolvedChecks = [...guardChecks]
367
+ for (const [guard, resolver] of Object.entries(guardResolvers)) {
368
+ const value = await resolver({ input: params.input, context, signal })
369
+ if (value !== undefined) resolvedChecks.push([guard, value])
370
+ }
371
+ for (const [guard, value] of resolvedChecks) {
372
+ const result = await guards[guard]?.(value, {
373
+ context,
374
+ input: params.input,
375
+ name,
376
+ procedure: key,
377
+ meta,
378
+ signal
379
+ })
380
+ if (result === false)
381
+ throw new ORPCError('FORBIDDEN', {
382
+ message: `Guard "${guard}" denied this call`
383
+ })
384
+ if (result !== undefined && result !== true)
385
+ throw new TypeError(
386
+ `orpc-fn: guard "${guard}" must return void or boolean`
387
+ )
388
+ }
389
+ const extraParams = {
390
+ context,
391
+ span: state.span,
392
+ signal,
393
+ name,
394
+ procedure: key
395
+ }
396
+ const shared = await runtime.extras?.(extraParams)
397
+ const scoped = await runtime.extrasByProcedure?.[key]?.(extraParams)
398
+ const extras = {
399
+ ...(shared as object | undefined),
400
+ ...(scoped as object | undefined)
401
+ }
402
+ if (extras && typeof extras === 'object') {
403
+ for (const reserved of [
404
+ 'input',
405
+ 'context',
406
+ 'call',
407
+ 'signal',
408
+ 'span',
409
+ 'logger',
410
+ 'errors',
411
+ 'lastEventId'
412
+ ]) {
413
+ if (Object.hasOwn(extras, reserved))
414
+ throw new TypeError(
415
+ `orpc-fn: extra "${reserved}" shadows a handler parameter`
416
+ )
417
+ }
418
+ }
419
+ return handler({
420
+ ...(extras as object | undefined),
421
+ input: params.input,
422
+ context,
423
+ call: createBoundCall(context, signal, tracing),
424
+ signal,
425
+ span: state.span,
426
+ logger: state.logger,
427
+ errors: params.errors,
428
+ lastEventId: params.lastEventId,
429
+ [RAW_INPUT]: state.rawInput
430
+ })
431
+ }
432
+ })
433
+ // Prepending middleware shifts oRPC's validation indexes, covering auth,
434
+ // input validation, output validation, and the final stream lifetime.
435
+ return os
436
+ .use(async ({ context, next }, rawInput) => {
437
+ const started = performance.now()
438
+ const state: Invocation = {
439
+ span: undefined,
440
+ logger: defaultLogger,
441
+ input: undefined,
442
+ context,
443
+ handlerStarted: false,
444
+ rawInput
445
+ }
446
+ const result = await tracing.inSpan(
328
447
  name,
329
448
  'SERVER',
330
449
  async (span) => {
331
- logger = createLogger('fn', span)
450
+ state.span = span
451
+ state.logger = createLogger('fn', span)
332
452
  span?.setAttribute('fn.operation', name)
333
453
  span?.setAttribute('fn.procedure', key)
334
- const attributes = runtime.spanAttributes?.({
335
- context,
336
- name,
337
- procedure: key,
338
- meta
339
- })
340
- for (const [attribute, value] of Object.entries(attributes ?? {})) {
341
- if (value !== undefined) span?.setAttribute(attribute, value)
342
- }
343
454
  try {
344
- for (const [guard, value] of guardChecks) {
345
- await guards[guard]?.(value, {
346
- context,
347
- input: params.input,
348
- name,
349
- procedure: key,
350
- meta,
351
- signal
352
- })
455
+ const result = await next({ context: { [invocation]: state } })
456
+ if (isAsyncIteratorObject(result.output) !== stream) {
457
+ if (isAsyncIteratorObject(result.output)) {
458
+ try {
459
+ await result.output.return?.()
460
+ } catch {
461
+ /* Release the unexpected stream. */
462
+ }
463
+ }
464
+ throw new TypeError(
465
+ `orpc-fn: ${name} returned a result inconsistent with stream; declare stream: true for iterators`
466
+ )
353
467
  }
354
- const extras = await runtime.extras?.({
355
- context,
356
- span,
357
- signal,
358
- name,
359
- procedure: key
360
- })
361
- return await handler({
362
- ...(extras as object | undefined),
363
- input: params.input,
364
- context,
365
- call: createBoundCall(context, signal, tracing),
366
- signal,
367
- span,
368
- logger
369
- })
468
+ return result.output
370
469
  } finally {
371
- // Server-Timing reports the time until the result (or stream) exists.
372
- const timing = context.timing
373
- if (typeof timing === 'object' && timing !== null) {
374
- ;(timing as Record<string, unknown>).handler_ms = elapsedMs()
470
+ const timing = (context as Record<symbol, unknown>)[
471
+ REQUEST_TIMING
472
+ ] as InvocationTiming | undefined
473
+ if (timing && !(context as Record<symbol, unknown>)[invocation]) {
474
+ timing.procedureMs += performance.now() - started
475
+ timing.calls++
375
476
  }
376
477
  }
377
478
  },
378
- // After the result, or after a returned stream ends; before the span ends.
379
479
  (span, ...error) => {
380
- const durationMs = elapsedMs()
480
+ const durationMs =
481
+ Math.round((performance.now() - started) * 100) / 100
381
482
  span?.setAttribute('fn.duration_ms', durationMs)
382
- logCompleted(logger, {
483
+ logCompleted(state.logger, {
383
484
  name,
384
485
  procedure: key,
385
486
  durationMs,
386
487
  success: error.length === 0,
387
488
  error: error[0],
388
- input: params.input,
389
- context,
489
+ input: state.input,
490
+ context: state.context,
390
491
  span,
391
- meta
492
+ meta,
493
+ handlerStarted: state.handlerStarted
392
494
  })
393
495
  }
394
496
  )
395
- }
396
- })
497
+ return { output: result, context }
498
+ })
499
+ .router({ route: built }).route
397
500
  }
398
501
 
399
502
  const logCompleted = (logger: FnLogger, event: FnCompletedEvent) => {
400
503
  const { error, success } = event
401
- const timing = (event.context as { timing?: unknown }).timing
402
- const timingFields: LogAttributes = {}
403
- if (typeof timing === 'object' && timing !== null) {
404
- for (const [field, value] of Object.entries(timing)) {
405
- if (field !== 'handler_ms' && typeof value === 'number') {
406
- timingFields[field] = value
407
- }
408
- }
409
- }
410
504
  let extra: LogAttributes | undefined
411
505
  try {
412
506
  extra = runtime.onCompleted?.(event)
@@ -415,26 +509,31 @@ export function createFn<
415
509
  error: errorMessageOf(hookError)
416
510
  })
417
511
  }
418
- const level = success ? 'info' : isExpectedError(error) ? 'warn' : 'error'
512
+ let expected = false
513
+ try {
514
+ expected = isExpectedError(error)
515
+ } catch {
516
+ expected = isExpectedClientError(error)
517
+ }
518
+ const level = success ? 'info' : expected ? 'warn' : 'error'
419
519
  logger[level]('fn.completed', {
520
+ ...extra,
420
521
  event: 'fn.completed',
421
522
  operation: event.name,
422
523
  procedure: event.procedure,
423
524
  duration_ms: event.durationMs,
424
525
  status: success ? 'success' : 'failed',
425
- ...timingFields,
426
526
  ...(error === undefined
427
527
  ? {}
428
528
  : {
429
529
  error_type: error instanceof Error ? error.name : typeof error,
430
530
  error_message: errorMessageOf(error)
431
- }),
432
- ...extra
531
+ })
433
532
  })
434
533
  }
435
534
 
436
535
  const live = createLiveRuntime({
437
- buildProcedure,
536
+ fn,
438
537
  tracing,
439
538
  createLogger,
440
539
  pubsub: runtime.pubsub
@@ -443,19 +542,33 @@ export function createFn<
443
542
  // The runtimes are untyped; `Fn`, `FnLive`, `CreatePubSub` and
444
543
  // `CreatePublisher` are their typed surface.
445
544
  return {
446
- fn: fn as never,
545
+ fn,
447
546
  fnLive: createFnLive({
448
547
  fn,
449
548
  createChannel: live.createChannel,
450
549
  createLogger,
451
550
  isGuard
452
- }) as never,
453
- createPubSub: live.createPubSub as never,
454
- createPublisher: live.createPublisher as never,
551
+ }),
552
+ createPubSub: live.createPubSub,
553
+ createPublisher: live.createPublisher,
455
554
  createRouter,
456
- readMeta: (procedure) => readFnMeta(procedure),
457
- createCall: (context, signal) => createBoundCall(context, signal, tracing),
555
+ readMeta: readFnMeta,
556
+ createCall: <TContext>(context: TContext, signal?: AbortSignal) =>
557
+ createBoundCall(context, signal, tracing),
558
+ shutdown: live.shutdown,
458
559
  drainPubSubSubscribers: live.drainPubSubSubscribers,
459
560
  activePubSubSubscriberCount: live.activePubSubSubscriberCount
460
- }
561
+ } as unknown as FnFactory<
562
+ Definition<
563
+ TProcedures,
564
+ TDefault,
565
+ TExtras,
566
+ TGuards,
567
+ TMeta,
568
+ TTag,
569
+ TOtel,
570
+ TLogger,
571
+ TScoped
572
+ >
573
+ >
461
574
  }