orpc-fn 0.1.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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/bound-call.d.ts +23 -0
- package/dist/bound-call.d.ts.map +1 -0
- package/dist/bound-call.js +23 -0
- package/dist/bound-call.js.map +1 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -0
- package/dist/create-fn.d.ts +97 -0
- package/dist/create-fn.d.ts.map +1 -0
- package/dist/create-fn.js +224 -0
- package/dist/create-fn.js.map +1 -0
- package/dist/expected-client-error.d.ts +11 -0
- package/dist/expected-client-error.d.ts.map +1 -0
- package/dist/expected-client-error.js +21 -0
- package/dist/expected-client-error.js.map +1 -0
- package/dist/expo.d.ts +66 -0
- package/dist/expo.d.ts.map +1 -0
- package/dist/expo.js +74 -0
- package/dist/expo.js.map +1 -0
- package/dist/hono.d.ts +81 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +138 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/json-schema.d.ts +47 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +44 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/live/codec.d.ts +9 -0
- package/dist/live/codec.d.ts.map +1 -0
- package/dist/live/codec.js +27 -0
- package/dist/live/codec.js.map +1 -0
- package/dist/live/fn-live.d.ts +155 -0
- package/dist/live/fn-live.d.ts.map +1 -0
- package/dist/live/fn-live.js +210 -0
- package/dist/live/fn-live.js.map +1 -0
- package/dist/live/index.d.ts +4 -0
- package/dist/live/index.d.ts.map +1 -0
- package/dist/live/index.js +5 -0
- package/dist/live/index.js.map +1 -0
- package/dist/live/ioredis.d.ts +29 -0
- package/dist/live/ioredis.d.ts.map +1 -0
- package/dist/live/ioredis.js +49 -0
- package/dist/live/ioredis.js.map +1 -0
- package/dist/live/memory.d.ts +10 -0
- package/dist/live/memory.d.ts.map +1 -0
- package/dist/live/memory.js +45 -0
- package/dist/live/memory.js.map +1 -0
- package/dist/live/pub-sub.d.ts +211 -0
- package/dist/live/pub-sub.d.ts.map +1 -0
- package/dist/live/pub-sub.js +529 -0
- package/dist/live/pub-sub.js.map +1 -0
- package/dist/live/redis-bun.d.ts +26 -0
- package/dist/live/redis-bun.d.ts.map +1 -0
- package/dist/live/redis-bun.js +59 -0
- package/dist/live/redis-bun.js.map +1 -0
- package/dist/live/redis.d.ts +45 -0
- package/dist/live/redis.d.ts.map +1 -0
- package/dist/live/redis.js +214 -0
- package/dist/live/redis.js.map +1 -0
- package/dist/live/transport.d.ts +35 -0
- package/dist/live/transport.d.ts.map +1 -0
- package/dist/live/transport.js +2 -0
- package/dist/live/transport.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mastra.d.ts +44 -0
- package/dist/mastra.d.ts.map +1 -0
- package/dist/mastra.js +78 -0
- package/dist/mastra.js.map +1 -0
- package/dist/mcp.d.ts +39 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +38 -0
- package/dist/mcp.js.map +1 -0
- package/dist/meta.d.ts +35 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +30 -0
- package/dist/meta.js.map +1 -0
- package/dist/otel.d.ts +66 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +63 -0
- package/dist/otel.js.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +14 -0
- package/dist/router.js.map +1 -0
- package/dist/stream.d.ts +13 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +44 -0
- package/dist/stream.js.map +1 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/package.json +157 -0
- package/src/bound-call.ts +50 -0
- package/src/client.ts +82 -0
- package/src/create-fn.ts +461 -0
- package/src/expected-client-error.ts +23 -0
- package/src/expo.ts +127 -0
- package/src/hono.ts +247 -0
- package/src/index.ts +45 -0
- package/src/json-schema.ts +82 -0
- package/src/live/codec.ts +31 -0
- package/src/live/fn-live.ts +470 -0
- package/src/live/index.ts +31 -0
- package/src/live/ioredis.ts +74 -0
- package/src/live/memory.ts +50 -0
- package/src/live/pub-sub.ts +828 -0
- package/src/live/redis-bun.ts +76 -0
- package/src/live/redis.ts +255 -0
- package/src/live/transport.ts +41 -0
- package/src/logger.ts +28 -0
- package/src/mastra.ts +140 -0
- package/src/mcp.ts +73 -0
- package/src/meta.ts +72 -0
- package/src/otel.ts +151 -0
- package/src/router.ts +15 -0
- package/src/stream.ts +54 -0
- package/src/types.ts +389 -0
package/src/create-fn.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type { AnySchema } from '@orpc/contract'
|
|
2
|
+
import type { AnyProcedure } from '@orpc/server'
|
|
3
|
+
import { type BoundCall, createBoundCall } from './bound-call.js'
|
|
4
|
+
import { isExpectedClientError } from './expected-client-error.js'
|
|
5
|
+
import { createFnLive, type FnLive } from './live/fn-live.js'
|
|
6
|
+
import {
|
|
7
|
+
type CreatePublisher,
|
|
8
|
+
type CreatePubSub,
|
|
9
|
+
createLiveRuntime,
|
|
10
|
+
type PubSubRuntimeOptions
|
|
11
|
+
} from './live/pub-sub.js'
|
|
12
|
+
import {
|
|
13
|
+
createDefaultLogger,
|
|
14
|
+
type FnLogger,
|
|
15
|
+
type LogAttributes
|
|
16
|
+
} from './logger.js'
|
|
17
|
+
import {
|
|
18
|
+
FN_META_KEY,
|
|
19
|
+
type FnMeta,
|
|
20
|
+
readFnMeta,
|
|
21
|
+
type StoredFnMeta
|
|
22
|
+
} from './meta.js'
|
|
23
|
+
import {
|
|
24
|
+
type AttributeValue,
|
|
25
|
+
createTracing,
|
|
26
|
+
errorMessageOf,
|
|
27
|
+
type OtelApiLike,
|
|
28
|
+
type SpanLike,
|
|
29
|
+
type SpanOf
|
|
30
|
+
} from './otel.js'
|
|
31
|
+
import { createRouter } from './router.js'
|
|
32
|
+
import {
|
|
33
|
+
type AnyFnContext,
|
|
34
|
+
type BuilderLike,
|
|
35
|
+
type BuildProcedure,
|
|
36
|
+
type FiniteExtras,
|
|
37
|
+
type Fn,
|
|
38
|
+
type FnCompletedEvent,
|
|
39
|
+
type FnDefinition,
|
|
40
|
+
type Guard,
|
|
41
|
+
type GuardParams,
|
|
42
|
+
type MaybePromise,
|
|
43
|
+
type RejectKeys,
|
|
44
|
+
RESERVED_OPTION_KEYS,
|
|
45
|
+
ROUTE_OPTION_KEYS,
|
|
46
|
+
type ReservedOptionKey
|
|
47
|
+
} from './types.js'
|
|
48
|
+
|
|
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
|
+
/** `CreateFnOptions` with the generics erased: what the implementation reads. */
|
|
59
|
+
type RuntimeOptions = {
|
|
60
|
+
procedures: Record<string, BuilderChain>
|
|
61
|
+
default?: string
|
|
62
|
+
extras?: (params: Record<string, unknown>) => unknown
|
|
63
|
+
guards?: Record<
|
|
64
|
+
string,
|
|
65
|
+
(value: unknown, params: GuardParams<unknown, unknown>) => unknown
|
|
66
|
+
>
|
|
67
|
+
otel?: OtelApiLike
|
|
68
|
+
logger?: (scope: string, span: SpanLike | undefined) => FnLogger
|
|
69
|
+
spanAttributes?: (params: {
|
|
70
|
+
context: unknown
|
|
71
|
+
name: string
|
|
72
|
+
procedure: string
|
|
73
|
+
meta: Record<string, unknown>
|
|
74
|
+
}) => Record<string, AttributeValue | undefined>
|
|
75
|
+
onCompleted?: (event: FnCompletedEvent) => LogAttributes | undefined
|
|
76
|
+
isExpectedError?: (error: unknown) => boolean
|
|
77
|
+
pubsub?: PubSubRuntimeOptions
|
|
78
|
+
}
|
|
79
|
+
|
|
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
|
+
export type CreateFnOptions<
|
|
93
|
+
TProcedures extends Record<string, BuilderLike>,
|
|
94
|
+
TDefault extends keyof TProcedures & string,
|
|
95
|
+
TExtras extends object,
|
|
96
|
+
TGuards,
|
|
97
|
+
TMeta extends object,
|
|
98
|
+
TTag extends string,
|
|
99
|
+
TOtel extends OtelApiLike | undefined,
|
|
100
|
+
TLogger extends FnLogger
|
|
101
|
+
> = {
|
|
102
|
+
/** Named oRPC builders, e.g. `{ public: os.$context<Ctx>(), protected: authed }`. */
|
|
103
|
+
procedures: TProcedures
|
|
104
|
+
/**
|
|
105
|
+
* Builder used when a route omits `procedure`. Leave it out to make every
|
|
106
|
+
* route name its procedure, so none gets one by accident.
|
|
107
|
+
*/
|
|
108
|
+
default?: TDefault
|
|
109
|
+
/** 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>
|
|
117
|
+
/**
|
|
118
|
+
* Checks run before the handler; each key becomes a typed `fn()` option.
|
|
119
|
+
* Keys must not reuse route options or meta keys.
|
|
120
|
+
*/
|
|
121
|
+
guards?: TGuards &
|
|
122
|
+
Record<
|
|
123
|
+
string,
|
|
124
|
+
Guard<never, GuardParams<UnionContext<TProcedures>, TMeta>>
|
|
125
|
+
> &
|
|
126
|
+
RejectKeys<
|
|
127
|
+
NoInfer<TGuards>,
|
|
128
|
+
ReservedOptionKey | keyof NoInfer<TMeta>,
|
|
129
|
+
'orpc-fn: this guard name is a route option or meta key'
|
|
130
|
+
>
|
|
131
|
+
/**
|
|
132
|
+
* Typed route metadata: `meta: {} as { readOnly?: boolean }`. Keys become
|
|
133
|
+
* `fn()` options and must not reuse route options.
|
|
134
|
+
*/
|
|
135
|
+
meta?: TMeta &
|
|
136
|
+
RejectKeys<
|
|
137
|
+
NoInfer<TMeta>,
|
|
138
|
+
ReservedOptionKey,
|
|
139
|
+
'orpc-fn: this meta key is a route option'
|
|
140
|
+
>
|
|
141
|
+
/** Allowed route tags: `tags: ['internal', 'external']`. */
|
|
142
|
+
tags?: readonly TTag[]
|
|
143
|
+
/** `import * as otel from '@opentelemetry/api'`. Omit for no tracing. */
|
|
144
|
+
otel?: TOtel
|
|
145
|
+
logger?: (scope: string, span: SpanOf<TOtel> | undefined) => TLogger
|
|
146
|
+
/** 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. */
|
|
154
|
+
onCompleted?: (
|
|
155
|
+
event: FnCompletedEvent<UnionContext<TProcedures>, SpanOf<TOtel>>
|
|
156
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: lets hooks that return nothing type-check
|
|
157
|
+
) => LogAttributes | undefined | void
|
|
158
|
+
/** Errors that are the caller's fault, logged at `warn`. Default: 4xx `ORPCError` or `AbortError`. */
|
|
159
|
+
isExpectedError?: (error: unknown) => boolean
|
|
160
|
+
/** Needed for `fnLive` and `createPubSub`. */
|
|
161
|
+
pubsub?: PubSubRuntimeOptions
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type Definition<
|
|
165
|
+
TProcedures extends Record<string, BuilderLike>,
|
|
166
|
+
TDefault extends string,
|
|
167
|
+
TExtras extends object,
|
|
168
|
+
TGuards,
|
|
169
|
+
TMeta extends object,
|
|
170
|
+
TTag extends string,
|
|
171
|
+
TOtel,
|
|
172
|
+
TLogger extends FnLogger
|
|
173
|
+
> = {
|
|
174
|
+
procedures: TProcedures
|
|
175
|
+
default: TDefault
|
|
176
|
+
extras: TExtras
|
|
177
|
+
guards: TGuards
|
|
178
|
+
meta: TMeta
|
|
179
|
+
tag: TTag
|
|
180
|
+
span: SpanOf<TOtel>
|
|
181
|
+
logger: TLogger
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type FnFactory<TDef extends FnDefinition> = {
|
|
185
|
+
fn: Fn<TDef>
|
|
186
|
+
fnLive: FnLive<TDef>
|
|
187
|
+
createPubSub: CreatePubSub<TDef>
|
|
188
|
+
createPublisher: CreatePublisher
|
|
189
|
+
createRouter: typeof createRouter
|
|
190
|
+
readMeta: (procedure: AnyProcedure) => FnMeta<TDef['meta']>
|
|
191
|
+
/**
|
|
192
|
+
* A `call` outside any handler (seeders, scripts, tests), traced with this
|
|
193
|
+
* instance's OpenTelemetry.
|
|
194
|
+
*/
|
|
195
|
+
createCall: <TContext>(
|
|
196
|
+
context: TContext,
|
|
197
|
+
signal?: AbortSignal
|
|
198
|
+
) => BoundCall<TContext>
|
|
199
|
+
/** End every open subscription of this instance; returns how many closed. */
|
|
200
|
+
drainPubSubSubscribers: () => number
|
|
201
|
+
activePubSubSubscriberCount: () => number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Builds the app's `fn` and friends once. Everything app-specific (auth,
|
|
206
|
+
* context, database, queues, i18n, permissions) is injected here.
|
|
207
|
+
*/
|
|
208
|
+
export function createFn<
|
|
209
|
+
const TProcedures extends Record<string, BuilderLike>,
|
|
210
|
+
TDefault extends keyof TProcedures & string = never,
|
|
211
|
+
TExtras extends object = Record<never, never>,
|
|
212
|
+
TGuards extends object = Record<never, never>,
|
|
213
|
+
TMeta extends object = Record<never, never>,
|
|
214
|
+
const TTag extends string = string,
|
|
215
|
+
TOtel extends OtelApiLike | undefined = undefined,
|
|
216
|
+
TLogger extends FnLogger = FnLogger
|
|
217
|
+
>(
|
|
218
|
+
options: CreateFnOptions<
|
|
219
|
+
TProcedures,
|
|
220
|
+
TDefault,
|
|
221
|
+
TExtras,
|
|
222
|
+
TGuards,
|
|
223
|
+
TMeta,
|
|
224
|
+
TTag,
|
|
225
|
+
TOtel,
|
|
226
|
+
TLogger
|
|
227
|
+
> &
|
|
228
|
+
FiniteExtras<NoInfer<TExtras>>
|
|
229
|
+
): FnFactory<
|
|
230
|
+
Definition<
|
|
231
|
+
TProcedures,
|
|
232
|
+
TDefault,
|
|
233
|
+
TExtras,
|
|
234
|
+
TGuards,
|
|
235
|
+
TMeta,
|
|
236
|
+
TTag,
|
|
237
|
+
TOtel,
|
|
238
|
+
TLogger
|
|
239
|
+
>
|
|
240
|
+
> {
|
|
241
|
+
const runtime = options as unknown as RuntimeOptions
|
|
242
|
+
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))
|
|
248
|
+
const isExpectedError = runtime.isExpectedError ?? isExpectedClientError
|
|
249
|
+
const guards = runtime.guards ?? {}
|
|
250
|
+
const isGuard = (option: string) => Object.hasOwn(guards, option)
|
|
251
|
+
for (const guard of Object.keys(guards)) {
|
|
252
|
+
if ((RESERVED_OPTION_KEYS as readonly string[]).includes(guard)) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`orpc-fn: guard "${guard}" reuses a route option name; rename it`
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const [key, builder] of Object.entries(runtime.procedures)) {
|
|
259
|
+
const def = (builder as { '~orpc'?: Record<string, unknown> })['~orpc']
|
|
260
|
+
if (def?.inputSchema !== undefined || def?.outputSchema !== undefined) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`orpc-fn: procedure "${key}" sets an input or output schema; set schemas on routes instead`
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const resolveKey = (procedure: string | undefined, name: string) => {
|
|
268
|
+
const key = procedure ?? runtime.default
|
|
269
|
+
if (key === undefined) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`orpc-fn: "${name}" needs a procedure; createFn has no default`
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
return key
|
|
275
|
+
}
|
|
276
|
+
|
|
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
|
+
}
|
|
298
|
+
|
|
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
|
|
302
|
+
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({
|
|
315
|
+
name,
|
|
316
|
+
procedure: key,
|
|
317
|
+
route,
|
|
318
|
+
meta,
|
|
319
|
+
input,
|
|
320
|
+
output,
|
|
321
|
+
handler: (params) => {
|
|
322
|
+
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(
|
|
328
|
+
name,
|
|
329
|
+
'SERVER',
|
|
330
|
+
async (span) => {
|
|
331
|
+
logger = createLogger('fn', span)
|
|
332
|
+
span?.setAttribute('fn.operation', name)
|
|
333
|
+
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
|
+
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
|
+
})
|
|
353
|
+
}
|
|
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
|
+
})
|
|
370
|
+
} 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()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
// After the result, or after a returned stream ends; before the span ends.
|
|
379
|
+
(span, ...error) => {
|
|
380
|
+
const durationMs = elapsedMs()
|
|
381
|
+
span?.setAttribute('fn.duration_ms', durationMs)
|
|
382
|
+
logCompleted(logger, {
|
|
383
|
+
name,
|
|
384
|
+
procedure: key,
|
|
385
|
+
durationMs,
|
|
386
|
+
success: error.length === 0,
|
|
387
|
+
error: error[0],
|
|
388
|
+
input: params.input,
|
|
389
|
+
context,
|
|
390
|
+
span,
|
|
391
|
+
meta
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const logCompleted = (logger: FnLogger, event: FnCompletedEvent) => {
|
|
400
|
+
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
|
+
let extra: LogAttributes | undefined
|
|
411
|
+
try {
|
|
412
|
+
extra = runtime.onCompleted?.(event)
|
|
413
|
+
} catch (hookError) {
|
|
414
|
+
logger.warn('onCompleted hook failed', {
|
|
415
|
+
error: errorMessageOf(hookError)
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
const level = success ? 'info' : isExpectedError(error) ? 'warn' : 'error'
|
|
419
|
+
logger[level]('fn.completed', {
|
|
420
|
+
event: 'fn.completed',
|
|
421
|
+
operation: event.name,
|
|
422
|
+
procedure: event.procedure,
|
|
423
|
+
duration_ms: event.durationMs,
|
|
424
|
+
status: success ? 'success' : 'failed',
|
|
425
|
+
...timingFields,
|
|
426
|
+
...(error === undefined
|
|
427
|
+
? {}
|
|
428
|
+
: {
|
|
429
|
+
error_type: error instanceof Error ? error.name : typeof error,
|
|
430
|
+
error_message: errorMessageOf(error)
|
|
431
|
+
}),
|
|
432
|
+
...extra
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const live = createLiveRuntime({
|
|
437
|
+
buildProcedure,
|
|
438
|
+
tracing,
|
|
439
|
+
createLogger,
|
|
440
|
+
pubsub: runtime.pubsub
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// The runtimes are untyped; `Fn`, `FnLive`, `CreatePubSub` and
|
|
444
|
+
// `CreatePublisher` are their typed surface.
|
|
445
|
+
return {
|
|
446
|
+
fn: fn as never,
|
|
447
|
+
fnLive: createFnLive({
|
|
448
|
+
fn,
|
|
449
|
+
createChannel: live.createChannel,
|
|
450
|
+
createLogger,
|
|
451
|
+
isGuard
|
|
452
|
+
}) as never,
|
|
453
|
+
createPubSub: live.createPubSub as never,
|
|
454
|
+
createPublisher: live.createPublisher as never,
|
|
455
|
+
createRouter,
|
|
456
|
+
readMeta: (procedure) => readFnMeta(procedure),
|
|
457
|
+
createCall: (context, signal) => createBoundCall(context, signal, tracing),
|
|
458
|
+
drainPubSubSubscribers: live.drainPubSubSubscribers,
|
|
459
|
+
activePubSubSubscriberCount: live.activePubSubSubscriberCount
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ORPCError } from '@orpc/server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An expected client failure: an `ORPCError` with a 4xx status (validation,
|
|
5
|
+
* auth, not-found) or an `AbortError` (the client closed the connection, e.g.
|
|
6
|
+
* a browser leaving a live stream). These are normal outcomes the caller
|
|
7
|
+
* drives, not server defects, so they must not be logged at `error` severity
|
|
8
|
+
* or reported to error tracking. Only 5xx and unknown failures are defects.
|
|
9
|
+
*
|
|
10
|
+
* This is the default `isExpectedError`; extend it with `createFn({ isExpectedError })`.
|
|
11
|
+
*/
|
|
12
|
+
export function isExpectedClientError(error: unknown): boolean {
|
|
13
|
+
if (error instanceof ORPCError && error.status < 500) return true
|
|
14
|
+
return isAbortError(error)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isAbortError(error: unknown): boolean {
|
|
18
|
+
return (
|
|
19
|
+
typeof error === 'object' &&
|
|
20
|
+
error !== null &&
|
|
21
|
+
(error as { name?: unknown }).name === 'AbortError'
|
|
22
|
+
)
|
|
23
|
+
}
|
package/src/expo.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ClientContext } from '@orpc/client'
|
|
2
|
+
import type { RPCLink } from '@orpc/client/fetch'
|
|
3
|
+
import { type CreateRpcLinkOptions, createRpcLink } from './client.js'
|
|
4
|
+
|
|
5
|
+
/** What `fetch` from `expo/fetch` accepts from this bridge. */
|
|
6
|
+
export type ExpoFetchInit = {
|
|
7
|
+
method: string
|
|
8
|
+
headers: Record<string, string>
|
|
9
|
+
body?: ArrayBuffer
|
|
10
|
+
signal: AbortSignal
|
|
11
|
+
credentials: 'omit'
|
|
12
|
+
redirect?: NonNullable<RequestInit['redirect']>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The response members oRPC reads; `expo/fetch`'s `FetchResponse` has them. */
|
|
16
|
+
export type ExpoResponse = Pick<
|
|
17
|
+
Response,
|
|
18
|
+
'status' | 'headers' | 'body' | 'text' | 'blob' | 'formData'
|
|
19
|
+
>
|
|
20
|
+
|
|
21
|
+
/** `fetch` from `expo/fetch` (the only fetch that streams on native). */
|
|
22
|
+
export type ExpoFetch = (
|
|
23
|
+
url: string,
|
|
24
|
+
init: ExpoFetchInit
|
|
25
|
+
) => Promise<ExpoResponse>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* oRPC's link `fetch` for React Native, built on `expo/fetch`.
|
|
29
|
+
*
|
|
30
|
+
* oRPC carries the input and the abort signal on the `Request`, not in
|
|
31
|
+
* `init`, and the call is rebuilt for `expo/fetch`, so both are forwarded
|
|
32
|
+
* explicitly: the body so the server receives the input, and the signal so a
|
|
33
|
+
* cancelled subscription closes its socket. Without the signal, connections
|
|
34
|
+
* leak and exhaust the device's HTTP pool after a few navigations, and every
|
|
35
|
+
* later request hangs.
|
|
36
|
+
*
|
|
37
|
+
* Native sends cookies by header (see `createExpoLink`), so it omits the
|
|
38
|
+
* credentialed cookie jar. On web it uses the platform `fetch` with cookies.
|
|
39
|
+
*/
|
|
40
|
+
export function createExpoFetch(options: {
|
|
41
|
+
fetch: ExpoFetch
|
|
42
|
+
/** `Platform.OS !== 'web'`. */
|
|
43
|
+
native: boolean
|
|
44
|
+
}) {
|
|
45
|
+
return async (
|
|
46
|
+
request: Request,
|
|
47
|
+
init?: { redirect?: NonNullable<RequestInit['redirect']> }
|
|
48
|
+
): Promise<Response> => {
|
|
49
|
+
if (!options.native) {
|
|
50
|
+
return globalThis.fetch(request, { ...init, credentials: 'include' })
|
|
51
|
+
}
|
|
52
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
|
|
53
|
+
const body = hasBody ? await request.arrayBuffer() : undefined
|
|
54
|
+
const response = await options.fetch(request.url, {
|
|
55
|
+
method: request.method,
|
|
56
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
57
|
+
...init,
|
|
58
|
+
...(body === undefined ? {} : { body }),
|
|
59
|
+
signal: request.signal,
|
|
60
|
+
credentials: 'omit'
|
|
61
|
+
})
|
|
62
|
+
// Not rebuilt with `new Response(...)`: React Native's Response polyfill
|
|
63
|
+
// cannot stream a body. oRPC reads only the `ExpoResponse` members.
|
|
64
|
+
return response as Response
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CreateExpoLinkOptions<T extends ClientContext> = Omit<
|
|
69
|
+
CreateRpcLinkOptions<T>,
|
|
70
|
+
'fetch' | 'headers'
|
|
71
|
+
> & {
|
|
72
|
+
fetch: ExpoFetch
|
|
73
|
+
native: boolean
|
|
74
|
+
/** Session cookie, e.g. Better Auth's `authClient.getCookie()`. */
|
|
75
|
+
getCookie?: () => string | null | undefined
|
|
76
|
+
/** `expo-origin` header; the server copies it to `origin` (`normalizeExpoOrigin`). */
|
|
77
|
+
getExpoOrigin?: () => string | null | undefined
|
|
78
|
+
/** Extra headers on every request. */
|
|
79
|
+
headers?: () => Record<string, string>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `createRpcLink` for Expo: batching, the `expo/fetch` bridge and the headers
|
|
84
|
+
* a Better Auth Expo client sends, so the API sees the same session and
|
|
85
|
+
* origin as `/api/auth` requests.
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { fetch } from 'expo/fetch'
|
|
89
|
+
* import { Platform } from 'react-native'
|
|
90
|
+
*
|
|
91
|
+
* const link = createExpoLink({
|
|
92
|
+
* url: `${baseUrl}/api/rpc`,
|
|
93
|
+
* fetch,
|
|
94
|
+
* native: Platform.OS !== 'web',
|
|
95
|
+
* getCookie: () => authClient.getCookie(),
|
|
96
|
+
* getExpoOrigin
|
|
97
|
+
* })
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function createExpoLink<T extends ClientContext = ClientContext>(
|
|
101
|
+
options: CreateExpoLinkOptions<T>
|
|
102
|
+
): RPCLink<T> {
|
|
103
|
+
const {
|
|
104
|
+
fetch,
|
|
105
|
+
native,
|
|
106
|
+
getCookie,
|
|
107
|
+
getExpoOrigin,
|
|
108
|
+
headers: extraHeaders,
|
|
109
|
+
...rest
|
|
110
|
+
} = options
|
|
111
|
+
return createRpcLink<T>({
|
|
112
|
+
...rest,
|
|
113
|
+
fetch: createExpoFetch({ fetch, native }),
|
|
114
|
+
headers: () => {
|
|
115
|
+
const headers: Record<string, string> = { ...extraHeaders?.() }
|
|
116
|
+
const cookie = getCookie?.()
|
|
117
|
+
if (cookie) headers.Cookie = cookie
|
|
118
|
+
if (native) {
|
|
119
|
+
// What @better-auth/expo sends from native, for session and CORS parity.
|
|
120
|
+
headers['x-skip-oauth-proxy'] = 'true'
|
|
121
|
+
const origin = getExpoOrigin?.()
|
|
122
|
+
if (origin) headers['expo-origin'] = origin
|
|
123
|
+
}
|
|
124
|
+
return headers
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|