orpc-fn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/bound-call.d.ts +23 -0
- package/dist/bound-call.d.ts.map +1 -0
- package/dist/bound-call.js +23 -0
- package/dist/bound-call.js.map +1 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -0
- package/dist/create-fn.d.ts +97 -0
- package/dist/create-fn.d.ts.map +1 -0
- package/dist/create-fn.js +224 -0
- package/dist/create-fn.js.map +1 -0
- package/dist/expected-client-error.d.ts +11 -0
- package/dist/expected-client-error.d.ts.map +1 -0
- package/dist/expected-client-error.js +21 -0
- package/dist/expected-client-error.js.map +1 -0
- package/dist/expo.d.ts +66 -0
- package/dist/expo.d.ts.map +1 -0
- package/dist/expo.js +74 -0
- package/dist/expo.js.map +1 -0
- package/dist/hono.d.ts +81 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +138 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/json-schema.d.ts +47 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +44 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/live/codec.d.ts +9 -0
- package/dist/live/codec.d.ts.map +1 -0
- package/dist/live/codec.js +27 -0
- package/dist/live/codec.js.map +1 -0
- package/dist/live/fn-live.d.ts +155 -0
- package/dist/live/fn-live.d.ts.map +1 -0
- package/dist/live/fn-live.js +210 -0
- package/dist/live/fn-live.js.map +1 -0
- package/dist/live/index.d.ts +4 -0
- package/dist/live/index.d.ts.map +1 -0
- package/dist/live/index.js +5 -0
- package/dist/live/index.js.map +1 -0
- package/dist/live/ioredis.d.ts +29 -0
- package/dist/live/ioredis.d.ts.map +1 -0
- package/dist/live/ioredis.js +49 -0
- package/dist/live/ioredis.js.map +1 -0
- package/dist/live/memory.d.ts +10 -0
- package/dist/live/memory.d.ts.map +1 -0
- package/dist/live/memory.js +45 -0
- package/dist/live/memory.js.map +1 -0
- package/dist/live/pub-sub.d.ts +211 -0
- package/dist/live/pub-sub.d.ts.map +1 -0
- package/dist/live/pub-sub.js +529 -0
- package/dist/live/pub-sub.js.map +1 -0
- package/dist/live/redis-bun.d.ts +26 -0
- package/dist/live/redis-bun.d.ts.map +1 -0
- package/dist/live/redis-bun.js +59 -0
- package/dist/live/redis-bun.js.map +1 -0
- package/dist/live/redis.d.ts +45 -0
- package/dist/live/redis.d.ts.map +1 -0
- package/dist/live/redis.js +214 -0
- package/dist/live/redis.js.map +1 -0
- package/dist/live/transport.d.ts +35 -0
- package/dist/live/transport.d.ts.map +1 -0
- package/dist/live/transport.js +2 -0
- package/dist/live/transport.js.map +1 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mastra.d.ts +44 -0
- package/dist/mastra.d.ts.map +1 -0
- package/dist/mastra.js +78 -0
- package/dist/mastra.js.map +1 -0
- package/dist/mcp.d.ts +39 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +38 -0
- package/dist/mcp.js.map +1 -0
- package/dist/meta.d.ts +35 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +30 -0
- package/dist/meta.js.map +1 -0
- package/dist/otel.d.ts +66 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +63 -0
- package/dist/otel.js.map +1 -0
- package/dist/router.d.ts +13 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +14 -0
- package/dist/router.js.map +1 -0
- package/dist/stream.d.ts +13 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +44 -0
- package/dist/stream.js.map +1 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/package.json +157 -0
- package/src/bound-call.ts +50 -0
- package/src/client.ts +82 -0
- package/src/create-fn.ts +461 -0
- package/src/expected-client-error.ts +23 -0
- package/src/expo.ts +127 -0
- package/src/hono.ts +247 -0
- package/src/index.ts +45 -0
- package/src/json-schema.ts +82 -0
- package/src/live/codec.ts +31 -0
- package/src/live/fn-live.ts +470 -0
- package/src/live/index.ts +31 -0
- package/src/live/ioredis.ts +74 -0
- package/src/live/memory.ts +50 -0
- package/src/live/pub-sub.ts +828 -0
- package/src/live/redis-bun.ts +76 -0
- package/src/live/redis.ts +255 -0
- package/src/live/transport.ts +41 -0
- package/src/logger.ts +28 -0
- package/src/mastra.ts +140 -0
- package/src/mcp.ts +73 -0
- package/src/meta.ts +72 -0
- package/src/otel.ts +151 -0
- package/src/router.ts +15 -0
- package/src/stream.ts +54 -0
- package/src/types.ts +389 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import type { Schema } from '@orpc/contract'
|
|
2
|
+
import { type AnyProcedure, ORPCError } from '@orpc/server'
|
|
3
|
+
import type { ZodType, z } from 'zod'
|
|
4
|
+
import type { FnLogger } from '../logger.js'
|
|
5
|
+
import { errorMessageOf, type SpanLike } from '../otel.js'
|
|
6
|
+
import type {
|
|
7
|
+
FnContext,
|
|
8
|
+
FnDefinition,
|
|
9
|
+
FnProcedure,
|
|
10
|
+
FnRouteOptions,
|
|
11
|
+
HandlerParams,
|
|
12
|
+
MaybePromise,
|
|
13
|
+
ProcedureKey
|
|
14
|
+
} from '../types.js'
|
|
15
|
+
import type {
|
|
16
|
+
AuthFn,
|
|
17
|
+
Channel,
|
|
18
|
+
ChannelDefinition,
|
|
19
|
+
ChannelOptions,
|
|
20
|
+
ObjectSchema,
|
|
21
|
+
PublisherConfig
|
|
22
|
+
} from './pub-sub.js'
|
|
23
|
+
|
|
24
|
+
/** Marks a coalesce window that ended before the next event arrived. */
|
|
25
|
+
const TIMEOUT = Symbol('timeout')
|
|
26
|
+
|
|
27
|
+
const fnLivePatchMarker = Symbol('fnLive.patch')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Transformer result that splits the internal accumulator from the wire
|
|
31
|
+
* payload: `state` becomes the `previous` snapshot for later events while only
|
|
32
|
+
* `emit` is sent to the subscriber. Lets high-frequency streams avoid
|
|
33
|
+
* re-serializing the full snapshot per event.
|
|
34
|
+
*/
|
|
35
|
+
export type FnLivePatch<TOutput, TEmit> = {
|
|
36
|
+
[fnLivePatchMarker]: true
|
|
37
|
+
state: TOutput
|
|
38
|
+
emit: TEmit
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fnLivePatch<TOutput, TEmit>(
|
|
42
|
+
state: TOutput,
|
|
43
|
+
emit: TEmit
|
|
44
|
+
): FnLivePatch<TOutput, TEmit> {
|
|
45
|
+
return { [fnLivePatchMarker]: true, state, emit }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isFnLivePatch(value: unknown): value is FnLivePatch<unknown, unknown> {
|
|
49
|
+
return (
|
|
50
|
+
typeof value === 'object' && value !== null && fnLivePatchMarker in value
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FnLiveConfig<
|
|
55
|
+
TDef extends FnDefinition,
|
|
56
|
+
TInput,
|
|
57
|
+
TEventSchema extends ZodType,
|
|
58
|
+
TOutput,
|
|
59
|
+
TKey extends ProcedureKey<TDef>,
|
|
60
|
+
TEmit = never
|
|
61
|
+
> = Omit<PublisherConfig, 'name'> & {
|
|
62
|
+
/** Subscribe route name; default `${name}.live`. */
|
|
63
|
+
name?: string
|
|
64
|
+
eventSchema: TEventSchema
|
|
65
|
+
channel: ChannelDefinition<
|
|
66
|
+
TInput,
|
|
67
|
+
z.output<TEventSchema>,
|
|
68
|
+
FnContext<TDef, TKey>
|
|
69
|
+
>
|
|
70
|
+
/** Fold an event into the snapshot; without it every event re-runs the handler. */
|
|
71
|
+
transformerFn?: (
|
|
72
|
+
params: HandlerParams<TDef, TInput, TKey> & {
|
|
73
|
+
event: z.output<TEventSchema>
|
|
74
|
+
previous: TOutput | undefined
|
|
75
|
+
rerun: (input?: TInput) => Promise<TOutput>
|
|
76
|
+
}
|
|
77
|
+
) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
|
|
78
|
+
/** Return false to skip an event. */
|
|
79
|
+
shouldUpdate?: (
|
|
80
|
+
params: HandlerParams<TDef, TInput, TKey> & {
|
|
81
|
+
event: z.output<TEventSchema>
|
|
82
|
+
previous: TOutput | undefined
|
|
83
|
+
}
|
|
84
|
+
) => MaybePromise<boolean>
|
|
85
|
+
authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
|
|
86
|
+
/** Log publish failures instead of throwing them into the publishing route. */
|
|
87
|
+
safePublish?: boolean
|
|
88
|
+
/** Batch events arriving within this window into one snapshot update. */
|
|
89
|
+
coalesceMs?: number
|
|
90
|
+
/**
|
|
91
|
+
* When a slow subscriber's queue overflows (drop-oldest), this marker is
|
|
92
|
+
* enqueued INSTEAD of the dropped event so the client resyncs. Return
|
|
93
|
+
* `null` to skip.
|
|
94
|
+
*/
|
|
95
|
+
overflowMarker?: (input: TInput) => z.output<TEventSchema> | null
|
|
96
|
+
summary?: string
|
|
97
|
+
description?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type FnLiveReturn<
|
|
101
|
+
TDef extends FnDefinition,
|
|
102
|
+
TKey extends ProcedureKey<TDef>,
|
|
103
|
+
TInput extends ObjectSchema,
|
|
104
|
+
TOutputSchema extends Schema<unknown, unknown>,
|
|
105
|
+
TOutput,
|
|
106
|
+
TEventSchema extends ZodType,
|
|
107
|
+
TEmit = never
|
|
108
|
+
> = {
|
|
109
|
+
procedure: FnProcedure<TDef, TKey, TInput, TOutputSchema>
|
|
110
|
+
subscribe: FnProcedure<
|
|
111
|
+
TDef,
|
|
112
|
+
TKey,
|
|
113
|
+
TInput,
|
|
114
|
+
Schema<AsyncGenerator<TOutput | TEmit>, AsyncGenerator<TOutput | TEmit>>
|
|
115
|
+
>
|
|
116
|
+
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
117
|
+
getChannelName: (
|
|
118
|
+
params: Partial<z.output<TInput>> | Partial<z.output<TEventSchema>>,
|
|
119
|
+
context?: FnContext<TDef, TKey>
|
|
120
|
+
) => string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A route plus a subscribe route that streams its result: the initial
|
|
125
|
+
* snapshot, then an update for every event published on the channel.
|
|
126
|
+
*/
|
|
127
|
+
export interface FnLive<TDef extends FnDefinition> {
|
|
128
|
+
<
|
|
129
|
+
TInput extends ObjectSchema,
|
|
130
|
+
TOutputSchema extends ZodType,
|
|
131
|
+
TEventSchema extends ZodType,
|
|
132
|
+
TKey extends ProcedureKey<TDef> = TDef['default'],
|
|
133
|
+
TEmit = never
|
|
134
|
+
>(
|
|
135
|
+
options: {
|
|
136
|
+
input: TInput
|
|
137
|
+
output: TOutputSchema
|
|
138
|
+
handler: (
|
|
139
|
+
params: HandlerParams<TDef, z.output<TInput>, NoInfer<TKey>> & {
|
|
140
|
+
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
141
|
+
}
|
|
142
|
+
// The output schema parses what the handler returns: its input type.
|
|
143
|
+
) => MaybePromise<z.input<TOutputSchema>>
|
|
144
|
+
live: FnLiveConfig<
|
|
145
|
+
TDef,
|
|
146
|
+
z.output<TInput>,
|
|
147
|
+
TEventSchema,
|
|
148
|
+
z.output<TOutputSchema>,
|
|
149
|
+
NoInfer<TKey>,
|
|
150
|
+
TEmit
|
|
151
|
+
>
|
|
152
|
+
} & FnRouteOptions<TDef, TKey>
|
|
153
|
+
): FnLiveReturn<
|
|
154
|
+
TDef,
|
|
155
|
+
TKey,
|
|
156
|
+
TInput,
|
|
157
|
+
TOutputSchema,
|
|
158
|
+
z.output<TOutputSchema>,
|
|
159
|
+
TEventSchema,
|
|
160
|
+
TEmit
|
|
161
|
+
>
|
|
162
|
+
|
|
163
|
+
<
|
|
164
|
+
TInput extends ObjectSchema,
|
|
165
|
+
TOutput,
|
|
166
|
+
TEventSchema extends ZodType,
|
|
167
|
+
TKey extends ProcedureKey<TDef> = TDef['default'],
|
|
168
|
+
TEmit = never
|
|
169
|
+
>(
|
|
170
|
+
options: {
|
|
171
|
+
input: TInput
|
|
172
|
+
output?: undefined
|
|
173
|
+
handler: (
|
|
174
|
+
params: HandlerParams<TDef, z.output<TInput>, NoInfer<TKey>> & {
|
|
175
|
+
publish: (event: z.input<TEventSchema>) => Promise<void>
|
|
176
|
+
}
|
|
177
|
+
) => MaybePromise<TOutput>
|
|
178
|
+
live: FnLiveConfig<
|
|
179
|
+
TDef,
|
|
180
|
+
z.output<TInput>,
|
|
181
|
+
TEventSchema,
|
|
182
|
+
TOutput,
|
|
183
|
+
NoInfer<TKey>,
|
|
184
|
+
TEmit
|
|
185
|
+
>
|
|
186
|
+
} & FnRouteOptions<TDef, TKey>
|
|
187
|
+
): FnLiveReturn<
|
|
188
|
+
TDef,
|
|
189
|
+
TKey,
|
|
190
|
+
TInput,
|
|
191
|
+
Schema<TOutput, TOutput>,
|
|
192
|
+
TOutput,
|
|
193
|
+
TEventSchema,
|
|
194
|
+
TEmit
|
|
195
|
+
>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fail a live subscription whose initial snapshot could not be built.
|
|
200
|
+
*
|
|
201
|
+
* An expected client failure - an `ORPCError` with a 4xx status, e.g. the
|
|
202
|
+
* subscribed resource was not found or access was refused - reaches the
|
|
203
|
+
* subscriber unchanged and is NOT logged as a server fault. Only 5xx and
|
|
204
|
+
* unknown failures are logged. Unknown errors are wrapped as
|
|
205
|
+
* `INTERNAL_SERVER_ERROR`.
|
|
206
|
+
*/
|
|
207
|
+
export function throwInitialSnapshotError(
|
|
208
|
+
err: unknown,
|
|
209
|
+
liveName: string,
|
|
210
|
+
logger: Pick<FnLogger, 'error'>,
|
|
211
|
+
/** `createFn`'s `isExpectedError`; default: a 4xx `ORPCError`. */
|
|
212
|
+
isExpected: (error: unknown) => boolean = (error) =>
|
|
213
|
+
error instanceof ORPCError && error.status < 500
|
|
214
|
+
): never {
|
|
215
|
+
if (!isExpected(err)) {
|
|
216
|
+
logger.error('fnLive initial snapshot failed', {
|
|
217
|
+
name: liveName,
|
|
218
|
+
err: errorMessageOf(err)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
throw toSnapshotError(err, liveName)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** An `ORPCError` passes through; anything else becomes INTERNAL_SERVER_ERROR. */
|
|
225
|
+
function toSnapshotError(
|
|
226
|
+
err: unknown,
|
|
227
|
+
liveName: string
|
|
228
|
+
): ORPCError<string, unknown> {
|
|
229
|
+
if (err instanceof ORPCError) return err
|
|
230
|
+
return new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
231
|
+
message: `Failed to load initial snapshot for ${liveName}`,
|
|
232
|
+
cause: err
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Drives a live subscription: yields the initial snapshot, then folds each
|
|
238
|
+
* inbound event into the running snapshot via `apply`, yielding after every
|
|
239
|
+
* change. With `coalesceMs`, events arriving within that window are batched so
|
|
240
|
+
* bursty streams apply together.
|
|
241
|
+
*
|
|
242
|
+
* `apply` may return an {@link FnLivePatch} to keep the full snapshot as the
|
|
243
|
+
* internal accumulator while yielding only the lighter `emit` payload.
|
|
244
|
+
*
|
|
245
|
+
* Invariant: there is never more than one outstanding `source.next()` read. On
|
|
246
|
+
* a coalesce-window timeout the still-pending read is stashed in `pending` and
|
|
247
|
+
* reused by the next `readNext()` - issuing a second concurrent read would
|
|
248
|
+
* strand the in-flight event behind the next one (delivering it "one behind").
|
|
249
|
+
*/
|
|
250
|
+
export async function* streamLiveSnapshots<
|
|
251
|
+
TEvent,
|
|
252
|
+
TOutput,
|
|
253
|
+
TEmit = never
|
|
254
|
+
>(opts: {
|
|
255
|
+
source: AsyncIterator<TEvent>
|
|
256
|
+
initial: TOutput
|
|
257
|
+
coalesceMs?: number | undefined
|
|
258
|
+
apply: (
|
|
259
|
+
event: TEvent,
|
|
260
|
+
previous: TOutput
|
|
261
|
+
) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
|
|
262
|
+
}): AsyncGenerator<TOutput | TEmit> {
|
|
263
|
+
const { source, initial, coalesceMs, apply } = opts
|
|
264
|
+
|
|
265
|
+
let pending: Promise<IteratorResult<TEvent>> | null = null
|
|
266
|
+
const readNext = () => {
|
|
267
|
+
const read = pending ?? source.next()
|
|
268
|
+
pending = null
|
|
269
|
+
return read
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let previous = initial
|
|
273
|
+
yield previous
|
|
274
|
+
|
|
275
|
+
while (true) {
|
|
276
|
+
const current = await readNext()
|
|
277
|
+
if (current.done) break
|
|
278
|
+
|
|
279
|
+
const batch: TEvent[] = [current.value]
|
|
280
|
+
|
|
281
|
+
if (coalesceMs) {
|
|
282
|
+
const endAt = Date.now() + coalesceMs
|
|
283
|
+
while (Date.now() < endAt) {
|
|
284
|
+
const remainingMs = endAt - Date.now()
|
|
285
|
+
const read = readNext()
|
|
286
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
287
|
+
const result = await Promise.race([
|
|
288
|
+
read,
|
|
289
|
+
new Promise<typeof TIMEOUT>((resolve) => {
|
|
290
|
+
timer = setTimeout(resolve, remainingMs, TIMEOUT)
|
|
291
|
+
})
|
|
292
|
+
])
|
|
293
|
+
clearTimeout(timer)
|
|
294
|
+
|
|
295
|
+
if (result === TIMEOUT) {
|
|
296
|
+
// Reuse the SAME in-flight read next iteration instead of starting
|
|
297
|
+
// a new one - this is what prevents the "one behind" stranding.
|
|
298
|
+
pending = read
|
|
299
|
+
break
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (result.done) {
|
|
303
|
+
pending = Promise.resolve(result)
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
batch.push(result.value)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const event of batch) {
|
|
311
|
+
const next = await apply(event, previous)
|
|
312
|
+
if (next === undefined) continue
|
|
313
|
+
if (isFnLivePatch(next)) {
|
|
314
|
+
previous = next.state as TOutput
|
|
315
|
+
yield next.emit as TEmit
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
previous = next
|
|
319
|
+
yield previous
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type RuntimeParams = Record<string, unknown> & {
|
|
325
|
+
input: Record<string, unknown>
|
|
326
|
+
context: unknown
|
|
327
|
+
signal: AbortSignal | undefined
|
|
328
|
+
logger: FnLogger
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `FnLiveConfig` with the generics erased: what the implementation reads. */
|
|
332
|
+
type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
|
|
333
|
+
name?: string
|
|
334
|
+
transformerFn?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
335
|
+
shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
|
|
336
|
+
safePublish?: boolean
|
|
337
|
+
coalesceMs?: number
|
|
338
|
+
summary?: string
|
|
339
|
+
description?: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @internal Wires `fnLive` onto an `fn` and channel runtime of the same
|
|
344
|
+
* instance. `createFn` returns it typed as {@link FnLive}.
|
|
345
|
+
*/
|
|
346
|
+
export function createFnLive(runtime: {
|
|
347
|
+
// biome-ignore lint/suspicious/noExplicitAny: runtime half of the typed `Fn`
|
|
348
|
+
fn: (options: any) => AnyProcedure
|
|
349
|
+
createChannel: (options: ChannelOptions) => Channel
|
|
350
|
+
createLogger: (scope: string, span: SpanLike | undefined) => FnLogger
|
|
351
|
+
isGuard: (option: string) => boolean
|
|
352
|
+
}) {
|
|
353
|
+
const { fn, createChannel, isGuard } = runtime
|
|
354
|
+
const liveLogger = runtime.createLogger('fn-live', undefined)
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
options: {
|
|
358
|
+
live: RuntimeLiveConfig
|
|
359
|
+
handler: (params: RuntimeParams) => unknown
|
|
360
|
+
input: ZodType
|
|
361
|
+
output?: ZodType
|
|
362
|
+
name: string
|
|
363
|
+
procedure?: string
|
|
364
|
+
summary?: string
|
|
365
|
+
description?: string
|
|
366
|
+
tags?: string[]
|
|
367
|
+
} & Record<string, unknown>
|
|
368
|
+
) => {
|
|
369
|
+
const { live, handler, input, output, name, ...routeConfig } = options
|
|
370
|
+
const liveName = live.name ?? `${name}.live`
|
|
371
|
+
const liveSummary =
|
|
372
|
+
live.summary ?? `Live updates of ${routeConfig.summary ?? name}`
|
|
373
|
+
const liveDescription = live.description ?? routeConfig.description
|
|
374
|
+
|
|
375
|
+
// `createChannel` reads only the channel keys of the live config.
|
|
376
|
+
const channel = createChannel({ ...live, name: liveName })
|
|
377
|
+
|
|
378
|
+
const publish = async (event: unknown) => {
|
|
379
|
+
if (!live.safePublish) return channel.publish(event)
|
|
380
|
+
try {
|
|
381
|
+
await channel.publish(event)
|
|
382
|
+
} catch (err) {
|
|
383
|
+
// Live update failures must not roll back the route that emitted them,
|
|
384
|
+
// but they should still be visible in logs.
|
|
385
|
+
liveLogger.error('fnLive publish failed', {
|
|
386
|
+
name: liveName,
|
|
387
|
+
err: errorMessageOf(err)
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const runHandler = (params: RuntimeParams) =>
|
|
393
|
+
handler({ ...params, publish })
|
|
394
|
+
// Snapshots go through the output schema like the route's own result.
|
|
395
|
+
const runSnapshot = async (params: RuntimeParams) => {
|
|
396
|
+
const result = await runHandler(params)
|
|
397
|
+
return output ? output.parseAsync(result) : result
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const procedure = fn({
|
|
401
|
+
...routeConfig,
|
|
402
|
+
name,
|
|
403
|
+
input,
|
|
404
|
+
output,
|
|
405
|
+
handler: runHandler
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// Guards run on the subscribe route too; route meta stays on `procedure`.
|
|
409
|
+
const guardOptions = Object.fromEntries(
|
|
410
|
+
Object.entries(routeConfig).filter(([option]) => isGuard(option))
|
|
411
|
+
)
|
|
412
|
+
const subscribe = fn({
|
|
413
|
+
...guardOptions,
|
|
414
|
+
procedure: routeConfig.procedure,
|
|
415
|
+
tags: routeConfig.tags,
|
|
416
|
+
name: liveName,
|
|
417
|
+
method: 'GET',
|
|
418
|
+
input,
|
|
419
|
+
summary: liveSummary,
|
|
420
|
+
description: liveDescription,
|
|
421
|
+
handler: async function* (params: RuntimeParams) {
|
|
422
|
+
// Authorize and subscribe before the snapshot: a refused subscriber
|
|
423
|
+
// gets nothing, and events published while it loads are queued.
|
|
424
|
+
const subscription = await channel.open({
|
|
425
|
+
input: params.input,
|
|
426
|
+
context: params.context,
|
|
427
|
+
signal: params.signal
|
|
428
|
+
})
|
|
429
|
+
try {
|
|
430
|
+
let initial: unknown
|
|
431
|
+
try {
|
|
432
|
+
initial = await runSnapshot(params)
|
|
433
|
+
} catch (err) {
|
|
434
|
+
// `fn.completed` logs it, at the level `isExpectedError` picks.
|
|
435
|
+
throw toSnapshotError(err, liveName)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const rerun = (nextInput?: Record<string, unknown>) =>
|
|
439
|
+
runSnapshot({ ...params, input: nextInput ?? params.input })
|
|
440
|
+
const apply = async (event: unknown, previous: unknown) => {
|
|
441
|
+
if (
|
|
442
|
+
live.shouldUpdate &&
|
|
443
|
+
!(await live.shouldUpdate({ ...params, event, previous }))
|
|
444
|
+
) {
|
|
445
|
+
return undefined
|
|
446
|
+
}
|
|
447
|
+
if (!live.transformerFn) return runSnapshot(params)
|
|
448
|
+
return live.transformerFn({ ...params, event, previous, rerun })
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
yield* streamLiveSnapshots({
|
|
452
|
+
source: subscription.events,
|
|
453
|
+
initial,
|
|
454
|
+
coalesceMs: live.coalesceMs,
|
|
455
|
+
apply
|
|
456
|
+
})
|
|
457
|
+
} finally {
|
|
458
|
+
subscription.close()
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
procedure,
|
|
465
|
+
subscribe,
|
|
466
|
+
publish,
|
|
467
|
+
getChannelName: channel.getChannelName
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `fnLive`, `createPubSub` and `createPublisher` come typed from `createFn`.
|
|
2
|
+
export {
|
|
3
|
+
type FnLive,
|
|
4
|
+
type FnLiveConfig,
|
|
5
|
+
type FnLivePatch,
|
|
6
|
+
type FnLiveReturn,
|
|
7
|
+
fnLivePatch,
|
|
8
|
+
streamLiveSnapshots,
|
|
9
|
+
throwInitialSnapshotError
|
|
10
|
+
} from './fn-live.js'
|
|
11
|
+
export {
|
|
12
|
+
type AuthFn,
|
|
13
|
+
type ChannelDefinition,
|
|
14
|
+
type CreatePublisher,
|
|
15
|
+
type CreatePubSub,
|
|
16
|
+
createBoundedEventQueue,
|
|
17
|
+
type FilterFn,
|
|
18
|
+
type ObjectSchema,
|
|
19
|
+
type Publisher,
|
|
20
|
+
type PublisherConfig,
|
|
21
|
+
type PublisherOptions,
|
|
22
|
+
type PubSub,
|
|
23
|
+
type PubSubOptions,
|
|
24
|
+
type PubSubRuntimeOptions
|
|
25
|
+
} from './pub-sub.js'
|
|
26
|
+
export {
|
|
27
|
+
type BacklogOptions,
|
|
28
|
+
backlogKey,
|
|
29
|
+
type PubSubMessage,
|
|
30
|
+
type PubSubTransport
|
|
31
|
+
} from './transport.js'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createRedisTransport } from './redis.js'
|
|
2
|
+
|
|
3
|
+
/** The part of an ioredis `Redis` client this transport uses. */
|
|
4
|
+
export interface IORedisLike {
|
|
5
|
+
call(command: string, ...args: string[]): Promise<unknown>
|
|
6
|
+
subscribe(...channels: string[]): Promise<unknown>
|
|
7
|
+
unsubscribe(...channels: string[]): Promise<unknown>
|
|
8
|
+
duplicate(): IORedisLike
|
|
9
|
+
disconnect(): void
|
|
10
|
+
on(
|
|
11
|
+
event: 'message',
|
|
12
|
+
listener: (channel: string, message: string) => void
|
|
13
|
+
): unknown
|
|
14
|
+
on(event: 'end', listener: () => void): unknown
|
|
15
|
+
on(event: 'error', listener: (error: Error) => void): unknown
|
|
16
|
+
off(
|
|
17
|
+
event: 'message',
|
|
18
|
+
listener: (channel: string, message: string) => void
|
|
19
|
+
): unknown
|
|
20
|
+
off(event: 'end', listener: () => void): unknown
|
|
21
|
+
off(event: 'error', listener: (error: Error) => void): unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `PubSubTransport` for ioredis. Subscriptions use a dedicated connection
|
|
26
|
+
* (`client.duplicate()` unless `subscriber` is given); ioredis re-subscribes
|
|
27
|
+
* after reconnects itself (`autoResubscribe`), so no `resubscribe` is needed. `onError` receives connection
|
|
28
|
+
* errors that ioredis is already retrying.
|
|
29
|
+
*
|
|
30
|
+
* Call `close()` on shutdown to disconnect the subscriber it created.
|
|
31
|
+
*/
|
|
32
|
+
export function ioredisTransport(
|
|
33
|
+
client: IORedisLike,
|
|
34
|
+
options: { subscriber?: IORedisLike; onError?: (error: Error) => void } = {}
|
|
35
|
+
) {
|
|
36
|
+
return createRedisTransport({
|
|
37
|
+
send: (command, args) => client.call(command, ...args),
|
|
38
|
+
async connectSubscriber(handlers) {
|
|
39
|
+
const owned = !options.subscriber
|
|
40
|
+
const subscriber = options.subscriber ?? client.duplicate()
|
|
41
|
+
const onMessage = (channel: string, message: string) =>
|
|
42
|
+
handlers.onMessage(channel, message)
|
|
43
|
+
// Without a listener an ioredis 'error' event would crash the process.
|
|
44
|
+
const onError = (error: Error) => options.onError?.(error)
|
|
45
|
+
// 'end' means ioredis stopped reconnecting.
|
|
46
|
+
const onEnd = () =>
|
|
47
|
+
handlers.onLost(new Error('Redis subscriber connection ended'))
|
|
48
|
+
const ours = new Set<string>()
|
|
49
|
+
subscriber.on('message', onMessage)
|
|
50
|
+
subscriber.on('error', onError)
|
|
51
|
+
subscriber.on('end', onEnd)
|
|
52
|
+
return {
|
|
53
|
+
subscribe: (channel) => {
|
|
54
|
+
ours.add(channel)
|
|
55
|
+
return subscriber.subscribe(channel)
|
|
56
|
+
},
|
|
57
|
+
unsubscribe: (channel) => {
|
|
58
|
+
ours.delete(channel)
|
|
59
|
+
return subscriber.unsubscribe(channel)
|
|
60
|
+
},
|
|
61
|
+
close() {
|
|
62
|
+
// Detach first: a given subscriber outlives this transport's use.
|
|
63
|
+
subscriber.off('message', onMessage)
|
|
64
|
+
subscriber.off('end', onEnd)
|
|
65
|
+
if (owned) return subscriber.disconnect()
|
|
66
|
+
subscriber.off('error', onError)
|
|
67
|
+
if (ours.size > 0) {
|
|
68
|
+
void subscriber.unsubscribe(...ours).catch(() => {})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PubSubTransport } from './transport.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-process transport for tests and single-process development. Delivery is
|
|
5
|
+
* asynchronous (a microtask), like a real broker.
|
|
6
|
+
*/
|
|
7
|
+
export function memoryTransport(): PubSubTransport & {
|
|
8
|
+
/** Active listeners per channel, for assertions. */
|
|
9
|
+
listenerCount(channel: string): number
|
|
10
|
+
} {
|
|
11
|
+
const listeners = new Map<string, Set<(payload: string) => void>>()
|
|
12
|
+
const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
async publish(messages, backlog) {
|
|
16
|
+
for (const { channel, payload } of messages) {
|
|
17
|
+
if (backlog) {
|
|
18
|
+
const current = backlogs.get(channel)
|
|
19
|
+
const items =
|
|
20
|
+
current && current.expiresAt > Date.now() ? current.items : []
|
|
21
|
+
items.push(payload)
|
|
22
|
+
items.splice(0, Math.max(0, items.length - backlog.size))
|
|
23
|
+
backlogs.set(channel, {
|
|
24
|
+
items,
|
|
25
|
+
expiresAt: Date.now() + backlog.ttlSeconds * 1000
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
for (const listener of [...(listeners.get(channel) ?? [])]) {
|
|
29
|
+
queueMicrotask(() => listener(payload))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async readBacklog(channel) {
|
|
34
|
+
const backlog = backlogs.get(channel)
|
|
35
|
+
return backlog && backlog.expiresAt > Date.now() ? [...backlog.items] : []
|
|
36
|
+
},
|
|
37
|
+
async subscribe(channel, listener) {
|
|
38
|
+
const set = listeners.get(channel) ?? new Set()
|
|
39
|
+
listeners.set(channel, set)
|
|
40
|
+
// Wrap so the same listener can subscribe twice and unsubscribe once.
|
|
41
|
+
const entry = (payload: string) => listener(payload)
|
|
42
|
+
set.add(entry)
|
|
43
|
+
return async () => {
|
|
44
|
+
set.delete(entry)
|
|
45
|
+
if (set.size === 0) listeners.delete(channel)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
listenerCount: (channel) => listeners.get(channel)?.size ?? 0
|
|
49
|
+
}
|
|
50
|
+
}
|