orpc-fn 0.1.0 → 0.2.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 (141) hide show
  1. package/README.md +48 -32
  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/client.d.ts +4 -2
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/compatibility.d.ts +16 -0
  14. package/dist/compatibility.d.ts.map +1 -0
  15. package/dist/compatibility.js +4 -0
  16. package/dist/compatibility.js.map +1 -0
  17. package/dist/create-fn.d.ts +30 -34
  18. package/dist/create-fn.d.ts.map +1 -1
  19. package/dist/create-fn.js +177 -121
  20. package/dist/create-fn.js.map +1 -1
  21. package/dist/expo.d.ts +11 -25
  22. package/dist/expo.d.ts.map +1 -1
  23. package/dist/expo.js +25 -30
  24. package/dist/expo.js.map +1 -1
  25. package/dist/hono.d.ts +12 -4
  26. package/dist/hono.d.ts.map +1 -1
  27. package/dist/hono.js +29 -12
  28. package/dist/hono.js.map +1 -1
  29. package/dist/index.d.ts +2 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/json-schema.d.ts +2 -0
  34. package/dist/json-schema.d.ts.map +1 -1
  35. package/dist/json-schema.js +11 -0
  36. package/dist/json-schema.js.map +1 -1
  37. package/dist/live/codec.d.ts +6 -1
  38. package/dist/live/codec.d.ts.map +1 -1
  39. package/dist/live/codec.js +14 -3
  40. package/dist/live/codec.js.map +1 -1
  41. package/dist/live/fn-live.d.ts +63 -22
  42. package/dist/live/fn-live.d.ts.map +1 -1
  43. package/dist/live/fn-live.js +98 -16
  44. package/dist/live/fn-live.js.map +1 -1
  45. package/dist/live/index.d.ts +1 -1
  46. package/dist/live/index.d.ts.map +1 -1
  47. package/dist/live/index.js.map +1 -1
  48. package/dist/live/ioredis.d.ts +4 -1
  49. package/dist/live/ioredis.d.ts.map +1 -1
  50. package/dist/live/ioredis.js +21 -2
  51. package/dist/live/ioredis.js.map +1 -1
  52. package/dist/live/lifecycle.d.ts +7 -0
  53. package/dist/live/lifecycle.d.ts.map +1 -0
  54. package/dist/live/lifecycle.js +36 -0
  55. package/dist/live/lifecycle.js.map +1 -0
  56. package/dist/live/memory.d.ts.map +1 -1
  57. package/dist/live/memory.js +4 -0
  58. package/dist/live/memory.js.map +1 -1
  59. package/dist/live/pub-sub.d.ts +76 -12
  60. package/dist/live/pub-sub.d.ts.map +1 -1
  61. package/dist/live/pub-sub.js +429 -125
  62. package/dist/live/pub-sub.js.map +1 -1
  63. package/dist/live/redis-bun.d.ts +1 -1
  64. package/dist/live/redis-bun.d.ts.map +1 -1
  65. package/dist/live/redis-bun.js +3 -5
  66. package/dist/live/redis-bun.js.map +1 -1
  67. package/dist/live/redis.d.ts +2 -2
  68. package/dist/live/redis.d.ts.map +1 -1
  69. package/dist/live/redis.js +36 -9
  70. package/dist/live/redis.js.map +1 -1
  71. package/dist/live/transport.d.ts +5 -1
  72. package/dist/live/transport.d.ts.map +1 -1
  73. package/dist/live/transport.js.map +1 -1
  74. package/dist/logger.d.ts +2 -0
  75. package/dist/logger.d.ts.map +1 -1
  76. package/dist/logger.js +21 -0
  77. package/dist/logger.js.map +1 -1
  78. package/dist/mastra.d.ts.map +1 -1
  79. package/dist/mastra.js +16 -7
  80. package/dist/mastra.js.map +1 -1
  81. package/dist/mcp/sdk.d.ts +20 -0
  82. package/dist/mcp/sdk.d.ts.map +1 -0
  83. package/dist/mcp/sdk.js +82 -0
  84. package/dist/mcp/sdk.js.map +1 -0
  85. package/dist/mcp.d.ts +3 -2
  86. package/dist/mcp.d.ts.map +1 -1
  87. package/dist/mcp.js +22 -7
  88. package/dist/mcp.js.map +1 -1
  89. package/dist/meta.d.ts +15 -7
  90. package/dist/meta.d.ts.map +1 -1
  91. package/dist/meta.js +9 -2
  92. package/dist/meta.js.map +1 -1
  93. package/dist/otel.d.ts.map +1 -1
  94. package/dist/otel.js +77 -6
  95. package/dist/otel.js.map +1 -1
  96. package/dist/route-options.d.ts +22 -0
  97. package/dist/route-options.d.ts.map +1 -0
  98. package/dist/route-options.js +67 -0
  99. package/dist/route-options.js.map +1 -0
  100. package/dist/router.d.ts +5 -1
  101. package/dist/router.d.ts.map +1 -1
  102. package/dist/router.js +22 -0
  103. package/dist/router.js.map +1 -1
  104. package/dist/timing.d.ts +8 -0
  105. package/dist/timing.d.ts.map +1 -0
  106. package/dist/timing.js +4 -0
  107. package/dist/timing.js.map +1 -0
  108. package/dist/types.d.ts +86 -41
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js +6 -2
  111. package/dist/types.js.map +1 -1
  112. package/package.json +15 -3
  113. package/src/assemble-procedure.ts +42 -0
  114. package/src/bound-call.ts +4 -3
  115. package/src/client.ts +9 -2
  116. package/src/compatibility.ts +39 -0
  117. package/src/create-fn.ts +297 -183
  118. package/src/expo.ts +49 -44
  119. package/src/hono.ts +36 -17
  120. package/src/index.ts +6 -2
  121. package/src/json-schema.ts +16 -0
  122. package/src/live/codec.ts +22 -3
  123. package/src/live/fn-live.ts +244 -69
  124. package/src/live/index.ts +2 -0
  125. package/src/live/ioredis.ts +22 -2
  126. package/src/live/lifecycle.ts +44 -0
  127. package/src/live/memory.ts +4 -0
  128. package/src/live/pub-sub.ts +608 -166
  129. package/src/live/redis-bun.ts +7 -5
  130. package/src/live/redis.ts +37 -11
  131. package/src/live/transport.ts +4 -1
  132. package/src/logger.ts +20 -0
  133. package/src/mastra.ts +15 -7
  134. package/src/mcp/sdk.ts +118 -0
  135. package/src/mcp.ts +48 -20
  136. package/src/meta.ts +40 -24
  137. package/src/otel.ts +77 -10
  138. package/src/route-options.ts +100 -0
  139. package/src/router.ts +39 -1
  140. package/src/timing.ts +5 -0
  141. package/src/types.ts +204 -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 FiniteScopedExtras,
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]>
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,9 +237,11 @@ export function createFn<
223
237
  TMeta,
