orpc-fn 0.7.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.
- package/README.md +66 -28
- package/dist/assemble-procedure.d.ts.map +1 -1
- package/dist/assemble-procedure.js +3 -1
- package/dist/assemble-procedure.js.map +1 -1
- package/dist/client.d.ts +13 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +21 -1
- package/dist/client.js.map +1 -1
- package/dist/create-fn.d.ts +63 -5
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +45 -16
- package/dist/create-fn.js.map +1 -1
- package/dist/expected-client-error.d.ts +6 -0
- package/dist/expected-client-error.d.ts.map +1 -1
- package/dist/expected-client-error.js +16 -0
- package/dist/expected-client-error.js.map +1 -1
- package/dist/hono.d.ts +44 -22
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +51 -20
- package/dist/hono.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/issues.d.ts +12 -0
- package/dist/issues.d.ts.map +1 -0
- package/dist/issues.js +22 -0
- package/dist/issues.js.map +1 -0
- package/dist/json-schema.d.ts +3 -6
- package/dist/json-schema.d.ts.map +1 -1
- package/dist/json-schema.js +12 -7
- package/dist/json-schema.js.map +1 -1
- package/dist/live/codec.d.ts +2 -0
- package/dist/live/codec.d.ts.map +1 -1
- package/dist/live/codec.js +21 -16
- package/dist/live/codec.js.map +1 -1
- package/dist/live/fn-live.d.ts +6 -19
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +15 -23
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +2 -2
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js +1 -1
- package/dist/live/index.js.map +1 -1
- package/dist/live/memory.d.ts +11 -1
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +14 -1
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +50 -8
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +79 -26
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +6 -1
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +10 -1
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/scoped-change-bus.d.ts +3 -1
- package/dist/live/scoped-change-bus.d.ts.map +1 -1
- package/dist/live/scoped-change-bus.js +3 -0
- package/dist/live/scoped-change-bus.js.map +1 -1
- package/dist/mastra.d.ts +16 -3
- package/dist/mastra.d.ts.map +1 -1
- package/dist/mastra.js +15 -3
- package/dist/mastra.js.map +1 -1
- package/dist/mcp/core.d.ts +31 -14
- package/dist/mcp/core.d.ts.map +1 -1
- package/dist/mcp/core.js +39 -20
- package/dist/mcp/core.js.map +1 -1
- package/dist/mcp/sdk.d.ts +6 -2
- package/dist/mcp/sdk.d.ts.map +1 -1
- package/dist/mcp/sdk.js +9 -3
- package/dist/mcp/sdk.js.map +1 -1
- package/dist/mcp/server.d.ts +6 -2
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +9 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp.d.ts +3 -7
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +4 -6
- package/dist/mcp.js.map +1 -1
- package/dist/meta.d.ts +14 -2
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +6 -2
- package/dist/meta.js.map +1 -1
- package/dist/otel.d.ts +16 -4
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +40 -10
- package/dist/otel.js.map +1 -1
- package/dist/react.d.ts +6 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -1
- package/dist/route-options.d.ts.map +1 -1
- package/dist/route-options.js +4 -0
- package/dist/route-options.js.map +1 -1
- package/dist/stream-call.d.ts +15 -0
- package/dist/stream-call.d.ts.map +1 -0
- package/dist/stream-call.js +13 -0
- package/dist/stream-call.js.map +1 -0
- package/dist/timing.d.ts +2 -0
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +2 -0
- package/dist/timing.js.map +1 -1
- package/dist/tool-errors.d.ts +24 -0
- package/dist/tool-errors.d.ts.map +1 -0
- package/dist/tool-errors.js +36 -0
- package/dist/tool-errors.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vue.d.ts +47 -4
- package/dist/vue.d.ts.map +1 -1
- package/dist/vue.js +39 -9
- package/dist/vue.js.map +1 -1
- package/package.json +2 -2
- package/src/assemble-procedure.ts +3 -1
- package/src/client.ts +26 -2
- package/src/create-fn.ts +149 -19
- package/src/expected-client-error.ts +19 -0
- package/src/hono.ts +120 -41
- package/src/index.ts +3 -2
- package/src/issues.ts +35 -0
- package/src/json-schema.ts +16 -10
- package/src/live/codec.ts +21 -19
- package/src/live/fn-live.ts +18 -36
- package/src/live/index.ts +2 -2
- package/src/live/memory.ts +22 -1
- package/src/live/pub-sub.ts +142 -37
- package/src/live/redis-bun.ts +23 -2
- package/src/live/scoped-change-bus.ts +10 -1
- package/src/mastra.ts +25 -6
- package/src/mcp/core.ts +76 -29
- package/src/mcp/sdk.ts +27 -2
- package/src/mcp/server.ts +27 -2
- package/src/mcp.ts +5 -16
- package/src/meta.ts +28 -4
- package/src/otel.ts +41 -9
- package/src/react.ts +10 -3
- package/src/route-options.ts +8 -0
- package/src/stream-call.ts +15 -0
- package/src/timing.ts +3 -0
- package/src/tool-errors.ts +52 -0
- package/src/types.ts +2 -0
- 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
|
|
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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/json-schema.ts
CHANGED
|
@@ -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<
|
|
20
|
+
| { issues: ReadonlyArray<SchemaIssue> }
|
|
20
21
|
| Promise<
|
|
21
22
|
| { value: TOut; issues?: undefined }
|
|
22
|
-
| { issues: ReadonlyArray<
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
}
|
package/src/live/fn-live.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/src/live/memory.ts
CHANGED
|
@@ -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(
|