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
package/src/hono.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { SmartCoercionPlugin } from '@orpc/json-schema'
2
+ import { OpenAPIHandler } from '@orpc/openapi/fetch'
3
+ import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
4
+ import {
5
+ type AnyRouter,
6
+ type InferRouterInitialContext,
7
+ onError as orpcOnError
8
+ } from '@orpc/server'
9
+ import { RPCHandler } from '@orpc/server/fetch'
10
+ import { BatchHandlerPlugin } from '@orpc/server/plugins'
11
+ import type { StandardHandlerPlugin } from '@orpc/server/standard'
12
+ import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
13
+ import type { Context as HonoContext, Env, Hono } from 'hono'
14
+ import type { StatusCode } from 'hono/utils/http-status'
15
+ import { isExpectedClientError } from './expected-client-error.js'
16
+ import type { ProcedureFilter } from './mcp.js'
17
+
18
+ /** Per-request timings in ms; `fn()` adds `handler_ms`. Sent as Server-Timing. */
19
+ export type RequestTiming = Record<string, number>
20
+
21
+ /** What every request starts with; the default context when `context` is omitted. */
22
+ export type BaseContext = { headers: Headers; timing: RequestTiming }
23
+
24
+ /**
25
+ * Builds the router's initial context from the request. Return a `Response`
26
+ * to answer (e.g. 401) instead.
27
+ */
28
+ export type ContextFactory<E extends Env, R extends AnyRouter> = (
29
+ c: HonoContext<E>,
30
+ base: BaseContext
31
+ ) =>
32
+ | InferRouterInitialContext<R>
33
+ | Response
34
+ | Promise<InferRouterInitialContext<R> | Response>
35
+
36
+ /** `context` is optional only when the base context already satisfies the router. */
37
+ type ContextOption<E extends Env, R extends AnyRouter> =
38
+ BaseContext extends InferRouterInitialContext<R>
39
+ ? { context?: ContextFactory<E, R> }
40
+ : { context: ContextFactory<E, R> }
41
+
42
+ type Path = `/${string}`
43
+ // biome-ignore lint/suspicious/noExplicitAny: plugins are context-agnostic here
44
+ type Plugin = StandardHandlerPlugin<any>
45
+
46
+ export type MountOrpcOptions<
47
+ E extends Env,
48
+ R extends AnyRouter
49
+ > = ContextOption<E, R> & {
50
+ router: R
51
+ /** Mount the RPC handler (oRPC's `RPCLink`) here. Omit to skip it. */
52
+ rpcPrefix?: Path
53
+ /** Mount the OpenAPI (REST) handler and its Scalar docs here. */
54
+ openapi?: {
55
+ prefix: Path
56
+ info?: { title: string; version: string; description?: string }
57
+ /** Docs page, relative to `prefix` (default '/'); the spec is at `/spec.json`. */
58
+ docsPath?: Path
59
+ /** Only expose matching procedures (also filters the spec). */
60
+ filter?: ProcedureFilter
61
+ /** Turn JSON strings back into `Date`s etc. for `z.date()` inputs. */
62
+ smartCoercion?: boolean
63
+ /** Extra spec fields, e.g. `components.securitySchemes` and `security`. */
64
+ spec?: Record<string, unknown>
65
+ plugins?: Plugin[]
66
+ }
67
+ /** Collapse parallel RPC calls into one HTTP request. Default: true (25 calls). */
68
+ batch?: boolean | { maxSize: number }
69
+ /** Add a Server-Timing header from the request timing. Default: true */
70
+ serverTiming?: boolean
71
+ /** SSE keep-alive comments every N ms (default 15000), or false. */
72
+ sseKeepAlive?: boolean | number
73
+ /** Extra RPC plugins (e.g. `CORSPlugin`). */
74
+ plugins?: Plugin[]
75
+ /** Rewrite request headers before they reach the context (see `normalizeExpoOrigin`). */
76
+ normalizeHeaders?: (headers: Headers) => Headers
77
+ /** Time spent before the handler ran, reported as `queue`. */
78
+ queueMs?: (request: Request) => number | undefined
79
+ /**
80
+ * Unexpected procedure errors (oRPC turns thrown errors into responses, so
81
+ * Hono's `onError` never sees them). Expected client errors are skipped.
82
+ */
83
+ onError?: (error: unknown, request: { url: string; method: string }) => void
84
+ /** Default: 4xx `ORPCError` or `AbortError`. */
85
+ isExpectedError?: (error: unknown) => boolean
86
+ }
87
+
88
+ /**
89
+ * Expo native sends `expo-origin` but not `origin`; copy it so cookie-based
90
+ * auth (e.g. Better Auth) sees the origin.
91
+ */
92
+ export function normalizeExpoOrigin(raw: Headers): Headers {
93
+ const expo = raw.get('expo-origin')
94
+ if (!expo || raw.get('origin')) return raw
95
+ const headers = new Headers(raw)
96
+ headers.set('origin', expo)
97
+ return headers
98
+ }
99
+
100
+ export function formatServerTiming(
101
+ totalMs: number,
102
+ timing: Record<string, number | undefined>
103
+ ): string {
104
+ const parts = [`total;dur=${totalMs.toFixed(2)}`]
105
+ for (const [key, value] of Object.entries(timing)) {
106
+ if (value !== undefined) {
107
+ parts.push(`${key.replace(/_ms$/, '')};dur=${value.toFixed(2)}`)
108
+ }
109
+ }
110
+ return parts.join(', ')
111
+ }
112
+
113
+ /**
114
+ * Response headers plus Server-Timing. For SSE, also disable proxy buffering
115
+ * so keep-alive comments reach the edge (empty `:` lines are otherwise easy
116
+ * for nginx/Fastly to hold until an idle kill). A copy: some responses (e.g.
117
+ * redirects) have immutable headers.
118
+ */
119
+ function finishHeaders(
120
+ response: Response,
121
+ serverTiming: string | undefined
122
+ ): Headers {
123
+ const headers = new Headers(response.headers)
124
+ if (serverTiming) {
125
+ headers.set('Server-Timing', serverTiming)
126
+ headers.append('Access-Control-Expose-Headers', 'Server-Timing')
127
+ }
128
+ if ((headers.get('content-type') ?? '').includes('text/event-stream')) {
129
+ headers.set('Cache-Control', 'no-cache')
130
+ headers.set('X-Accel-Buffering', 'no')
131
+ }
132
+ return headers
133
+ }
134
+
135
+ /** Mount oRPC's RPC and OpenAPI handlers on a Hono app. */
136
+ export function mountOrpc<E extends Env, R extends AnyRouter>(
137
+ app: Hono<E>,
138
+ options: MountOrpcOptions<E, R>
139
+ ): void {
140
+ const {
141
+ router,
142
+ batch = true,
143
+ serverTiming = true,
144
+ sseKeepAlive = true
145
+ } = options
146
+ const isExpectedError = options.isExpectedError ?? isExpectedClientError
147
+ const keepAlive =
148
+ sseKeepAlive === false
149
+ ? { eventIteratorKeepAliveEnabled: false }
150
+ : {
151
+ eventIteratorKeepAliveEnabled: true,
152
+ // Explicit keep-alive under proxy idle timers (~30s); oRPC's default is 5s.
153
+ eventIteratorKeepAliveInterval:
154
+ sseKeepAlive === true ? 15_000 : sseKeepAlive
155
+ }
156
+ const report = (
157
+ error: unknown,
158
+ { request }: { request: { url: URL; method: string } }
159
+ ) => {
160
+ if (isExpectedError(error)) return
161
+ options.onError?.(error, {
162
+ url: request.url.pathname,
163
+ method: request.method
164
+ })
165
+ }
166
+
167
+ const mount = (prefix: Path, handler: Pick<RPCHandler<object>, 'handle'>) => {
168
+ app.use(`${prefix}/*`, async (c, next) => {
169
+ const timing: RequestTiming = {}
170
+ const queueMs = options.queueMs?.(c.req.raw)
171
+ if (queueMs !== undefined)
172
+ timing.queue_ms = Math.round(queueMs * 100) / 100
173
+ const started = performance.now()
174
+ const headers = options.normalizeHeaders
175
+ ? options.normalizeHeaders(c.req.raw.headers)
176
+ : c.req.raw.headers
177
+ const context = options.context
178
+ ? await options.context(c, { headers, timing })
179
+ : { headers, timing }
180
+ if (context instanceof Response) return context
181
+ const { matched, response } = await handler.handle(c.req.raw, {
182
+ prefix,
183
+ context: context as object
184
+ })
185
+ if (!matched) return next()
186
+ const timed = serverTiming
187
+ ? formatServerTiming(performance.now() - started, timing)
188
+ : undefined
189
+ return c.newResponse(response.body, {
190
+ // oRPC only produces valid HTTP status codes.
191
+ status: response.status as StatusCode,
192
+ headers: finishHeaders(response, timed)
193
+ })
194
+ })
195
+ }
196
+
197
+ if (options.rpcPrefix) {
198
+ const plugins: Plugin[] = [...(options.plugins ?? [])]
199
+ if (batch) {
200
+ plugins.push(
201
+ new BatchHandlerPlugin({
202
+ maxSize: batch === true ? 25 : batch.maxSize
203
+ })
204
+ )
205
+ }
206
+ mount(
207
+ options.rpcPrefix,
208
+ new RPCHandler(router, {
209
+ ...keepAlive,
210
+ plugins,
211
+ interceptors: options.onError ? [orpcOnError(report)] : []
212
+ })
213
+ )
214
+ }
215
+
216
+ if (options.openapi) {
217
+ const { openapi } = options
218
+ const filter = openapi.filter ? { filter: openapi.filter } : {}
219
+ const converters = [new ZodToJsonSchemaConverter()]
220
+ const plugins: Plugin[] = [...(openapi.plugins ?? [])]
221
+ if (openapi.smartCoercion) {
222
+ plugins.push(new SmartCoercionPlugin({ schemaConverters: converters }))
223
+ }
224
+ plugins.push(
225
+ new OpenAPIReferencePlugin({
226
+ docsProvider: 'scalar',
227
+ ...(openapi.docsPath ? { docsPath: openapi.docsPath } : {}),
228
+ schemaConverters: converters,
229
+ specGenerateOptions: {
230
+ info: openapi.info ?? { title: 'API', version: '0.0.0' },
231
+ servers: [{ url: openapi.prefix }],
232
+ ...filter,
233
+ ...openapi.spec
234
+ }
235
+ })
236
+ )
237
+ mount(
238
+ openapi.prefix,
239
+ new OpenAPIHandler(router, {
240
+ ...keepAlive,
241
+ ...filter,
242
+ plugins,
243
+ interceptors: options.onError ? [orpcOnError(report)] : []
244
+ })
245
+ )
246
+ }
247
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ export { type BoundCall, createBoundCall } from './bound-call.js'
2
+ export {
3
+ type CreateFnOptions,
4
+ createFn,
5
+ type FnFactory
6
+ } from './create-fn.js'
7
+ export { isExpectedClientError } from './expected-client-error.js'
8
+ export { createBoundedEventQueue } from './live/pub-sub.js'
9
+ export {
10
+ createDefaultLogger,
11
+ type FnLogger,
12
+ type LogAttributes
13
+ } from './logger.js'
14
+ export { FN_META_KEY, type FnMeta, readFnMeta } from './meta.js'
15
+ export type {
16
+ AttributeValue,
17
+ OtelApiLike,
18
+ SpanLike,
19
+ SpanOf,
20
+ Tracing
21
+ } from './otel.js'
22
+ export { createRouter } from './router.js'
23
+ export type {
24
+ AnyFnContext,
25
+ BuilderLike,
26
+ CurrentContextOf,
27
+ Fn,
28
+ FnCompletedEvent,
29
+ FnContext,
30
+ FnDefinition,
31
+ FnProcedure,
32
+ FnRouteOptions,
33
+ Guard,
34
+ GuardOptions,
35
+ GuardParams,
36
+ HandlerParams,
37
+ InitialContextOf,
38
+ ProcedureContext,
39
+ ProcedureInput,
40
+ ProcedureKey,
41
+ ProcedureOption,
42
+ ProcedureOutput,
43
+ ReservedOptionKey,
44
+ RouteConfig
45
+ } from './types.js'
@@ -0,0 +1,82 @@
1
+ import type { AnySchema } from '@orpc/contract'
2
+ import { JsonSchemaCoercer } from '@orpc/json-schema'
3
+ import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
4
+
5
+ export type JsonSchema = Record<string, unknown>
6
+
7
+ /**
8
+ * A Standard Schema with JSON Schema attached, as MCP SDKs and Mastra expect.
9
+ * `TIn` is what is validated and what `validate` returns.
10
+ */
11
+ export type ToolSchema<TIn = unknown, TOut = TIn> = {
12
+ '~standard': {
13
+ version: 1
14
+ vendor: string
15
+ validate: (
16
+ value: unknown
17
+ ) =>
18
+ | { value: TOut; issues?: undefined }
19
+ | { issues: ReadonlyArray<{ message: string }> }
20
+ | Promise<
21
+ | { value: TOut; issues?: undefined }
22
+ | { issues: ReadonlyArray<{ message: string }> }
23
+ >
24
+ types?: { input: TIn; output: TOut }
25
+ jsonSchema: {
26
+ input: (options?: unknown) => JsonSchema
27
+ output: (options?: unknown) => JsonSchema
28
+ }
29
+ }
30
+ }
31
+
32
+ const converter = new ZodToJsonSchemaConverter()
33
+ const coercer = new JsonSchemaCoercer()
34
+
35
+ function toJsonSchema(
36
+ schema: AnySchema,
37
+ strategy: 'input' | 'output'
38
+ ): JsonSchema {
39
+ return converter.convert(schema, { strategy })[1] as JsonSchema
40
+ }
41
+
42
+ /**
43
+ * Tool input that validates like the procedure but returns the RAW input for
44
+ * the procedure's own input schema to parse. Transforms run in both passes
45
+ * but never on their own output, which handing over the parsed value would do. Zod's JSON Schema export rejects types like
46
+ * `z.date()`; oRPC's converter gives the same JSON Schema as the OpenAPI docs
47
+ * (dates become string/date-time), and clients send those dates as strings,
48
+ * so values are coerced back before validating.
49
+ */
50
+ export function rawInputSchema<TIn>(schema: AnySchema): ToolSchema<TIn> {
51
+ const jsonSchema = toJsonSchema(schema, 'input')
52
+ return {
53
+ '~standard': {
54
+ version: 1,
55
+ vendor: 'orpc-fn',
56
+ validate: (value) => {
57
+ const coerced = coercer.coerce(jsonSchema, value) as TIn
58
+ const finish = (
59
+ result: Awaited<ReturnType<AnySchema['~standard']['validate']>>
60
+ ) => (result.issues ? { issues: result.issues } : { value: coerced })
61
+ const result = schema['~standard'].validate(coerced)
62
+ return result instanceof Promise ? result.then(finish) : finish(result)
63
+ },
64
+ jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }
65
+ }
66
+ }
67
+ }
68
+
69
+ /** Tool output the procedure already validated: described, passed through. */
70
+ export function passThroughOutputSchema<TOut>(
71
+ schema: AnySchema
72
+ ): ToolSchema<TOut> {
73
+ const jsonSchema = toJsonSchema(schema, 'output')
74
+ return {
75
+ '~standard': {
76
+ version: 1,
77
+ vendor: 'orpc-fn',
78
+ validate: (value) => ({ value: value as TOut }),
79
+ jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,31 @@
1
+ import { StandardRPCJsonSerializer } from '@orpc/client/standard'
2
+
3
+ const serializer = new StandardRPCJsonSerializer()
4
+ const MARKER = 'orpc-fn'
5
+
6
+ /**
7
+ * Wire format for pub/sub payloads: oRPC's own JSON serializer, so values
8
+ * plain JSON loses (`Date`, `Map`, `Set`, `BigInt`, `URL`, `undefined`) arrive
9
+ * intact. Payloads without the envelope are read as plain JSON, so a
10
+ * publisher on the old format keeps working during a rolling deploy.
11
+ */
12
+ export function encodePayload(value: unknown): string {
13
+ const [json, meta, maps, blobs] = serializer.serialize(value)
14
+ if (maps.length > 0 || blobs.length > 0) {
15
+ throw new TypeError('orpc-fn: pub/sub events cannot contain files or blobs')
16
+ }
17
+ return JSON.stringify({ [MARKER]: 1, json, meta })
18
+ }
19
+
20
+ export function decodePayload(payload: string): unknown {
21
+ const parsed: unknown = JSON.parse(payload)
22
+ if (
23
+ typeof parsed === 'object' &&
24
+ parsed !== null &&
25
+ (parsed as Record<string, unknown>)[MARKER] === 1
26
+ ) {
27
+ const { json, meta } = parsed as { json: unknown; meta: [] }
28
+ return serializer.deserialize(json, meta)
29
+ }
30
+ return parsed
31
+ }