224
238
  TTag,
225
239
  TOtel,
226
- TLogger
240
+ TLogger,
241
+ TScoped
227
242
  > &
228
- FiniteExtras<NoInfer<TExtras>>
243
+ FiniteExtras<NoInfer<TExtras>> &
244
+ FiniteScopedExtras<NoInfer<TScoped>>
229
245
  ): FnFactory<
230
246
  Definition<
231
247
  TProcedures,
@@ -235,28 +251,42 @@ export function createFn<
235
251
  TMeta,
236
252
  TTag,
237
253
  TOtel,
238
- TLogger
254
+ TLogger,
255
+ TScoped
239
256
  >
240
257
  > {
241
258
  const runtime = options as unknown as RuntimeOptions
242
259
  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))
260
+ const defaultLogger = protectLogger(createDefaultLogger('fn'))
261
+ const createLogger = (
262
+ scope: string,
263
+ span: SpanLike | undefined
264
+ ): FnLogger => {
265
+ try {
266
+ return protectLogger(
267
+ runtime.logger?.(scope, span) ??
268
+ (scope === 'fn' ? defaultLogger : createDefaultLogger(scope))
269
+ )
270
+ } catch {
271
+ return defaultLogger
272
+ }
273
+ }
248
274
  const isExpectedError = runtime.isExpectedError ?? isExpectedClientError
