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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/bound-call.d.ts +23 -0
  4. package/dist/bound-call.d.ts.map +1 -0
  5. package/dist/bound-call.js +23 -0
  6. package/dist/bound-call.js.map +1 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +49 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/create-fn.d.ts +97 -0
  12. package/dist/create-fn.d.ts.map +1 -0
  13. package/dist/create-fn.js +224 -0
  14. package/dist/create-fn.js.map +1 -0
  15. package/dist/expected-client-error.d.ts +11 -0
  16. package/dist/expected-client-error.d.ts.map +1 -0
  17. package/dist/expected-client-error.js +21 -0
  18. package/dist/expected-client-error.js.map +1 -0
  19. package/dist/expo.d.ts +66 -0
  20. package/dist/expo.d.ts.map +1 -0
  21. package/dist/expo.js +74 -0
  22. package/dist/expo.js.map +1 -0
  23. package/dist/hono.d.ts +81 -0
  24. package/dist/hono.d.ts.map +1 -0
  25. package/dist/hono.js +138 -0
  26. package/dist/hono.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/json-schema.d.ts +47 -0
  32. package/dist/json-schema.d.ts.map +1 -0
  33. package/dist/json-schema.js +44 -0
  34. package/dist/json-schema.js.map +1 -0
  35. package/dist/live/codec.d.ts +9 -0
  36. package/dist/live/codec.d.ts.map +1 -0
  37. package/dist/live/codec.js +27 -0
  38. package/dist/live/codec.js.map +1 -0
  39. package/dist/live/fn-live.d.ts +155 -0
  40. package/dist/live/fn-live.d.ts.map +1 -0
  41. package/dist/live/fn-live.js +210 -0
  42. package/dist/live/fn-live.js.map +1 -0
  43. package/dist/live/index.d.ts +4 -0
  44. package/dist/live/index.d.ts.map +1 -0
  45. package/dist/live/index.js +5 -0
  46. package/dist/live/index.js.map +1 -0
  47. package/dist/live/ioredis.d.ts +29 -0
  48. package/dist/live/ioredis.d.ts.map +1 -0
  49. package/dist/live/ioredis.js +49 -0
  50. package/dist/live/ioredis.js.map +1 -0
  51. package/dist/live/memory.d.ts +10 -0
  52. package/dist/live/memory.d.ts.map +1 -0
  53. package/dist/live/memory.js +45 -0
  54. package/dist/live/memory.js.map +1 -0
  55. package/dist/live/pub-sub.d.ts +211 -0
  56. package/dist/live/pub-sub.d.ts.map +1 -0
  57. package/dist/live/pub-sub.js +529 -0
  58. package/dist/live/pub-sub.js.map +1 -0
  59. package/dist/live/redis-bun.d.ts +26 -0
  60. package/dist/live/redis-bun.d.ts.map +1 -0
  61. package/dist/live/redis-bun.js +59 -0
  62. package/dist/live/redis-bun.js.map +1 -0
  63. package/dist/live/redis.d.ts +45 -0
  64. package/dist/live/redis.d.ts.map +1 -0
  65. package/dist/live/redis.js +214 -0
  66. package/dist/live/redis.js.map +1 -0
  67. package/dist/live/transport.d.ts +35 -0
  68. package/dist/live/transport.d.ts.map +1 -0
  69. package/dist/live/transport.js +2 -0
  70. package/dist/live/transport.js.map +1 -0
  71. package/dist/logger.d.ts +15 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +15 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/mastra.d.ts +44 -0
  76. package/dist/mastra.d.ts.map +1 -0
  77. package/dist/mastra.js +78 -0
  78. package/dist/mastra.js.map +1 -0
  79. package/dist/mcp.d.ts +39 -0
  80. package/dist/mcp.d.ts.map +1 -0
  81. package/dist/mcp.js +38 -0
  82. package/dist/mcp.js.map +1 -0
  83. package/dist/meta.d.ts +35 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +30 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/otel.d.ts +66 -0
  88. package/dist/otel.d.ts.map +1 -0
  89. package/dist/otel.js +63 -0
  90. package/dist/otel.js.map +1 -0
  91. package/dist/router.d.ts +13 -0
  92. package/dist/router.d.ts.map +1 -0
  93. package/dist/router.js +14 -0
  94. package/dist/router.js.map +1 -0
  95. package/dist/stream.d.ts +13 -0
  96. package/dist/stream.d.ts.map +1 -0
  97. package/dist/stream.js +44 -0
  98. package/dist/stream.js.map +1 -0
  99. package/dist/types.d.ts +196 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/types.js.map +1 -0
  103. package/package.json +157 -0
  104. package/src/bound-call.ts +50 -0
  105. package/src/client.ts +82 -0
  106. package/src/create-fn.ts +461 -0
  107. package/src/expected-client-error.ts +23 -0
  108. package/src/expo.ts +127 -0
  109. package/src/hono.ts +247 -0
  110. package/src/index.ts +45 -0
  111. package/src/json-schema.ts +82 -0
  112. package/src/live/codec.ts +31 -0
  113. package/src/live/fn-live.ts +470 -0
  114. package/src/live/index.ts +31 -0
  115. package/src/live/ioredis.ts +74 -0
  116. package/src/live/memory.ts +50 -0
  117. package/src/live/pub-sub.ts +828 -0
  118. package/src/live/redis-bun.ts +76 -0
  119. package/src/live/redis.ts +255 -0
  120. package/src/live/transport.ts +41 -0
  121. package/src/logger.ts +28 -0
  122. package/src/mastra.ts +140 -0
  123. package/src/mcp.ts +73 -0
  124. package/src/meta.ts +72 -0
  125. package/src/otel.ts +151 -0
  126. package/src/router.ts +15 -0
  127. package/src/stream.ts +54 -0
  128. package/src/types.ts +389 -0
@@ -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
+ }