orpc-fn 0.8.0 → 0.9.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 (144) hide show
  1. package/README.md +66 -28
  2. package/dist/assemble-procedure.d.ts.map +1 -1
  3. package/dist/assemble-procedure.js +3 -1
  4. package/dist/assemble-procedure.js.map +1 -1
  5. package/dist/client.d.ts +13 -3
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +21 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/create-fn.d.ts +63 -5
  10. package/dist/create-fn.d.ts.map +1 -1
  11. package/dist/create-fn.js +45 -16
  12. package/dist/create-fn.js.map +1 -1
  13. package/dist/expected-client-error.d.ts +6 -0
  14. package/dist/expected-client-error.d.ts.map +1 -1
  15. package/dist/expected-client-error.js +16 -0
  16. package/dist/expected-client-error.js.map +1 -1
  17. package/dist/hono.d.ts +44 -22
  18. package/dist/hono.d.ts.map +1 -1
  19. package/dist/hono.js +51 -20
  20. package/dist/hono.js.map +1 -1
  21. package/dist/index.d.ts +1 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +0 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/issues.d.ts +12 -0
  26. package/dist/issues.d.ts.map +1 -0
  27. package/dist/issues.js +22 -0
  28. package/dist/issues.js.map +1 -0
  29. package/dist/json-schema.d.ts +3 -6
  30. package/dist/json-schema.d.ts.map +1 -1
  31. package/dist/json-schema.js +12 -7
  32. package/dist/json-schema.js.map +1 -1
  33. package/dist/live/codec.d.ts +2 -0
  34. package/dist/live/codec.d.ts.map +1 -1
  35. package/dist/live/codec.js +21 -16
  36. package/dist/live/codec.js.map +1 -1
  37. package/dist/live/fn-live.d.ts +6 -19
  38. package/dist/live/fn-live.d.ts.map +1 -1
  39. package/dist/live/fn-live.js +15 -23
  40. package/dist/live/fn-live.js.map +1 -1
  41. package/dist/live/index.d.ts +2 -2
  42. package/dist/live/index.d.ts.map +1 -1
  43. package/dist/live/index.js +1 -1
  44. package/dist/live/index.js.map +1 -1
  45. package/dist/live/memory.d.ts +11 -1
  46. package/dist/live/memory.d.ts.map +1 -1
  47. package/dist/live/memory.js +14 -1
  48. package/dist/live/memory.js.map +1 -1
  49. package/dist/live/pub-sub.d.ts +50 -8
  50. package/dist/live/pub-sub.d.ts.map +1 -1
  51. package/dist/live/pub-sub.js +79 -26
  52. package/dist/live/pub-sub.js.map +1 -1
  53. package/dist/live/redis-bun.d.ts +6 -1
  54. package/dist/live/redis-bun.d.ts.map +1 -1
  55. package/dist/live/redis-bun.js +10 -1
  56. package/dist/live/redis-bun.js.map +1 -1
  57. package/dist/live/scoped-change-bus.d.ts +3 -1
  58. package/dist/live/scoped-change-bus.d.ts.map +1 -1
  59. package/dist/live/scoped-change-bus.js +3 -0
  60. package/dist/live/scoped-change-bus.js.map +1 -1
  61. package/dist/mastra.d.ts +8 -3
  62. package/dist/mastra.d.ts.map +1 -1
  63. package/dist/mastra.js +8 -1
  64. package/dist/mastra.js.map +1 -1
  65. package/dist/mcp/core.d.ts +31 -14
  66. package/dist/mcp/core.d.ts.map +1 -1
  67. package/dist/mcp/core.js +39 -20
  68. package/dist/mcp/core.js.map +1 -1
  69. package/dist/mcp/sdk.d.ts +6 -2
  70. package/dist/mcp/sdk.d.ts.map +1 -1
  71. package/dist/mcp/sdk.js +9 -3
  72. package/dist/mcp/sdk.js.map +1 -1
  73. package/dist/mcp/server.d.ts +6 -2
  74. package/dist/mcp/server.d.ts.map +1 -1
  75. package/dist/mcp/server.js +9 -3
  76. package/dist/mcp/server.js.map +1 -1
  77. package/dist/mcp.d.ts +3 -7
  78. package/dist/mcp.d.ts.map +1 -1
  79. package/dist/mcp.js +4 -6
  80. package/dist/mcp.js.map +1 -1
  81. package/dist/meta.d.ts +14 -2
  82. package/dist/meta.d.ts.map +1 -1
  83. package/dist/meta.js +6 -2
  84. package/dist/meta.js.map +1 -1
  85. package/dist/otel.d.ts +16 -4
  86. package/dist/otel.d.ts.map +1 -1
  87. package/dist/otel.js +40 -10
  88. package/dist/otel.js.map +1 -1
  89. package/dist/react.d.ts +6 -1
  90. package/dist/react.d.ts.map +1 -1
  91. package/dist/react.js +5 -1
  92. package/dist/react.js.map +1 -1
  93. package/dist/route-options.d.ts.map +1 -1
  94. package/dist/route-options.js +4 -0
  95. package/dist/route-options.js.map +1 -1
  96. package/dist/stream-call.d.ts +15 -0
  97. package/dist/stream-call.d.ts.map +1 -0
  98. package/dist/stream-call.js +13 -0
  99. package/dist/stream-call.js.map +1 -0
  100. package/dist/timing.d.ts +2 -0
  101. package/dist/timing.d.ts.map +1 -1
  102. package/dist/timing.js +2 -0
  103. package/dist/timing.js.map +1 -1
  104. package/dist/tool-errors.d.ts +24 -0
  105. package/dist/tool-errors.d.ts.map +1 -0
  106. package/dist/tool-errors.js +36 -0
  107. package/dist/tool-errors.js.map +1 -0
  108. package/dist/types.d.ts +2 -0
  109. package/dist/types.d.ts.map +1 -1
  110. package/dist/types.js.map +1 -1
  111. package/dist/vue.d.ts +47 -4
  112. package/dist/vue.d.ts.map +1 -1
  113. package/dist/vue.js +39 -9
  114. package/dist/vue.js.map +1 -1
  115. package/package.json +1 -1
  116. package/src/assemble-procedure.ts +3 -1
  117. package/src/client.ts +26 -2
  118. package/src/create-fn.ts +149 -19
  119. package/src/expected-client-error.ts +19 -0
  120. package/src/hono.ts +120 -41
  121. package/src/index.ts +3 -2
  122. package/src/issues.ts +35 -0
  123. package/src/json-schema.ts +16 -10
  124. package/src/live/codec.ts +21 -19
  125. package/src/live/fn-live.ts +18 -36
  126. package/src/live/index.ts +2 -2
  127. package/src/live/memory.ts +22 -1
  128. package/src/live/pub-sub.ts +142 -37
  129. package/src/live/redis-bun.ts +23 -2
  130. package/src/live/scoped-change-bus.ts +10 -1
  131. package/src/mastra.ts +10 -4
  132. package/src/mcp/core.ts +76 -29
  133. package/src/mcp/sdk.ts +27 -2
  134. package/src/mcp/server.ts +27 -2
  135. package/src/mcp.ts +5 -16
  136. package/src/meta.ts +28 -4
  137. package/src/otel.ts +41 -9
  138. package/src/react.ts +10 -3
  139. package/src/route-options.ts +8 -0
  140. package/src/stream-call.ts +15 -0
  141. package/src/timing.ts +3 -0
  142. package/src/tool-errors.ts +52 -0
  143. package/src/types.ts +2 -0
  144. package/src/vue.ts +92 -19
