orpc-fn 0.3.0 → 0.5.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 +146 -5
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -1
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +2 -8
- package/dist/create-fn.js.map +1 -1
- package/dist/hono.d.ts +12 -1
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +15 -1
- package/dist/hono.js.map +1 -1
- package/dist/live/fn-live.d.ts +1 -6
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +2 -18
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +3 -1
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js +2 -0
- package/dist/live/index.js.map +1 -1
- package/dist/live/invalidation-stream.d.ts +52 -0
- package/dist/live/invalidation-stream.d.ts.map +1 -0
- package/dist/live/invalidation-stream.js +98 -0
- package/dist/live/invalidation-stream.js.map +1 -0
- package/dist/live/ioredis.d.ts +1 -0
- package/dist/live/ioredis.d.ts.map +1 -1
- package/dist/live/ioredis.js +1 -0
- package/dist/live/ioredis.js.map +1 -1
- package/dist/live/memory.d.ts +2 -1
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +6 -1
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +5 -0
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +36 -15
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +3 -0
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +1 -0
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/redis.d.ts +4 -1
- package/dist/live/redis.d.ts.map +1 -1
- package/dist/live/redis.js +14 -2
- package/dist/live/redis.js.map +1 -1
- package/dist/live/scoped-change-bus.d.ts +45 -0
- package/dist/live/scoped-change-bus.d.ts.map +1 -0
- package/dist/live/scoped-change-bus.js +38 -0
- package/dist/live/scoped-change-bus.js.map +1 -0
- package/dist/live/transport.d.ts +11 -0
- package/dist/live/transport.d.ts.map +1 -1
- package/dist/live/transport.js.map +1 -1
- package/dist/otel.d.ts +5 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/otel.js +7 -0
- package/dist/otel.js.map +1 -1
- package/dist/procedure-cli.d.ts +25 -0
- package/dist/procedure-cli.d.ts.map +1 -0
- package/dist/procedure-cli.js +67 -0
- package/dist/procedure-cli.js.map +1 -0
- package/dist/react.d.ts +116 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +156 -0
- package/dist/react.js.map +1 -0
- package/dist/realtime.d.ts +114 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +316 -0
- package/dist/realtime.js.map +1 -0
- package/dist/vue/testing.d.ts +67 -0
- package/dist/vue/testing.d.ts.map +1 -0
- package/dist/vue/testing.js +225 -0
- package/dist/vue/testing.js.map +1 -0
- package/dist/vue.d.ts +211 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +312 -0
- package/dist/vue.js.map +1 -0
- package/package.json +51 -4
- package/src/client.ts +57 -0
- package/src/create-fn.ts +2 -7
- package/src/hono.ts +22 -2
- package/src/live/fn-live.ts +3 -22
- package/src/live/index.ts +12 -1
- package/src/live/invalidation-stream.ts +142 -0
- package/src/live/ioredis.ts +2 -0
- package/src/live/memory.ts +11 -2
- package/src/live/pub-sub.ts +48 -17
- package/src/live/redis-bun.ts +3 -0
- package/src/live/redis.ts +33 -6
- package/src/live/scoped-change-bus.ts +98 -0
- package/src/live/transport.ts +12 -0
- package/src/otel.ts +8 -0
- package/src/procedure-cli.ts +99 -0
- package/src/react.ts +355 -0
- package/src/realtime.ts +416 -0
- package/src/vue/testing.ts +296 -0
- package/src/vue.ts +783 -0
package/src/live/pub-sub.ts
CHANGED
|
@@ -2,7 +2,12 @@ import type { Schema } from '@orpc/contract'
|
|
|
2
2
|
import { ORPCError } from '@orpc/server'
|
|
3
3
|
import type { ZodObject, ZodRawShape, ZodType, z } from 'zod'
|
|
4
4
|
import type { FnLogger } from '../logger.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
errorAttributes,
|
|
7
|
+
errorMessageOf,
|
|
8
|
+
type SpanLike,
|
|
9
|
+
type Tracing
|
|
10
|
+
} from '../otel.js'
|
|
6
11
|
import type {
|
|
7
12
|
GuardOptions,
|
|
8
13
|
FnContext,
|
|
@@ -79,6 +84,11 @@ export type PublisherConfig = {
|
|
|
79
84
|
* channel means no duplicate work.
|
|
80
85
|
*/
|
|
81
86
|
mirrorChannel?: string
|
|
87
|
+
/**
|
|
88
|
+
* Log publish failures (validation or broker) instead of throwing them, so
|
|
89
|
+
* a publish after a committed write cannot fail the request. No retry.
|
|
90
|
+
*/
|
|
91
|
+
safePublish?: boolean
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/** Per-definition overrides of the factory queue bounds. */
|
|
@@ -607,10 +617,25 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
607
617
|
}
|
|
608
618
|
})
|
|
609
619
|
|
|
620
|
+
const guarded = async (
|
|
621
|
+
operation: 'publish' | 'publishMany',
|
|
622
|
+
items: readonly unknown[]
|
|
623
|
+
) => {
|
|
624
|
+
if (!config.safePublish) return send(operation, items)
|
|
625
|
+
try {
|
|
626
|
+
await send(operation, items)
|
|
627
|
+
} catch (error) {
|
|
628
|
+
logger.error('pubsub.publish_failed', {
|
|
629
|
+
name,
|
|
630
|
+
...errorAttributes(error)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
610
635
|
return {
|
|
611
|
-
publish: (data: unknown) =>
|
|
636
|
+
publish: (data: unknown) => guarded('publish', [data]),
|
|
612
637
|
publishMany: async (items: readonly unknown[]) => {
|
|
613
|
-
if (items.length > 0) await
|
|
638
|
+
if (items.length > 0) await guarded('publishMany', items)
|
|
614
639
|
}
|
|
615
640
|
}
|
|
616
641
|
}
|
|
@@ -706,8 +731,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
706
731
|
name: config.name,
|
|
707
732
|
channel
|
|
708
733
|
})
|
|
709
|
-
logger.warn(
|
|
710
|
-
|
|
734
|
+
logger.warn('pubsub.message_dropped', {
|
|
735
|
+
channel,
|
|
736
|
+
...errorAttributes(error)
|
|
711
737
|
})
|
|
712
738
|
}
|
|
713
739
|
}
|
|
@@ -721,7 +747,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
721
747
|
if (closed || timer) return
|
|
722
748
|
const delayMs = getResubscribeDelayMs(attempt)
|
|
723
749
|
attempt++
|
|
724
|
-
logger.warn(
|
|
750
|
+
logger.warn('pubsub.resubscribe_scheduled', {
|
|
751
|
+
channel,
|
|
725
752
|
reason,
|
|
726
753
|
attempt,
|
|
727
754
|
delayMs
|
|
@@ -749,8 +776,9 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
749
776
|
connected = false
|
|
750
777
|
markPending()
|
|
751
778
|
}
|
|
752
|
-
logger.error(
|
|
753
|
-
|
|
779
|
+
logger.error('pubsub.subscription_lost', {
|
|
780
|
+
channel,
|
|
781
|
+
...errorAttributes(error)
|
|
754
782
|
})
|
|
755
783
|
scheduleResubscribe('lost')
|
|
756
784
|
},
|
|
@@ -784,10 +812,11 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
784
812
|
connectedOnce = true
|
|
785
813
|
markReady()
|
|
786
814
|
attempt = 0
|
|
787
|
-
logger.
|
|
815
|
+
logger.debug('pubsub.subscribed', { channel })
|
|
788
816
|
} catch (error) {
|
|
789
|
-
logger.error(
|
|
790
|
-
|
|
817
|
+
logger.error('pubsub.subscribe_failed', {
|
|
818
|
+
channel,
|
|
819
|
+
...errorAttributes(error)
|
|
791
820
|
})
|
|
792
821
|
scheduleResubscribe('subscribe-failed')
|
|
793
822
|
}
|
|
@@ -926,7 +955,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
926
955
|
throw error
|
|
927
956
|
}
|
|
928
957
|
|
|
929
|
-
subscriberLogger.
|
|
958
|
+
subscriberLogger.debug('pubsub.subscribing', { channel: channelName })
|
|
930
959
|
|
|
931
960
|
let messageCount = 0
|
|
932
961
|
let droppedCount = 0
|
|
@@ -1051,7 +1080,8 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
1051
1080
|
span?.setAttribute('pubsub.message_count', messageCount)
|
|
1052
1081
|
span?.setAttribute('pubsub.dropped_count', droppedCount)
|
|
1053
1082
|
tracing.end(span, ...error)
|
|
1054
|
-
subscriberLogger.
|
|
1083
|
+
subscriberLogger.debug('pubsub.unsubscribed', {
|
|
1084
|
+
channel: channelName,
|
|
1055
1085
|
messageCount,
|
|
1056
1086
|
droppedCount,
|
|
1057
1087
|
durationMs: Math.round(durationMs)
|
|
@@ -1106,10 +1136,10 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
1106
1136
|
if (items.length > maxReplay) replayGap = true
|
|
1107
1137
|
} catch (error) {
|
|
1108
1138
|
replayGap = true
|
|
1109
|
-
subscriberLogger.warn(
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
)
|
|
1139
|
+
subscriberLogger.warn('pubsub.replay_failed', {
|
|
1140
|
+
channel: channelName,
|
|
1141
|
+
...errorAttributes(error)
|
|
1142
|
+
})
|
|
1113
1143
|
}
|
|
1114
1144
|
}
|
|
1115
1145
|
for (const { data, id } of replaying ?? [])
|
|
@@ -1195,6 +1225,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
1195
1225
|
backlogSize: _backlogSize,
|
|
1196
1226
|
backlogTtl: _backlogTtl,
|
|
1197
1227
|
mirrorChannel: _mirrorChannel,
|
|
1228
|
+
safePublish: _safePublish,
|
|
1198
1229
|
maxQueueSize: _maxQueueSize,
|
|
1199
1230
|
maxIngressSize: _maxIngressSize,
|
|
1200
1231
|
maxReplaySize: _maxReplaySize,
|
package/src/live/redis-bun.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface BunRedisClientLike {
|
|
|
9
9
|
unsubscribe(channel: string, listener?: Listener): Promise<unknown>
|
|
10
10
|
duplicate(): Promise<BunRedisClientLike>
|
|
11
11
|
close(): void
|
|
12
|
+
/** Bun's `RedisClient.connected`; read by `stats()` when present. */
|
|
13
|
+
readonly connected?: boolean
|
|
12
14
|
onconnect: ((this: never) => void) | null
|
|
13
15
|
onclose: ((this: never, error: Error) => void) | null
|
|
14
16
|
}
|
|
@@ -58,6 +60,7 @@ export function bunRedisTransport(
|
|
|
58
60
|
ours.delete(channel)
|
|
59
61
|
return subscriber.unsubscribe(channel, dispatch)
|
|
60
62
|
},
|
|
63
|
+
connected: () => subscriber.connected !== false,
|
|
61
64
|
close() {
|
|
62
65
|
// No-ops, not null: Bun calls `onclose` without checking it.
|
|
63
66
|
subscriber.onconnect = () => {}
|
package/src/live/redis.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { errorMessageOf } from '../otel.js'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
backlogKey,
|
|
4
|
+
type PubSubTransport,
|
|
5
|
+
type PubSubTransportStats
|
|
6
|
+
} from './transport.js'
|
|
3
7
|
|
|
4
8
|
export type RedisSend = (command: string, args: string[]) => Promise<unknown>
|
|
5
9
|
|
|
@@ -77,6 +81,8 @@ export type SubscriberConnection = {
|
|
|
77
81
|
resubscribe?(channel: string): Promise<unknown>
|
|
78
82
|
/** Stop delivering and release the connection (if the driver created it). */
|
|
79
83
|
close(): void | Promise<void>
|
|
84
|
+
/** The driver reports the connection open; omitted means open until lost. */
|
|
85
|
+
connected?(): boolean
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/** Callbacks for one connection; they do nothing once it is replaced. */
|
|
@@ -115,7 +121,10 @@ export function createRedisTransport(driver: {
|
|
|
115
121
|
connectSubscriber: (
|
|
116
122
|
handlers: SubscriberHandlers
|
|
117
123
|
) => Promise<SubscriberConnection>
|
|
118
|
-
}): PubSubTransport & {
|
|
124
|
+
}): PubSubTransport & {
|
|
125
|
+
close(): Promise<void>
|
|
126
|
+
stats(): PubSubTransportStats
|
|
127
|
+
} {
|
|
119
128
|
const publishScript = createLuaScript(driver.send, PUBLISH_LUA)
|
|
120
129
|
const channels = new Map<string, ChannelState>()
|
|
121
130
|
// An UNSUBSCRIBE still in flight would cancel a SUBSCRIBE sent after it.
|
|
@@ -124,6 +133,8 @@ export function createRedisTransport(driver: {
|
|
|
124
133
|
connection: Promise<SubscriberConnection>
|
|
125
134
|
retire: () => Promise<void>
|
|
126
135
|
} | null = null
|
|
136
|
+
// The open connection of the current generation, once it resolves.
|
|
137
|
+
let open: SubscriberConnection | null = null
|
|
127
138
|
|
|
128
139
|
const connect = () => {
|
|
129
140
|
let retired = false
|
|
@@ -131,7 +142,10 @@ export function createRedisTransport(driver: {
|
|
|
131
142
|
const retire = (error?: Error) => {
|
|
132
143
|
if (retired) return closing ?? Promise.resolve()
|
|
133
144
|
retired = true
|
|
134
|
-
if (current?.connection === connection)
|
|
145
|
+
if (current?.connection === connection) {
|
|
146
|
+
current = null
|
|
147
|
+
open = null
|
|
148
|
+
}
|
|
135
149
|
closing = connection.then((open) => open.close()).catch(() => {})
|
|
136
150
|
if (!error) return closing
|
|
137
151
|
const lost = [...channels.values()].flatMap((state) => [...state.entries])
|
|
@@ -182,9 +196,14 @@ export function createRedisTransport(driver: {
|
|
|
182
196
|
onLost: (error) => retire(error)
|
|
183
197
|
}
|
|
184
198
|
const connection = driver.connectSubscriber(handlers)
|
|
185
|
-
connection.
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
connection.then(
|
|
200
|
+
(resolved) => {
|
|
201
|
+
if (!retired) open = resolved
|
|
202
|
+
},
|
|
203
|
+
() => {
|
|
204
|
+
if (current?.connection === connection) current = null
|
|
205
|
+
}
|
|
206
|
+
)
|
|
188
207
|
current = { connection, retire: () => retire() }
|
|
189
208
|
return connection
|
|
190
209
|
}
|
|
@@ -268,6 +287,14 @@ export function createRedisTransport(driver: {
|
|
|
268
287
|
await done
|
|
269
288
|
}
|
|
270
289
|
},
|
|
290
|
+
stats: () => ({
|
|
291
|
+
channels: channels.size,
|
|
292
|
+
listeners: [...channels.values()].reduce(
|
|
293
|
+
(sum, state) => sum + state.entries.size,
|
|
294
|
+
0
|
|
295
|
+
),
|
|
296
|
+
subscriberConnected: open ? (open.connected?.() ?? true) : false
|
|
297
|
+
}),
|
|
271
298
|
async close() {
|
|
272
299
|
const closing = current?.retire()
|
|
273
300
|
channels.clear()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ZodType, z } from 'zod'
|
|
2
|
+
import type {
|
|
3
|
+
AnyFnContext,
|
|
4
|
+
FnDefinition,
|
|
5
|
+
FnRouteOptions,
|
|
6
|
+
GuardOptions,
|
|
7
|
+
ProcedureKey,
|
|
8
|
+
ProcedureOption
|
|
9
|
+
} from '../types.js'
|
|
10
|
+
import type {
|
|
11
|
+
CreatePubSub,
|
|
12
|
+
ObjectSchema,
|
|
13
|
+
PubSub,
|
|
14
|
+
PubSubOptions
|
|
15
|
+
} from './pub-sub.js'
|
|
16
|
+
|
|
17
|
+
/** Where a change bus partitions its channels, e.g. one per organization. */
|
|
18
|
+
export type ChangeBusScope<TContext, TEvent> = {
|
|
19
|
+
/** The subscriber's scope key, from its context. Throw to refuse the subscription. */
|
|
20
|
+
subscribe: (context: TContext) => string
|
|
21
|
+
/** The event's scope key, carried in the event itself. */
|
|
22
|
+
publish: (event: TEvent) => string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A bus's channel definition; pass it to `fnLive` to share the bus's events. */
|
|
26
|
+
export type ChangeBusChannel<TContext, TEvent> = {
|
|
27
|
+
subscribe: (params: { context: TContext }) => string
|
|
28
|
+
publish: (event: TEvent) => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ScopedChangeBus<
|
|
32
|
+
TDef extends FnDefinition,
|
|
33
|
+
TInputSchema extends ObjectSchema,
|
|
34
|
+
TEventSchema extends ZodType,
|
|
35
|
+
TKey extends ProcedureKey<TDef>,
|
|
36
|
+
TScopeEvent
|
|
37
|
+
> = PubSub<TDef, TInputSchema, TEventSchema, TKey> & {
|
|
38
|
+
channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Binds the app's `createPubSub` to one scope rule, for buses that tell every
|
|
43
|
+
* subscriber in a scope "something changed". Each bus then names only its
|
|
44
|
+
* channel prefix: events go to `${prefix}:${scope}`. Publishing is safe by
|
|
45
|
+
* default (`safePublish: true`), because buses publish after committed writes.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const defineChangeBus = defineScopedChangeBus(createPubSub, {
|
|
49
|
+
* subscribe: (context) => activeOrganizationId(context),
|
|
50
|
+
* publish: (event: { organizationId: string }) => event.organizationId
|
|
51
|
+
* })
|
|
52
|
+
* export const dealChanges = defineChangeBus({
|
|
53
|
+
* name: 'deal.subscribe',
|
|
54
|
+
* prefix: 'deal:change',
|
|
55
|
+
* inputSchema: z.object({}),
|
|
56
|
+
* eventSchema: z.object({ organizationId: z.string(), dealId: z.string() })
|
|
57
|
+
* })
|
|
58
|
+
* // fnLive({ live: { channel: dealChanges.channel, ... } }) shares the channel.
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function defineScopedChangeBus<TDef extends FnDefinition, TScopeEvent>(
|
|
62
|
+
createPubSub: CreatePubSub<TDef>,
|
|
63
|
+
scope: ChangeBusScope<AnyFnContext<TDef>, TScopeEvent>
|
|
64
|
+
) {
|
|
65
|
+
return <
|
|
66
|
+
TInputSchema extends ObjectSchema,
|
|
67
|
+
TEventSchema extends ZodType<TScopeEvent>,
|
|
68
|
+
TKey extends ProcedureKey<TDef> = TDef['default']
|
|
69
|
+
>(
|
|
70
|
+
options: Omit<
|
|
71
|
+
PubSubOptions<TDef, TInputSchema, TEventSchema, NoInfer<TKey>>,
|
|
72
|
+
'channel'
|
|
73
|
+
> &
|
|
74
|
+
GuardOptions<TDef['guards']> &
|
|
75
|
+
Pick<
|
|
76
|
+
FnRouteOptions<TDef, TKey, z.output<TInputSchema>>,
|
|
77
|
+
'meta' | 'guardResolvers'
|
|
78
|
+
> &
|
|
79
|
+
ProcedureOption<TDef, TKey> & {
|
|
80
|
+
/** Channel prefix; the scope key is appended as `${prefix}:${scope}`. */
|
|
81
|
+
prefix: string
|
|
82
|
+
}
|
|
83
|
+
): ScopedChangeBus<TDef, TInputSchema, TEventSchema, TKey, TScopeEvent> => {
|
|
84
|
+
const { prefix, ...pubsubOptions } = options
|
|
85
|
+
const channel: ChangeBusChannel<AnyFnContext<TDef>, TScopeEvent> = {
|
|
86
|
+
subscribe: ({ context }) => `${prefix}:${scope.subscribe(context)}`,
|
|
87
|
+
publish: (event) => `${prefix}:${scope.publish(event)}`
|
|
88
|
+
}
|
|
89
|
+
// The bus's context is one member of AnyFnContext, which the generic
|
|
90
|
+
// signature of createPubSub cannot see.
|
|
91
|
+
const bus = (createPubSub as (options: object) => object)({
|
|
92
|
+
safePublish: true,
|
|
93
|
+
...pubsubOptions,
|
|
94
|
+
channel
|
|
95
|
+
}) as PubSub<TDef, TInputSchema, TEventSchema, TKey>
|
|
96
|
+
return { ...bus, channel }
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/live/transport.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** One payload for one channel. */
|
|
2
2
|
export type PubSubMessage = { channel: string; payload: string }
|
|
3
3
|
|
|
4
|
+
/** What a transport holds open on this instance, for gauges and health checks. */
|
|
5
|
+
export type PubSubTransportStats = {
|
|
6
|
+
/** Broker channels subscribed (one per channel, however many listeners). */
|
|
7
|
+
channels: number
|
|
8
|
+
/** Local listeners across those channels. */
|
|
9
|
+
listeners: number
|
|
10
|
+
/** The subscriber connection is open; false before the first subscribe. */
|
|
11
|
+
subscriberConnected: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export type BacklogOptions = {
|
|
5
15
|
/** Items kept per channel (oldest dropped). */
|
|
6
16
|
size: number
|
|
@@ -39,6 +49,8 @@ export interface PubSubTransport {
|
|
|
39
49
|
onLost?: (error: Error) => void,
|
|
40
50
|
options?: { onReconnect?: () => void }
|
|
41
51
|
): Promise<() => Promise<void>>
|
|
52
|
+
/** Current subscription counts and connection state. */
|
|
53
|
+
stats?(): PubSubTransportStats
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export const backlogKey = (channel: string) => `${channel}:backlog`
|
package/src/otel.ts
CHANGED
|
@@ -195,6 +195,14 @@ export function errorMessageOf(error: unknown): string {
|
|
|
195
195
|
return error instanceof Error ? error.message : String(error)
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** Error fields for a log line, named like the `fn.completed` ones. */
|
|
199
|
+
export function errorAttributes(error: unknown) {
|
|
200
|
+
return {
|
|
201
|
+
error_type: error instanceof Error ? error.name : typeof error,
|
|
202
|
+
error_message: errorMessageOf(error)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
198
206
|
function protectSpan<T extends SpanLike>(span: T): T {
|
|
199
207
|
return new Proxy(span, {
|
|
200
208
|
get(target, key) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type AnyProcedure, call, isProcedure, ORPCError } from '@orpc/server'
|
|
2
|
+
import {
|
|
3
|
+
CliError,
|
|
4
|
+
type Command,
|
|
5
|
+
type CommandGroup,
|
|
6
|
+
defineCommand
|
|
7
|
+
} from 'hanzio/cli'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { type FnMeta, readFnMeta } from './meta.js'
|
|
10
|
+
|
|
11
|
+
export type ProcedureCommandsOptions<C> = {
|
|
12
|
+
/**
|
|
13
|
+
* Which procedures become commands. Default: those declared with
|
|
14
|
+
* `meta: { cli: true }` (add `cli?: boolean` to the app's meta type).
|
|
15
|
+
*/
|
|
16
|
+
filter?: (meta: FnMeta) => boolean
|
|
17
|
+
/**
|
|
18
|
+
* The oRPC context a command calls its procedure with, from the CLI's
|
|
19
|
+
* context (`defineCli({ context })`). Default: that context as-is. It gets
|
|
20
|
+
* the procedure's meta, so it can refuse early with a `CliError`, e.g. a
|
|
21
|
+
* protected procedure run without a user.
|
|
22
|
+
*/
|
|
23
|
+
context?: (cliContext: C, meta: FnMeta) => unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const optedIn = (meta: FnMeta) => (meta.meta as { cli?: unknown }).cli === true
|
|
27
|
+
|
|
28
|
+
const kebab = (key: string) =>
|
|
29
|
+
key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The opted-in `fn()` procedures of a router as hanzio/cli commands, nested
|
|
33
|
+
* like the router: `supportLearning.syncRun` is `support-learning sync-run`.
|
|
34
|
+
* Flags come from the procedure's input schema, help from its summary. A
|
|
35
|
+
* command calls the procedure through oRPC, so middleware, guards, tracing
|
|
36
|
+
* and errors run as they do over HTTP.
|
|
37
|
+
*/
|
|
38
|
+
export function procedureCommands<C = unknown>(
|
|
39
|
+
router: object,
|
|
40
|
+
options: ProcedureCommandsOptions<C> = {}
|
|
41
|
+
): CommandGroup<C> {
|
|
42
|
+
const filter = options.filter ?? optedIn
|
|
43
|
+
const commands: Record<string, Command<C> | CommandGroup<C>> = {}
|
|
44
|
+
|
|
45
|
+
for (const [key, value] of Object.entries(router)) {
|
|
46
|
+
if (isProcedure(value)) {
|
|
47
|
+
const meta = readFnMeta(value)
|
|
48
|
+
if (filter(meta)) commands[kebab(key)] = toCommand(value, meta, options)
|
|
49
|
+
} else if (value && typeof value === 'object') {
|
|
50
|
+
const group = procedureCommands(value, options)
|
|
51
|
+
if (Object.keys(group).length > 0) commands[kebab(key)] = group
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return commands
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toCommand<C>(
|
|
59
|
+
procedure: AnyProcedure,
|
|
60
|
+
meta: FnMeta,
|
|
61
|
+
options: ProcedureCommandsOptions<C>
|
|
62
|
+
): Command<C> {
|
|
63
|
+
const name = meta.name ?? 'procedure'
|
|
64
|
+
if (meta.stream) {
|
|
65
|
+
throw new TypeError(`orpc-fn: ${name} streams and cannot be a CLI command`)
|
|
66
|
+
}
|
|
67
|
+
const input = meta.inputSchema
|
|
68
|
+
if (input !== undefined && !(input instanceof z.ZodObject)) {
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`orpc-fn: ${name} must take a z.object() input to be a CLI command`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return defineCommand<z.ZodObject, unknown, C>({
|
|
75
|
+
description: meta.description,
|
|
76
|
+
input: input ?? z.object({}),
|
|
77
|
+
run: async (parsed, ctx) => {
|
|
78
|
+
const context = options.context
|
|
79
|
+
? await options.context(ctx.context, meta)
|
|
80
|
+
: ctx.context
|
|
81
|
+
try {
|
|
82
|
+
// The flags were validated against this schema; the procedure parses
|
|
83
|
+
// them again, like any nested call with a parsed value.
|
|
84
|
+
return await call(procedure, input ? parsed : undefined, {
|
|
85
|
+
context: context as Record<never, never>
|
|
86
|
+
})
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A refusal the caller drives (auth, permission, not found) reads as
|
|
89
|
+
// a message, not a crash with a stack trace.
|
|
90
|
+
if (error instanceof ORPCError && error.status < 500) {
|
|
91
|
+
throw new CliError(`${error.code}: ${error.message}`, {
|
|
92
|
+
cause: error
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|