249
275
  const guards = runtime.guards ?? {}
250
276
  const isGuard = (option: string) => Object.hasOwn(guards, option)
251
277
  for (const guard of Object.keys(guards)) {
278
+ if (typeof guards[guard] !== 'function')
279
+ throw new TypeError(`orpc-fn: guard ${guard} must be a function`)
252
280
  if ((RESERVED_OPTION_KEYS as readonly string[]).includes(guard)) {
253
281
  throw new Error(
254
282
  `orpc-fn: guard "${guard}" reuses a route option name; rename it`
255
283
  )
256
284
  }
257
285
  }
286
+ if (runtime.default && !Object.hasOwn(runtime.procedures, runtime.default))
287
+ throw new TypeError(`orpc-fn: unknown default procedure ${runtime.default}`)
258
288
  for (const [key, builder] of Object.entries(runtime.procedures)) {
259
- const def = (builder as { '~orpc'?: Record<string, unknown> })['~orpc']
289
+ const def = procedureDefinition(builder)
260
290
  if (def?.inputSchema !== undefined || def?.outputSchema !== undefined) {
261
291
  throw new Error(
262
292
  `orpc-fn: procedure "${key}" sets an input or output schema; set schemas on routes instead`
@@ -274,139 +304,204 @@ export function createFn<
274
304
  return key
275
305
  }
276
306
 
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
- }
307
+ const buildProcedure = createProcedureAssembler(
308
+ runtime.procedures,
309
+ resolveKey
310
+ )
298
311
 
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
312
+ const invocation = Symbol('orpc-fn.invocation')
313
+ type Invocation = {
314
+ span: SpanLike | undefined
315
+ logger: FnLogger
316
+ input: unknown
317
+ context: unknown
318
+ handlerStarted: boolean
319
+ rawInput: unknown
320
+ }
321
+ // The overload-to-runtime boundary is adapted once at the returned factory.
322
+ const fn = (routeOptions: Record<string, unknown>): AnyProcedure => {
323
+ const {
324
+ handler,
325
+ input,
326
+ output,
327
+ name,
328
+ procedure,
329
+ meta,
330
+ stream,
331
+ errors,
332
+ guardResolvers,
333
+ route,
334
+ guardChecks
335
+ } = normalizeRoute(routeOptions, isGuard)
302
336
  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({
337
+ const built = buildProcedure({
315
338
  name,
316
339
  procedure: key,
317
340
  route,
318
341
  meta,
319
342
  input,
320
343
  output,
321
- handler: (params) => {
344
+ stream,
345
+ ...(errors ? { errors } : {}),
346
+ handler: async (params) => {
322
347
  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(
348
+ const state = context[invocation] as Invocation
349
+ const { [invocation]: _invocation, ...observedContext } = context
350
+ state.context = observedContext
351
+ state.input = params.input
352
+ state.handlerStarted = true
353
+ try {
354
+ const attributes = runtime.spanAttributes?.({
355
+ context,
356
+ name,
357
+ procedure: key,
358
+ meta
359
+ })
360
+ for (const [attribute, value] of Object.entries(attributes ?? {}))
361
+ if (value !== undefined) state.span?.setAttribute(attribute, value)
362
+ } catch (error) {
363
+ state.logger.warn('spanAttributes hook failed', {
364
+ error: errorMessageOf(error)
365
+ })
366
+ }
367
+ const resolvedChecks = [...guardChecks]
368
+ for (const [guard, resolver] of Object.entries(guardResolvers)) {
369
+ const value = await resolver({ input: params.input, context, signal })
370
+ if (value !== undefined) resolvedChecks.push([guard, value])
371
+ }
372
+ for (const [guard, value] of resolvedChecks) {
373
+ const result = await guards[guard]?.(value, {
374
+ context,
375
+ input: params.input,
376
+ name,
377
+ procedure: key,
378
+ meta,
379
+ signal
380
+ })
381
+ if (result === false)
382
+ throw new ORPCError('FORBIDDEN', {
383
+ message: `Guard "${guard}" denied this call`
384
+ })
385
+ if (result !== undefined && result !== true)
386
+ throw new TypeError(
387
+ `orpc-fn: guard "${guard}" must return void or boolean`
388
+ )
389
+ }
390
+ const extraParams = {
391
+ context,
392
+ span: state.span,
393
+ signal,
394
+ name,
395
+ procedure: key
396
+ }
397
+ const shared = await runtime.extras?.(extraParams)
398
+ const scoped = await runtime.extrasByProcedure?.[key]?.(extraParams)
399
+ const extras = {
400
+ ...(shared as object | undefined),
401
+ ...(scoped as object | undefined)
402
+ }
403
+ if (extras && typeof extras === 'object') {
404
+ for (const reserved of [
405
+ 'input',
406
+ 'context',
407
+ 'call',
408
+ 'signal',
409
+ 'span',
410
+ 'logger',
411
+ 'errors',
412
+ 'lastEventId'
413
+ ]) {
414
+ if (Object.hasOwn(extras, reserved))
415
+ throw new TypeError(
416
+ `orpc-fn: extra "${reserved}" shadows a handler parameter`
417
+ )
418
+ }
419
+ }
420
+ return handler({
421
+ ...(extras as object | undefined),
422
+ input: params.input,
423
+ context,
424
+ call: createBoundCall(context, signal, tracing),
425
+ signal,
426
+ span: state.span,
427
+ logger: state.logger,
428
+ errors: params.errors,
429
+ lastEventId: params.lastEventId,
430
+ [RAW_INPUT]: state.rawInput
431
+ })
432
+ }
433
+ })
434
+ // Prepending middleware shifts oRPC's validation indexes, covering auth,
435
+ // input validation, output validation, and the final stream lifetime.
436
+ return os
437
+ .use(async ({ context, next }, rawInput) => {
438
+ const started = performance.now()
439
+ const state: Invocation = {
440
+ span: undefined,
441
+ logger: defaultLogger,
442
+ input: undefined,
443
+ context,
444
+ handlerStarted: false,
445
+ rawInput
446
+ }
447
+ const result = await tracing.inSpan(
328
448
  name,
329
449
  'SERVER',
330
450
  async (span) => {
331
- logger = createLogger('fn', span)
451
+ state.span = span
452
+ state.logger = createLogger('fn', span)
332
453
  span?.setAttribute('fn.operation', name)
333
454
  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
455
  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
- })
456
+ const result = await next({ context: { [invocation]: state } })
457
+ if (isAsyncIteratorObject(result.output) !== stream) {
458
+ if (isAsyncIteratorObject(result.output)) {
459
+ try {
460
+ await result.output.return?.()
461
+ } catch {
462
+ /* Release the unexpected stream. */
463
+ }
464
+ }
465
+ throw new TypeError(
466
+ `orpc-fn: ${name} returned a result inconsistent with stream; declare stream: true for iterators`
467
+ )
353
468
  }
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
- })
469
+ return result.output
370
470
  } 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()
471
+ const timing = (context as Record<symbol, unknown>)[
472
+ REQUEST_TIMING
473
+ ] as InvocationTiming | undefined
474
+ if (timing && !(context as Record<symbol, unknown>)[invocation]) {
475
+ timing.procedureMs += performance.now() - started
476
+ timing.calls++
375
477
  }
376
478
  }
377
479
  },
378
- // After the result, or after a returned stream ends; before the span ends.
379
480
  (span, ...error) => {
380
- const durationMs = elapsedMs()
481
+ const durationMs =
482
+ Math.round((performance.now() - started) * 100) / 100
381
483
  span?.setAttribute('fn.duration_ms', durationMs)
382
- logCompleted(logger, {
484
+ logCompleted(state.logger, {
383
485
  name,
384
486
  procedure: key,
385
487
  durationMs,
386
488
  success: error.length === 0,
387
489
  error: error[0],
388
- input: params.input,
389
- context,
490
+ input: state.input,
491
+ context: state.context,
390
492
  span,
391
- meta
493
+ meta,
494
+ handlerStarted: state.handlerStarted
392
495
  })
393
496
  }
394
497
  )
395
- }
396
- })
498
+ return { output: result, context }
499
+ })
500
+ .router({ route: built }).route
397
501
  }