package/src/hono.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { Readable, pipeline } from 'node:stream'
2
2
  import zlib from 'node:zlib'
3
- import { SmartCoercionPlugin } from '@orpc/json-schema'
4
3
  import type { OpenAPIGeneratorGenerateOptions } from '@orpc/openapi'
5
- import { OpenAPIHandler } from '@orpc/openapi/fetch'
6
- import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
7
4
  import {
8
5
  type AnyRouter,
9
6
  type InferRouterInitialContext,
@@ -12,16 +9,35 @@ import {
12
9
  import { RPCHandler } from '@orpc/server/fetch'
13
10
  import { BatchHandlerPlugin } from '@orpc/server/plugins'
14
11
  import type { StandardHandlerPlugin } from '@orpc/server/standard'
15
- import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
16
12
  import type { Context as HonoContext, Env, Hono } from 'hono'
17
13
  import type { StatusCode } from 'hono/utils/http-status'
18
14
  import { REQUEST_TIMING, type InvocationTiming } from './timing.js'
19
15
  import { isExpectedClientError } from './expected-client-error.js'
20
- import type { ProcedureFilter } from './mcp.js'
16
+ import { hasTag, type ProcedureFilter } from './meta.js'
17
+
18
+ export { hasTag, type ProcedureFilter }
21
19
 
22
20
  /** Per-request timings in ms; `fn()` adds `handler_ms`. Sent as Server-Timing. */
23
21
  export type RequestTiming = Record<string, number>
24
22
 
23
+ /**
24
+ * Run `work` and add its duration to `timing[key]` (rounded to 0.01 ms),
25
+ * whether it resolves or throws: `await timed(context.timing, 'auth_ms', () => getSession(headers))`.
26
+ */
27
+ export async function timed<T>(
28
+ timing: RequestTiming,
29
+ key: string,
30
+ work: () => T | Promise<T>
31
+ ): Promise<T> {
32
+ const started = performance.now()
33
+ try {
34
+ return await work()
35
+ } finally {
36
+ timing[key] =
37
+ (timing[key] ?? 0) + Math.round((performance.now() - started) * 100) / 100
38
+ }
39
+ }
40
+
25
41
  /** What every request starts with; the default context when `context` is omitted. */
26
42
  export type BaseContext = { headers: Headers; timing: RequestTiming }
27
43
 
@@ -44,8 +60,34 @@ type ContextOption<E extends Env, R extends AnyRouter> =
44
60
  : { context: ContextFactory<E, R> }
45
61
 
46
62
  type Path = `/${string}`
63
+ type Handler<C> = {
64
+ handle(
65
+ request: Request,
66
+ options: { prefix: Path; context: C }
67
+ ): Promise<{ matched: boolean; response: Response | undefined }>
68
+ }
47
69
  type Plugin<T extends object> = StandardHandlerPlugin<T>
48
70
 
71
+ export type OpenApiMountOptions<R extends AnyRouter> = {
72
+ prefix: Path
73
+ info?: { title: string; version: string; description?: string }
74
+ /** Docs page, relative to `prefix` (default '/'); the spec is at `/spec.json`. */
75
+ docsPath?: Path
76
+ /** false: serve the REST routes without the docs page and `spec.json`. */
77
+ docs?: boolean
78
+ /**
79
+ * The procedures to publish (also filters the spec), e.g.
80
+ * `hasTag('external')`. Required, so publishing is a choice;
81
+ * `() => true` publishes every route.
82
+ */
83
+ filter: ProcedureFilter
84
+ /** Turn JSON strings back into `Date`s etc. for `z.date()` inputs. */
85
+ smartCoercion?: boolean
86
+ /** Extra spec fields, e.g. `components.securitySchemes` and `security`. */
87
+ spec?: Omit<OpenAPIGeneratorGenerateOptions, 'filter'>
88
+ plugins?: Plugin<InferRouterInitialContext<R>>[]
89
+ }
90
+
49
91
  export type MountOrpcOptions<
50
92
  E extends Env,
51
93
  R extends AnyRouter
@@ -53,20 +95,12 @@ export type MountOrpcOptions<
53
95
  router: R
54
96
  /** Mount the RPC handler (oRPC's `RPCLink`) here. Omit to skip it. */
55
97
  rpcPrefix?: Path
56
- /** Mount the OpenAPI (REST) handler and its Scalar docs here. */
57
- openapi?: {
58
- prefix: Path
59
- info?: { title: string; version: string; description?: string }
60
- /** Docs page, relative to `prefix` (default '/'); the spec is at `/spec.json`. */
61
- docsPath?: Path
62
- /** Only expose matching procedures (also filters the spec). */
63
- filter?: ProcedureFilter
64
- /** Turn JSON strings back into `Date`s etc. for `z.date()` inputs. */
65
- smartCoercion?: boolean
66
- /** Extra spec fields, e.g. `components.securitySchemes` and `security`. */
67
- spec?: Omit<OpenAPIGeneratorGenerateOptions, 'filter'>
68
- plugins?: Plugin<InferRouterInitialContext<R>>[]
69
- }
98
+ /**
99
+ * Mount the OpenAPI (REST) handler and its Scalar docs here. Needs the
100
+ * optional peers `@orpc/openapi`, `@orpc/json-schema` and `@orpc/zod`,
101
+ * loaded on the first request to `prefix`.
102
+ */
103
+ openapi?: OpenApiMountOptions<R>
70
104
  /** Collapse parallel RPC calls into one HTTP request. Default: true (25 calls). */
71
105
  batch?: boolean | { maxSize: number }
72
106
  /** Add a Server-Timing header from the request timing. Default: true */
@@ -79,7 +113,10 @@ export type MountOrpcOptions<
79
113
  compress?: boolean
80
114
  /** SSE keep-alive comments every N ms (default 15000), or false. */
81
115
  sseKeepAlive?: boolean | number
82
- /** Extra RPC plugins (e.g. `CORSPlugin`). */
116
+ /**
117
+ * Extra RPC plugins. CORS belongs to Hono's `cors()` on the app, once; a
118
+ * cross-origin client with batching needs `x-orpc-batch` in its `allowHeaders`.
119
+ */
83
120
  plugins?: Plugin<InferRouterInitialContext<R>>[]
84
121
  /** Rewrite request headers before they reach the context (see `normalizeExpoOrigin`). */
85
122
  normalizeHeaders?: (headers: Headers) => Headers
@@ -93,7 +130,10 @@ export type MountOrpcOptions<
93
130
  * Hono's `onError` never sees them). Expected client errors are skipped.
94
131
  */
95
132
  onError?: (error: unknown, request: { url: string; method: string }) => void
96
- /** Default: 4xx `ORPCError` or `AbortError`. */
133
+ /**
134
+ * Default: `isExpectedClientError`, which also accepts any error the
135
+ * route's `createFn({ isExpectedError })` classified as expected.
136
+ */
97
137
  isExpectedError?: (error: unknown) => boolean
98
138
  }
99
139
 
@@ -233,7 +273,7 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
233
273
 
234
274
  const mount = (
235
275
  prefix: Path,
236
- handler: Pick<RPCHandler<InferRouterInitialContext<R>>, 'handle'>
276
+ handler: Handler<InferRouterInitialContext<R>>
237
277
  ) => {
238
278
  app.use(`${prefix}/*`, async (c, next) => {
239
279
  const timing: RequestTiming = {}
@@ -256,7 +296,7 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
256
296
  [REQUEST_TIMING]: invocationTiming
257
297
  } as InferRouterInitialContext<R>
258
298
  })
259
- if (!matched) return next()
299
+ if (!matched || !response) return next()
260
300
  const timed = serverTiming
261
301
  ? formatServerTiming(performance.now() - started, {
262
302
  ...timing,
@@ -302,14 +342,56 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
302
342
 
303
343
  if (options.openapi) {
304
344
  const { openapi } = options
305
- const filter = openapi.filter ? { filter: openapi.filter } : {}
306
- const converters = [new ZodToJsonSchemaConverter()]
307
- const plugins: Plugin<InferRouterInitialContext<R>>[] = [
308
- ...(openapi.plugins ?? [])
309
- ]
310
- if (openapi.smartCoercion) {
311
- plugins.push(new SmartCoercionPlugin({ schemaConverters: converters }))
312
- }
345
+ if (typeof openapi.filter !== 'function')
346
+ throw new TypeError(
347
+ 'orpc-fn: mountOrpc openapi needs a filter; pass () => true to publish every route'
348
+ )
349
+ let loading: Promise<Handler<InferRouterInitialContext<R>>> | undefined
350
+ mount(openapi.prefix, {
351
+ handle: async (request, handleOptions) => {
352
+ loading ??= createOpenApiHandler(
353
+ router,
354
+ openapi,
355
+ keepAlive,
356
+ options.onError ? report : undefined
357
+ )
358
+ return (await loading).handle(request, handleOptions)
359
+ }
360
+ })
361
+ }
362
+ }
363
+
364
+ /** Loads the OpenAPI peers only for apps that mount it. */
365
+ async function createOpenApiHandler<R extends AnyRouter>(
366
+ router: R,
367
+ openapi: OpenApiMountOptions<R>,
368
+ keepAlive: object,
369
+ report:
370
+ | ((
371
+ error: unknown,
372
+ options: { request: { url: URL; method: string } }
373
+ ) => void)
374
+ | undefined
375
+ ): Promise<Handler<InferRouterInitialContext<R>>> {
376
+ const [
377
+ { SmartCoercionPlugin },
378
+ { OpenAPIHandler },
379
+ { OpenAPIReferencePlugin },
380
+ { ZodToJsonSchemaConverter }
381
+ ] = await Promise.all([
382
+ import('@orpc/json-schema'),
383
+ import('@orpc/openapi/fetch'),
384
+ import('@orpc/openapi/plugins'),
385
+ import('@orpc/zod/zod4')
386
+ ])
387
+ const converters = [new ZodToJsonSchemaConverter()]
388
+ const plugins: Plugin<InferRouterInitialContext<R>>[] = [
389
+ ...(openapi.plugins ?? [])
390
+ ]
391
+ if (openapi.smartCoercion) {
392
+ plugins.push(new SmartCoercionPlugin({ schemaConverters: converters }))
393
+ }
394
+ if (openapi.docs !== false) {
313
395
  plugins.push(
314
396
  new OpenAPIReferencePlugin({
315
397
  docsProvider: 'scalar',
@@ -318,19 +400,16 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
318
400
  specGenerateOptions: {
319
401
  info: openapi.info ?? { title: 'API', version: '0.0.0' },
320
402
  servers: [{ url: openapi.prefix }],
321
- ...filter,
403
+ filter: openapi.filter,
322
404
  ...openapi.spec
323
405
  }
324
406
  })
325
407
  )
326
- mount(
327
- openapi.prefix,
328
- new OpenAPIHandler(router, {
329
- ...keepAlive,
330
- ...filter,
331
- plugins,
332
- interceptors: options.onError ? [orpcOnError(report)] : []
333
- })
334
- )
335
408
  }
409
+ return new OpenAPIHandler(router, {
410
+ ...keepAlive,
411
+ filter: openapi.filter,
412
+ plugins,
413
+ interceptors: report ? [orpcOnError(report)] : []
414
+ })
336
415
  }
package/src/index.ts CHANGED
@@ -2,10 +2,11 @@ export { type BoundCall, createBoundCall } from './bound-call.js'
2
2
  export {
3
3
  type CreateFnOptions,
4
4
  createFn,
5
- type FnFactory
5
+ type FnFactory,
6
+ type RoutePolicyParams,
7
+ type TestCallExtras
6
8
  } from './create-fn.js'
7
9
  export { isExpectedClientError } from './expected-client-error.js'
8
- export { createBoundedEventQueue } from './live/pub-sub.js'
9
10
  export {
10
11
  createDefaultLogger,
11
12
  type FnLogger,
package/src/issues.ts ADDED
@@ -0,0 +1,35 @@
1
+ /** A Standard Schema issue, as oRPC puts them on `error.data.issues`. */
2
+ export type SchemaIssue = {
3
+ message: string
4
+ path?: ReadonlyArray<PropertyKey | { key: PropertyKey }> | undefined
5
+ }
6
+
7
+ /** `items.0.name: Required`, or the bare message for a root issue. */
8
+ export function formatIssue(issue: SchemaIssue): string {
9
+ const path = (issue.path ?? [])
10
+ .map((segment) =>
11
+ String(typeof segment === 'object' ? segment.key : segment)
12
+ )
13
+ .join('.')
14
+ return path ? `${path}: ${issue.message}` : issue.message
15
+ }
16
+
17
+ /** The validation issues oRPC attaches to a BAD_REQUEST (`data.issues`). */
18
+ export function issuesOf(error: unknown): SchemaIssue[] {
19
+ const data =
20
+ typeof error === 'object' && error !== null
21
+ ? (error as { data?: unknown }).data
22
+ : undefined
23
+ const issues =
24
+ typeof data === 'object' && data !== null
25
+ ? (data as { issues?: unknown }).issues
26
+ : undefined
27
+ return Array.isArray(issues)
28
+ ? issues.filter(
29
+ (issue): issue is SchemaIssue =>
30
+ typeof issue === 'object' &&
31
+ issue !== null &&
32
+ typeof (issue as { message?: unknown }).message === 'string'
33
+ )
34
+ : []
35
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AnySchema } from '@orpc/contract'
2
2
  import { JsonSchemaCoercer } from '@orpc/json-schema'
3
3
  import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
4
+ import type { SchemaIssue } from './issues.js'
4
5
 
5
6
  export type JsonSchema = Record<string, unknown>
6
7
 
@@ -16,10 +17,10 @@ export type ToolSchema<TIn = unknown, TOut = TIn> = {
16
17
  value: unknown
17
18
  ) =>
18
19
  | { value: TOut; issues?: undefined }
19
- | { issues: ReadonlyArray<{ message: string }> }
20
+ | { issues: ReadonlyArray<SchemaIssue> }
20
21
  | Promise<
21
22
  | { value: TOut; issues?: undefined }
22
- | { issues: ReadonlyArray<{ message: string }> }
23
+ | { issues: ReadonlyArray<SchemaIssue> }
23
24
  >
24
25
  types?: { input: TIn; output: TOut }
25
26
  jsonSchema: {
@@ -32,11 +33,16 @@ export type ToolSchema<TIn = unknown, TOut = TIn> = {
32
33
  const converter = new ZodToJsonSchemaConverter()
33
34
  const coercer = new JsonSchemaCoercer()
34
35
 
35
- function toJsonSchema(
36
+ /** Converted on first use: an app building many tools at import pays per tool used. */
37
+ function lazyJsonSchema(
36
38
  schema: AnySchema,
37
39
  strategy: 'input' | 'output'
38
- ): JsonSchema {
39
- return converter.convert(schema, { strategy })[1] as JsonSchema
40
+ ): () => JsonSchema {
41
+ let converted: JsonSchema | undefined
42
+ return () => {
43
+ converted ??= converter.convert(schema, { strategy })[1] as JsonSchema
44
+ return converted
45
+ }
40
46
  }
41
47
 
42
48
  /**
@@ -48,20 +54,20 @@ function toJsonSchema(
48
54
  * so values are coerced back before validating.
49
55
  */
50
56
  export function rawInputSchema<TIn>(schema: AnySchema): ToolSchema<TIn> {
51
- const jsonSchema = toJsonSchema(schema, 'input')
57
+ const jsonSchema = lazyJsonSchema(schema, 'input')
52
58
  return {
53
59
  '~standard': {
54
60
  version: 1,
55
61
  vendor: 'orpc-fn',
56
62
  validate: (value) => {
57
- const coerced = coercer.coerce(jsonSchema, value) as TIn
63
+ const coerced = coercer.coerce(jsonSchema(), value) as TIn
58
64
  const finish = (
59
65
  result: Awaited<ReturnType<AnySchema['~standard']['validate']>>
60
66
  ) => (result.issues ? { issues: result.issues } : { value: coerced })
61
67
  const result = schema['~standard'].validate(coerced)
62
68
  return result instanceof Promise ? result.then(finish) : finish(result)
63
69
  },
64
- jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }
70
+ jsonSchema: { input: jsonSchema, output: jsonSchema }
65
71
  }
66
72
  }
67
73
  }
@@ -70,13 +76,13 @@ export function rawInputSchema<TIn>(schema: AnySchema): ToolSchema<TIn> {
70
76
  export function passThroughOutputSchema<TOut>(
71
77
  schema: AnySchema
72
78
  ): ToolSchema<TOut> {
73
- const jsonSchema = toJsonSchema(schema, 'output')
79
+ const jsonSchema = lazyJsonSchema(schema, 'output')
74
80
  return {
75
81
  '~standard': {
76
82
  version: 1,
77
83
  vendor: 'orpc-fn',
78
84
  validate: (value) => ({ value: value as TOut }),
79
- jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }
85
+ jsonSchema: { input: jsonSchema, output: jsonSchema }
80
86
  }
81
87
  }
82
88
  }
package/src/live/codec.ts CHANGED
@@ -20,17 +20,15 @@ export function encodePayload(
20
20
  return JSON.stringify({ [MARKER]: 2, id, json, meta })
21
21
  }
22
22
 
23
+ /** A comparable serialization of a snapshot; undefined when it holds files. */
24
+ export function snapshotKey(value: unknown): string | undefined {
25
+ const [json, meta, maps, blobs] = serializer.serialize(value)
26
+ if (maps.length > 0 || blobs.length > 0) return undefined
27
+ return JSON.stringify([json, meta])
28
+ }
29
+
23
30
  export function decodePayload(payload: string): unknown {
24
- const parsed: unknown = JSON.parse(payload)
25
- if (
26
- typeof parsed === 'object' &&
27
- parsed !== null &&
28
- [1, 2].includes(Number((parsed as Record<string, unknown>)[MARKER]))
29
- ) {
30
- const { json, meta } = parsed as { json: unknown; meta: [] }
31
- return serializer.deserialize(json, meta)
32
- }
33
- return parsed
31
+ return decodeMessage(payload).value
34
32
  }
35
33
 
36
34
  /** IDs distinguish replay/live overlap; legacy payloads remain readable. */
@@ -38,13 +36,17 @@ export function decodeMessage(payload: string): {
38
36
  id: string | undefined
39
37
  value: unknown
40
38
  } {
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) }
39
+ const parsed: unknown = JSON.parse(payload)
40
+ if (typeof parsed !== 'object' || parsed === null)
41
+ return { id: undefined, value: parsed }
42
+ const envelope = parsed as Record<string, unknown>
43
+ const version = Number(envelope[MARKER])
44
+ if (version !== 1 && version !== 2) return { id: undefined, value: parsed }
45
+ return {
46
+ id:
47
+ version === 2 && typeof envelope.id === 'string'
48
+ ? envelope.id
49
+ : undefined,
50
+ value: serializer.deserialize(envelope.json, envelope.meta as [])
51
+ }
50
52
  }
@@ -3,8 +3,7 @@ import { type AnyProcedure, ORPCError } from '@orpc/server'
3
3
  import type { ZodType, z } from 'zod'
4
4
  import { RAW_INPUT } from '../timing.js'
5
5
  import { LIVE_GAP, positiveInteger } from './lifecycle.js'
6
- import type { FnLogger } from '../logger.js'
7
- import { errorMessageOf } from '../otel.js'
6
+ import { snapshotKey } from './codec.js'
8
7
  import type {
9
8
  FnContext,
10
9
  FnDefinition,
@@ -102,6 +101,11 @@ export type FnLiveConfig<
102
101
  authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
103
102
  /** Batch events arriving within this window into one snapshot update. */
104
103
  coalesceMs?: number
104
+ /**
105
+ * Without a transformer, skip a rerun's snapshot when it serializes the
106
+ * same as the last one sent. Default: true.
107
+ */
108
+ skipUnchanged?: boolean
105
109
  /** Maximum events collected in one coalescing batch (default 1000). */
106
110
  maxBatchSize?: number
107
111
  /**
@@ -169,11 +173,6 @@ export type FnLiveReturn<
169
173
  context: FnContext<TDef, TKey>
170
174
  }) => string
171
175
  getPublishChannelName: (event: z.output<TEventSchema>) => string
172
- /** @deprecated Prefer the separate required-input resolver methods. */
173
- getChannelName: (
174
- params: Partial<z.output<TInput>> | Partial<z.output<TEventSchema>>,
175
- context?: FnContext<TDef, TKey>
176
- ) => string
177
176
  }
178
177
 
179
178
  /**
@@ -267,32 +266,6 @@ export interface FnLive<TDef extends FnDefinition> {
267
266
  >
268
267
  }
269
268
 
270
- /**
271
- * Fail a live subscription whose initial snapshot could not be built.
272
- *
273
- * An expected client failure - an `ORPCError` with a 4xx status, e.g. the
274
- * subscribed resource was not found or access was refused - reaches the
275
- * subscriber unchanged and is NOT logged as a server fault. Only 5xx and
276
- * unknown failures are logged. Unknown errors are wrapped as
277
- * `INTERNAL_SERVER_ERROR`.
278
- */
279
- export function throwInitialSnapshotError(
280
- err: unknown,
281
- liveName: string,
282
- logger: Pick<FnLogger, 'error'>,
283
- /** `createFn`'s `isExpectedError`; default: a 4xx `ORPCError`. */
284
- isExpected: (error: unknown) => boolean = (error) =>
285
- error instanceof ORPCError && error.status < 500
286
- ): never {
287
- if (!isExpected(err)) {
288
- logger.error('fnLive initial snapshot failed', {
289
- name: liveName,
290
- err: errorMessageOf(err)
291
- })
292
- }
293
- throw toSnapshotError(err, liveName)
294
- }
295
-
296
269
  /** An `ORPCError` passes through; anything else becomes INTERNAL_SERVER_ERROR. */
297
270
  function toSnapshotError(
298
271
  err: unknown,
@@ -426,7 +399,6 @@ type RuntimeParams = Record<string, unknown> & {
426
399
  input: Record<string, unknown>
427
400
  context: unknown
428
401
  signal: AbortSignal | undefined
429
- logger: FnLogger
430
402
  call: (procedure: AnyProcedure, input: unknown) => Promise<unknown>
431
403
  [RAW_INPUT]: unknown
432
404
  }
@@ -441,6 +413,7 @@ type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
441
413
  shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
442
414
  coalesceMs?: number
443
415
  maxBatchSize?: number
416
+ skipUnchanged?: boolean
444
417
  summary?: string
445
418
  description?: string
446
419
  }
@@ -556,6 +529,7 @@ export function createFnLive(runtime: {
556
529
  live.reauthorize !== true &&
557
530
  !(await live.reauthorize({
558
531
  input: params.input,
532
+ context: params.context,
559
533
  ctx: params.context
560
534
  }))
561
535
  )
@@ -587,6 +561,8 @@ export function createFnLive(runtime: {
587
561
  }
588
562
  return validate(live.stateSchema as AnySchema, next)
589
563
  }
564
+ // What the client last received, to skip a rerun that changed nothing.
565
+ let sent: string | undefined
590
566
  const applyBatch = live.transformerFn
591
567
  ? undefined
592
568
  : async (events: readonly unknown[], previous: unknown) => {
@@ -599,7 +575,14 @@ export function createFnLive(runtime: {
599
575
  (await live.shouldUpdate({ ...params, event, previous }))
600
576
  )
601
577
  invalidated = true
602
- return invalidated ? rerun() : undefined
578
+ if (!invalidated) return undefined
579
+ const next = await rerun()
580
+ if (live.skipUnchanged === false) return next
581
+ sent ??= snapshotKey(previous)
582
+ const key = snapshotKey(next)
583
+ if (key !== undefined && key === sent) return undefined
584
+ sent = key
585
+ return next
603
586
  }
604
587
 
605
588
  yield* streamLiveSnapshots({
@@ -622,7 +605,6 @@ export function createFnLive(runtime: {
622
605
  procedure,
623
606
  subscribe,
624
607
  publish: channel.publish,
625
- getChannelName: channel.getChannelName,
626
608
  getSubscriptionChannelName: channel.getSubscriptionChannelName,
627
609
  getPublishChannelName: channel.getPublishChannelName
628
610
  }
package/src/live/index.ts CHANGED
@@ -5,8 +5,7 @@ export {
5
5
  type FnLivePatch,
6
6
  type FnLiveReturn,
7
7
  fnLivePatch,
8
- streamLiveSnapshots,
9
- throwInitialSnapshotError
8
+ streamLiveSnapshots
10
9
  } from './fn-live.js'
11
10
  export {
12
11
  type InvalidationEvent,
@@ -21,6 +20,7 @@ export {
21
20
  type CreatePubSub,
22
21
  createBoundedEventQueue,
23
22
  type FilterFn,
23
+ type ListenOptions,
24
24
  type ObjectSchema,
25
25
  type Publisher,
26
26
  type PublisherConfig,
@@ -1,16 +1,26 @@
1
+ import { decodePayload } from './codec.js'
1
2
  import type { PubSubTransport, PubSubTransportStats } from './transport.js'
2
3
 
4
+ /** One message the memory transport carried, decoded. */
5
+ export type PublishedEvent = { channel: string; event: unknown }
6
+
3
7
  /**
4
8
  * In-process transport for tests and single-process development. Delivery is
5
- * asynchronous (a microtask), like a real broker.
9
+ * asynchronous (a microtask), like a real broker. It records what was
10
+ * published, so a test asserts on `published()` instead of mocking a bus.
6
11
  */
7
12
  export function memoryTransport(): PubSubTransport & {
8
13
  /** Active listeners per channel, for assertions. */
9
14
  listenerCount(channel: string): number
10
15
  stats(): PubSubTransportStats
16
+ /** Events published so far, oldest first, decoded; optionally on one channel. */
17
+ published(channel?: string): PublishedEvent[]
18
+ /** Forget the recorded events. */
19
+ clearPublished(): void
11
20
  } {
12
21
  const listeners = new Map<string, Set<(payload: string) => void>>()
13
22
  const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
23
+ const log: { channel: string; payload: string }[] = []
14
24
 
15
25
  return {
16
26
  async close() {
@@ -19,6 +29,7 @@ export function memoryTransport(): PubSubTransport & {
19
29
  },
20
30
  async publish(messages, backlog) {
21
31
  for (const { channel, payload } of messages) {
32
+ log.push({ channel, payload })
22
33
  if (backlog) {
23
34
  const current = backlogs.get(channel)
24
35
  const items =
@@ -51,6 +62,16 @@ export function memoryTransport(): PubSubTransport & {
51
62
  }
52
63
  },
53
64
  listenerCount: (channel) => listeners.get(channel)?.size ?? 0,
65
+ published: (channel) =>
66
+ log
67
+ .filter((entry) => channel === undefined || entry.channel === channel)
68
+ .map((entry) => ({
69
+ channel: entry.channel,
70
+ event: decodePayload(entry.payload)
71
+ })),
72
+ clearPublished: () => {
73
+ log.length = 0
74
+ },
54
75
  stats: () => ({
55
76
  channels: listeners.size,
56
77
  listeners: [...listeners.values()].reduce(