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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/dist/bound-call.d.ts +23 -0
  4. package/dist/bound-call.d.ts.map +1 -0
  5. package/dist/bound-call.js +23 -0
  6. package/dist/bound-call.js.map +1 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +49 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/create-fn.d.ts +97 -0
  12. package/dist/create-fn.d.ts.map +1 -0
  13. package/dist/create-fn.js +224 -0
  14. package/dist/create-fn.js.map +1 -0
  15. package/dist/expected-client-error.d.ts +11 -0
  16. package/dist/expected-client-error.d.ts.map +1 -0
  17. package/dist/expected-client-error.js +21 -0
  18. package/dist/expected-client-error.js.map +1 -0
  19. package/dist/expo.d.ts +66 -0
  20. package/dist/expo.d.ts.map +1 -0
  21. package/dist/expo.js +74 -0
  22. package/dist/expo.js.map +1 -0
  23. package/dist/hono.d.ts +81 -0
  24. package/dist/hono.d.ts.map +1 -0
  25. package/dist/hono.js +138 -0
  26. package/dist/hono.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/json-schema.d.ts +47 -0
  32. package/dist/json-schema.d.ts.map +1 -0
  33. package/dist/json-schema.js +44 -0
  34. package/dist/json-schema.js.map +1 -0
  35. package/dist/live/codec.d.ts +9 -0
  36. package/dist/live/codec.d.ts.map +1 -0
  37. package/dist/live/codec.js +27 -0
  38. package/dist/live/codec.js.map +1 -0
  39. package/dist/live/fn-live.d.ts +155 -0
  40. package/dist/live/fn-live.d.ts.map +1 -0
  41. package/dist/live/fn-live.js +210 -0
  42. package/dist/live/fn-live.js.map +1 -0
  43. package/dist/live/index.d.ts +4 -0
  44. package/dist/live/index.d.ts.map +1 -0
  45. package/dist/live/index.js +5 -0
  46. package/dist/live/index.js.map +1 -0
  47. package/dist/live/ioredis.d.ts +29 -0
  48. package/dist/live/ioredis.d.ts.map +1 -0
  49. package/dist/live/ioredis.js +49 -0
  50. package/dist/live/ioredis.js.map +1 -0
  51. package/dist/live/memory.d.ts +10 -0
  52. package/dist/live/memory.d.ts.map +1 -0
  53. package/dist/live/memory.js +45 -0
  54. package/dist/live/memory.js.map +1 -0
  55. package/dist/live/pub-sub.d.ts +211 -0
  56. package/dist/live/pub-sub.d.ts.map +1 -0
  57. package/dist/live/pub-sub.js +529 -0
  58. package/dist/live/pub-sub.js.map +1 -0
  59. package/dist/live/redis-bun.d.ts +26 -0
  60. package/dist/live/redis-bun.d.ts.map +1 -0
  61. package/dist/live/redis-bun.js +59 -0
  62. package/dist/live/redis-bun.js.map +1 -0
  63. package/dist/live/redis.d.ts +45 -0
  64. package/dist/live/redis.d.ts.map +1 -0
  65. package/dist/live/redis.js +214 -0
  66. package/dist/live/redis.js.map +1 -0
  67. package/dist/live/transport.d.ts +35 -0
  68. package/dist/live/transport.d.ts.map +1 -0
  69. package/dist/live/transport.js +2 -0
  70. package/dist/live/transport.js.map +1 -0
  71. package/dist/logger.d.ts +15 -0
  72. package/dist/logger.d.ts.map +1 -0
  73. package/dist/logger.js +15 -0
  74. package/dist/logger.js.map +1 -0
  75. package/dist/mastra.d.ts +44 -0
  76. package/dist/mastra.d.ts.map +1 -0
  77. package/dist/mastra.js +78 -0
  78. package/dist/mastra.js.map +1 -0
  79. package/dist/mcp.d.ts +39 -0
  80. package/dist/mcp.d.ts.map +1 -0
  81. package/dist/mcp.js +38 -0
  82. package/dist/mcp.js.map +1 -0
  83. package/dist/meta.d.ts +35 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +30 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/otel.d.ts +66 -0
  88. package/dist/otel.d.ts.map +1 -0
  89. package/dist/otel.js +63 -0
  90. package/dist/otel.js.map +1 -0
  91. package/dist/router.d.ts +13 -0
  92. package/dist/router.d.ts.map +1 -0
  93. package/dist/router.js +14 -0
  94. package/dist/router.js.map +1 -0
  95. package/dist/stream.d.ts +13 -0
  96. package/dist/stream.d.ts.map +1 -0
  97. package/dist/stream.js +44 -0
  98. package/dist/stream.js.map +1 -0
  99. package/dist/types.d.ts +196 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +29 -0
  102. package/dist/types.js.map +1 -0
  103. package/package.json +157 -0
  104. package/src/bound-call.ts +50 -0
  105. package/src/client.ts +82 -0
  106. package/src/create-fn.ts +461 -0
  107. package/src/expected-client-error.ts +23 -0
  108. package/src/expo.ts +127 -0
  109. package/src/hono.ts +247 -0
  110. package/src/index.ts +45 -0
  111. package/src/json-schema.ts +82 -0
  112. package/src/live/codec.ts +31 -0
  113. package/src/live/fn-live.ts +470 -0
  114. package/src/live/index.ts +31 -0
  115. package/src/live/ioredis.ts +74 -0
  116. package/src/live/memory.ts +50 -0
  117. package/src/live/pub-sub.ts +828 -0
  118. package/src/live/redis-bun.ts +76 -0
  119. package/src/live/redis.ts +255 -0
  120. package/src/live/transport.ts +41 -0
  121. package/src/logger.ts +28 -0
  122. package/src/mastra.ts +140 -0
  123. package/src/mcp.ts +73 -0
  124. package/src/meta.ts +72 -0
  125. package/src/otel.ts +151 -0
  126. package/src/router.ts +15 -0
  127. package/src/stream.ts +54 -0
  128. 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
+ }