398
502
 
399
503
  const logCompleted = (logger: FnLogger, event: FnCompletedEvent) => {
400
504
  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
505
  let extra: LogAttributes | undefined
411
506
  try {
412
507
  extra = runtime.onCompleted?.(event)
@@ -415,26 +510,31 @@ export function createFn<
415
510
  error: errorMessageOf(hookError)
416
511
  })
417
512
  }
418
- const level = success ? 'info' : isExpectedError(error) ? 'warn' : 'error'
513
+ let expected = false
514
+ try {
515
+ expected = isExpectedError(error)
516
+ } catch {
517
+ expected = isExpectedClientError(error)
518
+ }
519
+ const level = success ? 'info' : expected ? 'warn' : 'error'
419
520
  logger[level]('fn.completed', {
521
+ ...extra,
420
522
  event: 'fn.completed',
421
523
  operation: event.name,
422
524
  procedure: event.procedure,
423
525
  duration_ms: event.durationMs,
424
526
  status: success ? 'success' : 'failed',
425
- ...timingFields,
426
527
  ...(error === undefined
427
528
  ? {}
428
529
  : {
429
530
  error_type: error instanceof Error ? error.name : typeof error,
430
531
  error_message: errorMessageOf(error)
431
- }),
432
- ...extra
532
+ })
433
533
  })
