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/expo.ts CHANGED
@@ -41,13 +41,17 @@ export function createExpoFetch(options: {
41
41
  fetch: ExpoFetch
42
42
  /** `Platform.OS !== 'web'`. */
43
43
  native: boolean
44
+ webFetch?: (request: Request, init?: RequestInit) => Promise<Response>
44
45
  }) {
45
46
  return async (
46
47
  request: Request,
47
48
  init?: { redirect?: NonNullable<RequestInit['redirect']> }
48
49
  ): Promise<Response> => {
49
50
  if (!options.native) {
50
- return globalThis.fetch(request, { ...init, credentials: 'include' })
51
+ return (options.webFetch ?? globalThis.fetch)(request, {
52
+ ...init,
53
+ credentials: 'include'
54
+ })
51
55
  }
52
56
  const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
53
57
  const body = hasBody ? await request.arrayBuffer() : undefined
@@ -67,59 +71,60 @@ export function createExpoFetch(options: {
67
71
 
68
72
  export type CreateExpoLinkOptions<T extends ClientContext> = Omit<
69
73
  CreateRpcLinkOptions<T>,
70
- 'fetch' | 'headers'
74
+ 'fetch'
71
75
  > & {
72
76
  fetch: ExpoFetch
73
77
  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>
78
+ /** Explicit browser fetch override; native always uses expo/fetch. */
79
+ webFetch?: (request: Request, init?: RequestInit) => Promise<Response>
80
80
  }
81
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
- */
82
+ /** Generic streaming bridge with upstream async/context-aware headers. */
100
83
  export function createExpoLink<T extends ClientContext = ClientContext>(
101
84
  options: CreateExpoLinkOptions<T>
102
85
  ): RPCLink<T> {
103
- const {
104
- fetch,
105
- native,
106
- getCookie,
107
- getExpoOrigin,
108
- headers: extraHeaders,
109
- ...rest
110
- } = options
86
+ const { fetch, native, webFetch, ...rest } = options
111
87
  return createRpcLink<T>({
112
88
  ...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
89
+ fetch: createExpoFetch({ fetch, native, ...(webFetch ? { webFetch } : {}) })
90
+ })
91
+ }
92
+
93
+ export type CreateBetterAuthExpoLinkOptions<T extends ClientContext> =
94
+ CreateExpoLinkOptions<T> & {
95
+ getCookie?: () =>
96
+ | string
97
+ | null
98
+ | undefined
99
+ | Promise<string | null | undefined>
100
+ getExpoOrigin?: () =>
101
+ | string
102
+ | null
103
+ | undefined
104
+ | Promise<string | null | undefined>
105
+ }
106
+
107
+ /** Better Auth's native cookie/origin headers, kept behind an explicit preset. */
108
+ export function createBetterAuthExpoLink<
109
+ T extends ClientContext = ClientContext
110
+ >(options: CreateBetterAuthExpoLinkOptions<T>): RPCLink<T> {
111
+ const { getCookie, getExpoOrigin, headers: extraHeaders, ...rest } = options
112
+ return createExpoLink<T>({
113
+ ...rest,
114
+ headers: async (...args) => {
115
+ const supplied =
116
+ typeof extraHeaders === 'function'
117
+ ? await extraHeaders(...args)
118
+ : await extraHeaders
119
+ const headers = new Headers(
120
+ supplied as ConstructorParameters<typeof Headers>[0]
121
+ )
122
+ const cookie = await getCookie?.()
123
+ if (cookie) headers.set('Cookie', cookie)
124
+ if (options.native) {
125
+ headers.set('x-skip-oauth-proxy', 'true')
126
+ const origin = await getExpoOrigin?.()
127
+ if (origin) headers.set('expo-origin', origin)
123
128
  }
124
129
  return headers
125
130
  }
package/src/hono.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SmartCoercionPlugin } from '@orpc/json-schema'
2
+ import type { OpenAPIGeneratorGenerateOptions } from '@orpc/openapi'
2
3
  import { OpenAPIHandler } from '@orpc/openapi/fetch'
3
4
  import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
4
5
  import {
@@ -12,6 +13,7 @@ import type { StandardHandlerPlugin } from '@orpc/server/standard'
12
13
  import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
13
14
  import type { Context as HonoContext, Env, Hono } from 'hono'
14
15
  import type { StatusCode } from 'hono/utils/http-status'
16
+ import { REQUEST_TIMING, type InvocationTiming } from './timing.js'
15
17
  import { isExpectedClientError } from './expected-client-error.js'
16
18
  import type { ProcedureFilter } from './mcp.js'
17
19
 
@@ -40,8 +42,7 @@ type ContextOption<E extends Env, R extends AnyRouter> =
40
42
  : { context: ContextFactory<E, R> }
41
43
 
42
44
  type Path = `/${string}`
43
- // biome-ignore lint/suspicious/noExplicitAny: plugins are context-agnostic here
44
- type Plugin = StandardHandlerPlugin<any>
45
+ type Plugin<T extends object> = StandardHandlerPlugin<T>
45
46
 
46
47
  export type MountOrpcOptions<
47
48
  E extends Env,
@@ -61,8 +62,8 @@ export type MountOrpcOptions<
61
62
  /** Turn JSON strings back into `Date`s etc. for `z.date()` inputs. */
62
63
  smartCoercion?: boolean
63
64
  /** Extra spec fields, e.g. `components.securitySchemes` and `security`. */
64
- spec?: Record<string, unknown>
65
- plugins?: Plugin[]
65
+ spec?: Omit<OpenAPIGeneratorGenerateOptions, 'filter'>
66
+ plugins?: Plugin<InferRouterInitialContext<R>>[]
66
67
  }
67
68
  /** Collapse parallel RPC calls into one HTTP request. Default: true (25 calls). */
68
69
  batch?: boolean | { maxSize: number }
@@ -71,7 +72,7 @@ export type MountOrpcOptions<
71
72
  /** SSE keep-alive comments every N ms (default 15000), or false. */
72
73
  sseKeepAlive?: boolean | number
73
74
  /** Extra RPC plugins (e.g. `CORSPlugin`). */
74
- plugins?: Plugin[]
75
+ plugins?: Plugin<InferRouterInitialContext<R>>[]
75
76
  /** Rewrite request headers before they reach the context (see `normalizeExpoOrigin`). */
76
77
  normalizeHeaders?: (headers: Headers) => Headers
77
78
  /** Time spent before the handler ran, reported as `queue`. */
@@ -116,7 +117,7 @@ export function formatServerTiming(
116
117
  * for nginx/Fastly to hold until an idle kill). A copy: some responses (e.g.
117
118
  * redirects) have immutable headers.
118
119
  */
119
- function finishHeaders(
120
+ export function finishOrpcHeaders(
120
121
  response: Response,
121
122
  serverTiming: string | undefined
122
123
  ): Headers {
@@ -157,14 +158,21 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
157
158
  error: unknown,
158
159
  { request }: { request: { url: URL; method: string } }
159
160
  ) => {
160
- if (isExpectedError(error)) return
161
- options.onError?.(error, {
162
- url: request.url.pathname,
163
- method: request.method
164
- })
161
+ try {
162
+ if (isExpectedError(error)) return
163
+ options.onError?.(error, {
164
+ url: request.url.pathname,
165
+ method: request.method
166
+ })
167
+ } catch {
168
+ /* Error reporting cannot replace transport outcomes. */
169
+ }
165
170
  }
166
171
 
167
- const mount = (prefix: Path, handler: Pick<RPCHandler<object>, 'handle'>) => {
172
+ const mount = (
173
+ prefix: Path,
174
+ handler: Pick<RPCHandler<InferRouterInitialContext<R>>, 'handle'>
175
+ ) => {
168
176
  app.use(`${prefix}/*`, async (c, next) => {
169
177
  const timing: RequestTiming = {}
170
178
  const queueMs = options.queueMs?.(c.req.raw)
@@ -178,24 +186,33 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
178
186
  ? await options.context(c, { headers, timing })
179
187
  : { headers, timing }
180
188
  if (context instanceof Response) return context
189
+ const invocationTiming: InvocationTiming = { procedureMs: 0, calls: 0 }
181
190
  const { matched, response } = await handler.handle(c.req.raw, {
182
191
  prefix,
183
- context: context as object
192
+ context: {
193
+ ...context,
194
+ [REQUEST_TIMING]: invocationTiming
195
+ } as InferRouterInitialContext<R>
184
196
  })
185
197
  if (!matched) return next()
186
198
  const timed = serverTiming
187
- ? formatServerTiming(performance.now() - started, timing)
199
+ ? formatServerTiming(performance.now() - started, {
200
+ ...timing,
201
+ handler_ms: invocationTiming.procedureMs
202
+ })
188
203
  : undefined
189
204
  return c.newResponse(response.body, {
190
205
  // oRPC only produces valid HTTP status codes.
191
206
  status: response.status as StatusCode,
192
- headers: finishHeaders(response, timed)
207
+ headers: finishOrpcHeaders(response, timed)
193
208
  })
194
209
  })
195
210
  }
196
211
 
197
212
  if (options.rpcPrefix) {
198
- const plugins: Plugin[] = [...(options.plugins ?? [])]
213
+ const plugins: Plugin<InferRouterInitialContext<R>>[] = [
214
+ ...(options.plugins ?? [])
215
+ ]
199
216
  if (batch) {
200
217
  plugins.push(
201
218
  new BatchHandlerPlugin({
@@ -217,7 +234,9 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
217
234
  const { openapi } = options
218
235
  const filter = openapi.filter ? { filter: openapi.filter } : {}
219
236
  const converters = [new ZodToJsonSchemaConverter()]
220
- const plugins: Plugin[] = [...(openapi.plugins ?? [])]
237
+ const plugins: Plugin<InferRouterInitialContext<R>>[] = [
238
+ ...(openapi.plugins ?? [])
239
+ ]
221
240
  if (openapi.smartCoercion) {
222
241
  plugins.push(new SmartCoercionPlugin({ schemaConverters: converters }))
223
242
  }
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  type FnLogger,
12
12
  type LogAttributes
13
13
  } from './logger.js'
14
- export { FN_META_KEY, type FnMeta, readFnMeta } from './meta.js'
14
+ export { defineMeta, FN_META_KEY, type FnMeta, readFnMeta } from './meta.js'
15
15
  export type {
16
16
  AttributeValue,
17
17
  OtelApiLike,
@@ -19,7 +19,11 @@ export type {
19
19
  SpanOf,
20
20
  Tracing
21
21
  } from './otel.js'
22
- export { createRouter } from './router.js'
22
+ export {
23
+ createRouter,
24
+ createStreamManifest,
25
+ createStreamManifestAsync
26
+ } from './router.js'
23
27
  export type {
24
28
  AnyFnContext,
25
29
  BuilderLike,
@@ -80,3 +80,19 @@ export function passThroughOutputSchema<TOut>(
80
80
  }
81
81
  }
82
82
  }
83
+
84
+ /** Tool protocols permit unions only when every branch accepts an object. */
85
+ export function isObjectJsonSchema(schema: JsonSchema): boolean {
86
+ if (schema.type === 'object') return true
87
+ const union = schema.anyOf ?? schema.oneOf
88
+ return (
89
+ Array.isArray(union) &&
90
+ union.length > 0 &&
91
+ union.every(
92
+ (branch) =>
93
+ typeof branch === 'object' &&
94
+ branch !== null &&
95
+ isObjectJsonSchema(branch)
96
+ )
97
+ )
98
+ }
package/src/live/codec.ts CHANGED
@@ -9,12 +9,15 @@ const MARKER = 'orpc-fn'
9
9
  * intact. Payloads without the envelope are read as plain JSON, so a
10
10
  * publisher on the old format keeps working during a rolling deploy.
11
11
  */
12
- export function encodePayload(value: unknown): string {
12
+ export function encodePayload(
13
+ value: unknown,
14
+ id: string = crypto.randomUUID()
15
+ ): string {
13
16
  const [json, meta, maps, blobs] = serializer.serialize(value)
14
17
  if (maps.length > 0 || blobs.length > 0) {
15
18
  throw new TypeError('orpc-fn: pub/sub events cannot contain files or blobs')
16
19
  }
17
- return JSON.stringify({ [MARKER]: 1, json, meta })
20
+ return JSON.stringify({ [MARKER]: 2, id, json, meta })
18
21
  }
19
22
 
20
23
  export function decodePayload(payload: string): unknown {
@@ -22,10 +25,26 @@ export function decodePayload(payload: string): unknown {
22
25
  if (
23
26
  typeof parsed === 'object' &&
24
27
  parsed !== null &&
25
- (parsed as Record<string, unknown>)[MARKER] === 1
28
+ [1, 2].includes(Number((parsed as Record<string, unknown>)[MARKER]))
26
29
  ) {
27
30
  const { json, meta } = parsed as { json: unknown; meta: [] }
28
31
  return serializer.deserialize(json, meta)
29
32
  }
30
33
  return parsed
31
34
  }
35
+
36
+ /** IDs distinguish replay/live overlap; legacy payloads remain readable. */
37
+ export function decodeMessage(payload: string): {
38
+ id: string | undefined
39
+ value: unknown
40
+ } {
41
+ const envelope = JSON.parse(payload) as unknown
42
+ const id =
43
+ typeof envelope === 'object' &&
44
+ envelope !== null &&
45
+ (envelope as Record<string, unknown>)[MARKER] === 2 &&
46
+ typeof (envelope as Record<string, unknown>).id === 'string'
47
+ ? (envelope as { id: string }).id
48
+ : undefined
49
+ return { id, value: decodePayload(payload) }
50
+ }