orpc-fn 0.4.0 → 0.5.1
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 -4
- package/dist/cli.js +37 -48
- package/dist/cli.js.map +1 -1
- 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 +0 -1
- 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 +16 -3
- 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/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 +49 -3
- package/src/cli.ts +47 -57
- package/src/client.ts +57 -0
- package/src/create-fn.ts +0 -1
- 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 +23 -2
- 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/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/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ClientContext, ORPCError, type ORPCErrorCode } from '@orpc/client'
|
|
2
2
|
import { RPCLink, type RPCLinkOptions } from '@orpc/client/fetch'
|
|
3
3
|
import { BatchLinkPlugin } from '@orpc/client/plugins'
|
|
4
|
+
import { StandardRPCJsonSerializer } from '@orpc/client/standard'
|
|
4
5
|
|
|
5
6
|
type BatchOptions<T extends ClientContext> = ConstructorParameters<
|
|
6
7
|
typeof BatchLinkPlugin<T>
|
|
@@ -87,3 +88,59 @@ export function hasOrpcErrorCode<TCode extends ORPCErrorCode>(
|
|
|
87
88
|
): error is ORPCError<TCode, unknown> {
|
|
88
89
|
return error instanceof ORPCError && error.code === code
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The server's message, only when the backend marked it user-safe by putting
|
|
94
|
+
* a `userMessage` string on the error's `data`. Route error messages are
|
|
95
|
+
* developer text (English, internal model names), so they are not shown by
|
|
96
|
+
* default; they stay on the error for logs and error tracking.
|
|
97
|
+
*
|
|
98
|
+
* ```ts
|
|
99
|
+
* throw new ORPCError('BAD_REQUEST', {
|
|
100
|
+
* message: 'Deal has no pipeline stage', // logs, telemetry
|
|
101
|
+
* data: { userMessage: 'Choose a stage first.' } // the user reads this
|
|
102
|
+
* })
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function userSafeServerMessage(error: unknown): string | null {
|
|
106
|
+
if (!(error instanceof ORPCError)) return null
|
|
107
|
+
const data: unknown = error.data
|
|
108
|
+
if (typeof data !== 'object' || data === null) return null
|
|
109
|
+
const userMessage = (data as { userMessage?: unknown }).userMessage
|
|
110
|
+
return typeof userMessage === 'string' && userMessage.trim().length > 0
|
|
111
|
+
? userMessage
|
|
112
|
+
: null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** User-facing text for a caught error: the opted-in server message, else your localized fallback. */
|
|
116
|
+
export function userFacingErrorMessage(
|
|
117
|
+
error: unknown,
|
|
118
|
+
fallback: string
|
|
119
|
+
): string {
|
|
120
|
+
return userSafeServerMessage(error) ?? fallback
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cacheSerializer = new StandardRPCJsonSerializer()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `serialize`/`deserialize` for a TanStack Query persister. API payloads carry
|
|
127
|
+
* `Date`s (and maybe `Map`s, `Set`s, `BigInt`s) that plain JSON returns as
|
|
128
|
+
* strings; oRPC's own serializer round-trips them.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* createAsyncStoragePersister({ storage: AsyncStorage, ...queryCacheSerializer })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export const queryCacheSerializer = {
|
|
135
|
+
serialize(value: unknown): string {
|
|
136
|
+
const [json, meta] = cacheSerializer.serialize(value)
|
|
137
|
+
return JSON.stringify({ json, meta })
|
|
138
|
+
},
|
|
139
|
+
deserialize<T>(text: string): T {
|
|
140
|
+
const { json, meta } = JSON.parse(text) as {
|
|
141
|
+
json: unknown
|
|
142
|
+
meta: Parameters<typeof cacheSerializer.deserialize>[1]
|
|
143
|
+
}
|
|
144
|
+
return cacheSerializer.deserialize(json, meta) as T
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/create-fn.ts
CHANGED
package/src/hono.ts
CHANGED
|
@@ -75,7 +75,10 @@ export type MountOrpcOptions<
|
|
|
75
75
|
plugins?: Plugin<InferRouterInitialContext<R>>[]
|
|
76
76
|
/** Rewrite request headers before they reach the context (see `normalizeExpoOrigin`). */
|
|
77
77
|
normalizeHeaders?: (headers: Headers) => Headers
|
|
78
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Time spent before the handler ran, reported as `queue`. Default: time
|
|
80
|
+
* since {@link markRequestArrival}, when the server marked the request.
|
|
81
|
+
*/
|
|
79
82
|
queueMs?: (request: Request) => number | undefined
|
|
80
83
|
/**
|
|
81
84
|
* Unexpected procedure errors (oRPC turns thrown errors into responses, so
|
|
@@ -86,6 +89,23 @@ export type MountOrpcOptions<
|
|
|
86
89
|
isExpectedError?: (error: unknown) => boolean
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
const arrivals = new WeakMap<Request, number>()
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Marks a request at the server's front door (`Bun.serve({ fetch })`), before
|
|
96
|
+
* routing and middleware. `mountOrpc` reports the time until the procedure
|
|
97
|
+
* runs as `queue_ms`: in-process queueing no handler timer can see.
|
|
98
|
+
*/
|
|
99
|
+
export function markRequestArrival(request: Request): void {
|
|
100
|
+
arrivals.set(request, performance.now())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Milliseconds since {@link markRequestArrival}, or undefined when unmarked. */
|
|
104
|
+
export function msSinceRequestArrival(request: Request): number | undefined {
|
|
105
|
+
const arrivedAt = arrivals.get(request)
|
|
106
|
+
return arrivedAt === undefined ? undefined : performance.now() - arrivedAt
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
/**
|
|
90
110
|
* Expo native sends `expo-origin` but not `origin`; copy it so cookie-based
|
|
91
111
|
* auth (e.g. Better Auth) sees the origin.
|
|
@@ -175,7 +195,7 @@ export function mountOrpc<E extends Env, R extends AnyRouter>(
|
|
|
175
195
|
) => {
|
|
176
196
|
app.use(`${prefix}/*`, async (c, next) => {
|
|
177
197
|
const timing: RequestTiming = {}
|
|
178
|
-
const queueMs = options.queueMs
|
|
198
|
+
const queueMs = (options.queueMs ?? msSinceRequestArrival)(c.req.raw)
|
|
179
199
|
if (queueMs !== undefined)
|
|
180
200
|
timing.queue_ms = Math.round(queueMs * 100) / 100
|
|
181
201
|
const started = performance.now()
|
package/src/live/fn-live.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ZodType, z } from 'zod'
|
|
|
4
4
|
import { RAW_INPUT } from '../timing.js'
|
|
5
5
|
import { LIVE_GAP, positiveInteger } from './lifecycle.js'
|
|
6
6
|
import type { FnLogger } from '../logger.js'
|
|
7
|
-
import { errorMessageOf
|
|
7
|
+
import { errorMessageOf } from '../otel.js'
|
|
8
8
|
import type {
|
|
9
9
|
FnContext,
|
|
10
10
|
FnDefinition,
|
|
@@ -100,8 +100,6 @@ export type FnLiveConfig<
|
|
|
100
100
|
}
|
|
101
101
|
) => MaybePromise<boolean>
|
|
102
102
|
authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
|
|
103
|
-
/** Log publish failures instead of throwing them into the publishing route. */
|
|
104
|
-
safePublish?: boolean
|
|
105
103
|
/** Batch events arriving within this window into one snapshot update. */
|
|
106
104
|
coalesceMs?: number
|
|
107
105
|
/** Maximum events collected in one coalescing batch (default 1000). */
|
|
@@ -441,7 +439,6 @@ type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
|
|
|
441
439
|
reauthorize?: AuthFn<Record<string, unknown>, unknown>
|
|
442
440
|
transformerFn?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
443
441
|
shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
444
|
-
safePublish?: boolean
|
|
445
442
|
coalesceMs?: number
|
|
446
443
|
maxBatchSize?: number
|
|
447
444
|
summary?: string
|
|
@@ -456,11 +453,9 @@ export function createFnLive(runtime: {
|
|
|
456
453
|
// biome-ignore lint/suspicious/noExplicitAny: runtime half of the typed `Fn`
|
|
457
454
|
fn: (options: any) => AnyProcedure
|
|
458
455
|
createChannel: (options: ChannelOptions) => Channel
|
|
459
|
-
createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
|
|
460
456
|
isGuard: (option: string) => boolean
|
|
461
457
|
}) {
|
|
462
458
|
const { fn, createChannel, isGuard } = runtime
|
|
463
|
-
const liveLogger = runtime.createLogger('fn-live', undefined)
|
|
464
459
|
|
|
465
460
|
return (
|
|
466
461
|
options: {
|
|
@@ -495,22 +490,8 @@ export function createFnLive(runtime: {
|
|
|
495
490
|
// `createChannel` reads only the channel keys of the live config.
|
|
496
491
|
const channel = createChannel({ ...live, name: liveName })
|
|
497
492
|
|
|
498
|
-
const publish = async (event: unknown) => {
|
|
499
|
-
if (!live.safePublish) return channel.publish(event)
|
|
500
|
-
try {
|
|
501
|
-
await channel.publish(event)
|
|
502
|
-
} catch (err) {
|
|
503
|
-
// Live update failures must not roll back the route that emitted them,
|
|
504
|
-
// but they should still be visible in logs.
|
|
505
|
-
liveLogger.error('fnLive publish failed', {
|
|
506
|
-
name: liveName,
|
|
507
|
-
err: errorMessageOf(err)
|
|
508
|
-
})
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
493
|
const runHandler = (params: RuntimeParams) =>
|
|
513
|
-
handler({ ...params, publish })
|
|
494
|
+
handler({ ...params, publish: channel.publish })
|
|
514
495
|
|
|
515
496
|
const procedure = fn({
|
|
516
497
|
...routeConfig,
|
|
@@ -640,7 +621,7 @@ export function createFnLive(runtime: {
|
|
|
640
621
|
return {
|
|
641
622
|
procedure,
|
|
642
623
|
subscribe,
|
|
643
|
-
publish,
|
|
624
|
+
publish: channel.publish,
|
|
644
625
|
getChannelName: channel.getChannelName,
|
|
645
626
|
getSubscriptionChannelName: channel.getSubscriptionChannelName,
|
|
646
627
|
getPublishChannelName: channel.getPublishChannelName
|
package/src/live/index.ts
CHANGED
|
@@ -8,6 +8,10 @@ export {
|
|
|
8
8
|
streamLiveSnapshots,
|
|
9
9
|
throwInitialSnapshotError
|
|
10
10
|
} from './fn-live.js'
|
|
11
|
+
export {
|
|
12
|
+
type InvalidationEvent,
|
|
13
|
+
invalidationStream
|
|
14
|
+
} from './invalidation-stream.js'
|
|
11
15
|
export {
|
|
12
16
|
type AuthFn,
|
|
13
17
|
type ChannelDefinition,
|
|
@@ -25,9 +29,16 @@ export {
|
|
|
25
29
|
type PubSubOptions,
|
|
26
30
|
type PubSubRuntimeOptions
|
|
27
31
|
} from './pub-sub.js'
|
|
32
|
+
export {
|
|
33
|
+
type ChangeBusChannel,
|
|
34
|
+
type ChangeBusScope,
|
|
35
|
+
defineScopedChangeBus,
|
|
36
|
+
type ScopedChangeBus
|
|
37
|
+
} from './scoped-change-bus.js'
|
|
28
38
|
export {
|
|
29
39
|
type BacklogOptions,
|
|
30
40
|
backlogKey,
|
|
31
41
|
type PubSubMessage,
|
|
32
|
-
type PubSubTransport
|
|
42
|
+
type PubSubTransport,
|
|
43
|
+
type PubSubTransportStats
|
|
33
44
|
} from './transport.js'
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyProcedure,
|
|
3
|
+
call,
|
|
4
|
+
type Procedure,
|
|
5
|
+
type Schema
|
|
6
|
+
} from '@orpc/server'
|
|
7
|
+
|
|
8
|
+
/** A streaming procedure taking no input, such as a `createPubSub` subscribe route. */
|
|
9
|
+
/* biome-ignore-start lint/suspicious/noExplicitAny: matches any context, input, errors and meta */
|
|
10
|
+
type StreamProcedure<TEvent> = Procedure<
|
|
11
|
+
any,
|
|
12
|
+
any,
|
|
13
|
+
any,
|
|
14
|
+
Schema<any, AsyncIterable<TEvent>>,
|
|
15
|
+
any,
|
|
16
|
+
any
|
|
17
|
+
>
|
|
18
|
+
/* biome-ignore-end lint/suspicious/noExplicitAny: matches any context, input, errors and meta */
|
|
19
|
+
|
|
20
|
+
/** One event of an invalidation stream: the domain that changed, plus whatever its mapper added. */
|
|
21
|
+
export type InvalidationEvent<
|
|
22
|
+
TEvents extends Record<string, unknown>,
|
|
23
|
+
TExtras extends { [K in keyof TEvents]?: object }
|
|
24
|
+
> = {
|
|
25
|
+
[K in keyof TEvents & string]: { domain: K } & (K extends keyof TExtras
|
|
26
|
+
? Partial<TExtras[K]>
|
|
27
|
+
: unknown)
|
|
28
|
+
}[keyof TEvents & string]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fans several change streams into one stream of `{ domain }` events, so a
|
|
32
|
+
* client keeps one connection and invalidates queries per domain. Use it as
|
|
33
|
+
* the body of a streaming `fn`:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* fn({
|
|
37
|
+
* name: 'realtime.changes',
|
|
38
|
+
* stream: true,
|
|
39
|
+
* input: z.object({}),
|
|
40
|
+
* handler: ({ context, signal }) =>
|
|
41
|
+
* invalidationStream({
|
|
42
|
+
* context,
|
|
43
|
+
* signal,
|
|
44
|
+
* sources: { deal: dealChanges.subscribe, workItem: workItemChanges.subscribe },
|
|
45
|
+
* // Optional: forward fields a client acts on (alerts), not just the domain.
|
|
46
|
+
* events: { deal: (event) => (event.kind === 'won' ? { dealId: event.dealId } : undefined) }
|
|
47
|
+
* })
|
|
48
|
+
* })
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* Each source is called with `{}`, the caller's context and a signal of its
|
|
52
|
+
* own. Events that queue up while the consumer is busy collapse when equal.
|
|
53
|
+
* When one source ends, the stream ends (a deploy drain); when one throws,
|
|
54
|
+
* the stream throws. Either way every source is closed and the client
|
|
55
|
+
* reconnects, which recreates them all.
|
|
56
|
+
*/
|
|
57
|
+
export async function* invalidationStream<
|
|
58
|
+
TEvents extends Record<string, unknown>,
|
|
59
|
+
TExtras extends { [K in keyof TEvents]?: object } = Record<never, never>
|
|
60
|
+
>(options: {
|
|
61
|
+
context: unknown
|
|
62
|
+
signal?: AbortSignal | undefined
|
|
63
|
+
sources: { [K in keyof TEvents]: StreamProcedure<TEvents[K]> }
|
|
64
|
+
events?: {
|
|
65
|
+
[K in keyof TExtras]: (
|
|
66
|
+
value: NoInfer<TEvents[K & keyof TEvents]>
|
|
67
|
+
) => TExtras[K] | undefined
|
|
68
|
+
}
|
|
69
|
+
}): AsyncGenerator<InvalidationEvent<TEvents, TExtras>, void, unknown> {
|
|
70
|
+
type Event = InvalidationEvent<TEvents, TExtras>
|
|
71
|
+
const controller = new AbortController()
|
|
72
|
+
const abort = () => controller.abort()
|
|
73
|
+
if (options.signal?.aborted) abort()
|
|
74
|
+
options.signal?.addEventListener('abort', abort, { once: true })
|
|
75
|
+
|
|
76
|
+
// Keyed by the serialized event: equal events collapse while queued.
|
|
77
|
+
const queue = new Map<string, Event>()
|
|
78
|
+
let wake: (() => void) | undefined
|
|
79
|
+
let ended = false
|
|
80
|
+
let failure: { error: unknown } | undefined
|
|
81
|
+
const stop = (error?: { error: unknown }) => {
|
|
82
|
+
if (ended) return
|
|
83
|
+
ended = true
|
|
84
|
+
failure = error
|
|
85
|
+
controller.abort()
|
|
86
|
+
wake?.()
|
|
87
|
+
}
|
|
88
|
+
controller.signal.addEventListener('abort', () => stop(), { once: true })
|
|
89
|
+
|
|
90
|
+
const mappers = (options.events ?? {}) as Record<
|
|
91
|
+
string,
|
|
92
|
+
((value: unknown) => object | undefined) | undefined
|
|
93
|
+
>
|
|
94
|
+
const pump = async (domain: string, procedure: AnyProcedure) => {
|
|
95
|
+
let iterator: AsyncIterator<unknown> | undefined
|
|
96
|
+
try {
|
|
97
|
+
// oRPC's `call`, not the handler's bound call: closing a source needs
|
|
98
|
+
// a signal of its own, because return() waits behind a pending next().
|
|
99
|
+
const stream = (await call(
|
|
100
|
+
procedure,
|
|
101
|
+
{},
|
|
102
|
+
{ context: options.context, signal: controller.signal }
|
|
103
|
+
)) as AsyncIterable<unknown>
|
|
104
|
+
iterator = stream[Symbol.asyncIterator]()
|
|
105
|
+
while (!ended) {
|
|
106
|
+
const result = await iterator.next()
|
|
107
|
+
if (result.done) return stop()
|
|
108
|
+
const event = { ...mappers[domain]?.(result.value), domain } as Event
|
|
109
|
+
queue.set(JSON.stringify(event), event)
|
|
110
|
+
wake?.()
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
stop({ error })
|
|
114
|
+
} finally {
|
|
115
|
+
await iterator?.return?.().catch(() => {})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const pumps = Object.entries(options.sources).map(([domain, procedure]) =>
|
|
119
|
+
pump(domain, procedure as AnyProcedure)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
while (true) {
|
|
124
|
+
const [next] = queue
|
|
125
|
+
if (next) {
|
|
126
|
+
queue.delete(next[0])
|
|
127
|
+
yield next[1]
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (ended) break
|
|
131
|
+
await new Promise<void>((resolve) => {
|
|
132
|
+
wake = resolve
|
|
133
|
+
})
|
|
134
|
+
wake = undefined
|
|
135
|
+
}
|
|
136
|
+
if (failure) throw failure.error
|
|
137
|
+
} finally {
|
|
138
|
+
stop()
|
|
139
|
+
options.signal?.removeEventListener('abort', abort)
|
|
140
|
+
await Promise.allSettled(pumps)
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/live/ioredis.ts
CHANGED
|
@@ -74,6 +74,8 @@ export function ioredisTransport(
|
|
|
74
74
|
ours.delete(channel)
|
|
75
75
|
return subscriber.unsubscribe(channel)
|
|
76
76
|
},
|
|
77
|
+
connected: () =>
|
|
78
|
+
subscriber.status === undefined || subscriber.status === 'ready',
|
|
77
79
|
close() {
|
|
78
80
|
// Detach first: a given subscriber outlives this transport's use.
|
|
79
81
|
subscriber.off('ready', onReady)
|
package/src/live/memory.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PubSubTransport } from './transport.js'
|
|
1
|
+
import type { PubSubTransport, PubSubTransportStats } from './transport.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* In-process transport for tests and single-process development. Delivery is
|
|
@@ -7,6 +7,7 @@ import type { PubSubTransport } from './transport.js'
|
|
|
7
7
|
export function memoryTransport(): PubSubTransport & {
|
|
8
8
|
/** Active listeners per channel, for assertions. */
|
|
9
9
|
listenerCount(channel: string): number
|
|
10
|
+
stats(): PubSubTransportStats
|
|
10
11
|
} {
|
|
11
12
|
const listeners = new Map<string, Set<(payload: string) => void>>()
|
|
12
13
|
const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
|
|
@@ -49,6 +50,14 @@ export function memoryTransport(): PubSubTransport & {
|
|
|
49
50
|
if (set.size === 0) listeners.delete(channel)
|
|
50
51
|
}
|
|
51
52
|
},
|
|
52
|
-
listenerCount: (channel) => listeners.get(channel)?.size ?? 0
|
|
53
|
+
listenerCount: (channel) => listeners.get(channel)?.size ?? 0,
|
|
54
|
+
stats: () => ({
|
|
55
|
+
channels: listeners.size,
|
|
56
|
+
listeners: [...listeners.values()].reduce(
|
|
57
|
+
(sum, set) => sum + set.size,
|
|
58
|
+
0
|
|
59
|
+
),
|
|
60
|
+
subscriberConnected: true
|
|
61
|
+
})
|
|
53
62
|
}
|
|
54
63
|
}
|
package/src/live/pub-sub.ts
CHANGED
|
@@ -84,6 +84,11 @@ export type PublisherConfig = {
|
|
|
84
84
|
* channel means no duplicate work.
|
|
85
85
|
*/
|
|
86
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
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
/** Per-definition overrides of the factory queue bounds. */
|
|
@@ -612,10 +617,25 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
612
617
|
}
|
|
613
618
|
})
|
|
614
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
|
+
|
|
615
635
|
return {
|
|
616
|
-
publish: (data: unknown) =>
|
|
636
|
+
publish: (data: unknown) => guarded('publish', [data]),
|
|
617
637
|
publishMany: async (items: readonly unknown[]) => {
|
|
618
|
-
if (items.length > 0) await
|
|
638
|
+
if (items.length > 0) await guarded('publishMany', items)
|
|
619
639
|
}
|
|
620
640
|
}
|
|
621
641
|
}
|
|
@@ -1205,6 +1225,7 @@ export function createLiveRuntime(options: LiveRuntimeOptions) {
|
|
|
1205
1225
|
backlogSize: _backlogSize,
|
|
1206
1226
|
backlogTtl: _backlogTtl,
|
|
1207
1227
|
mirrorChannel: _mirrorChannel,
|
|
1228
|
+
safePublish: _safePublish,
|
|
1208
1229
|
maxQueueSize: _maxQueueSize,
|
|
1209
1230
|
maxIngressSize: _maxIngressSize,
|
|
1210
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`
|