434
534
  }
435
535
 
436
536
  const live = createLiveRuntime({
437
- buildProcedure,
537
+ fn,
438
538
  tracing,
439
539
  createLogger,
440
540
  pubsub: runtime.pubsub
@@ -443,19 +543,33 @@ export function createFn<
443
543
  // The runtimes are untyped; `Fn`, `FnLive`, `CreatePubSub` and
444
544
  // `CreatePublisher` are their typed surface.
445
545
  return {
446
- fn: fn as never,
546
+ fn,
447
547
  fnLive: createFnLive({
448
548
  fn,
449
549
  createChannel: live.createChannel,
450
550
  createLogger,
451
551
  isGuard
452
- }) as never,
453
- createPubSub: live.createPubSub as never,
454
- createPublisher: live.createPublisher as never,
552
+ }),
553
+ createPubSub: live.createPubSub,
554
+ createPublisher: live.createPublisher,
455
555
  createRouter,
456
- readMeta: (procedure) => readFnMeta(procedure),
457
- createCall: (context, signal) => createBoundCall(context, signal, tracing),
556
+ readMeta: readFnMeta,
557
+ createCall: <TContext>(context: TContext, signal?: AbortSignal) =>
558
+ createBoundCall(context, signal, tracing),
559
+ shutdown: live.shutdown,
458
560
  drainPubSubSubscribers: live.drainPubSubSubscribers,
459
561
  activePubSubSubscriberCount: live.activePubSubSubscriberCount
460
- }
562
+ } as unknown as FnFactory<
563
+ Definition<
564
+ TProcedures,
565
+ TDefault,
566
+ TExtras,
567
+ TGuards,
568
+ TMeta,
569
+ TTag,
570
+ TOtel,
571
+ TLogger,
572
+ TScoped
573
+ >
574
+ >
461
575
  }