@furystack/cross-node-bus 1.0.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 (83) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +49 -0
  3. package/esm/cross-node-bus-telemetry.d.ts +74 -0
  4. package/esm/cross-node-bus-telemetry.d.ts.map +1 -0
  5. package/esm/cross-node-bus-telemetry.js +28 -0
  6. package/esm/cross-node-bus-telemetry.js.map +1 -0
  7. package/esm/cross-node-bus-telemetry.spec.d.ts +2 -0
  8. package/esm/cross-node-bus-telemetry.spec.d.ts.map +1 -0
  9. package/esm/cross-node-bus-telemetry.spec.js +115 -0
  10. package/esm/cross-node-bus-telemetry.spec.js.map +1 -0
  11. package/esm/cross-node-bus.d.ts +78 -0
  12. package/esm/cross-node-bus.d.ts.map +1 -0
  13. package/esm/cross-node-bus.js +18 -0
  14. package/esm/cross-node-bus.js.map +1 -0
  15. package/esm/cross-node-bus.spec.d.ts +2 -0
  16. package/esm/cross-node-bus.spec.d.ts.map +1 -0
  17. package/esm/cross-node-bus.spec.js +123 -0
  18. package/esm/cross-node-bus.spec.js.map +1 -0
  19. package/esm/define-in-process-cross-node-bus.d.ts +26 -0
  20. package/esm/define-in-process-cross-node-bus.d.ts.map +1 -0
  21. package/esm/define-in-process-cross-node-bus.js +27 -0
  22. package/esm/define-in-process-cross-node-bus.js.map +1 -0
  23. package/esm/errors.d.ts +21 -0
  24. package/esm/errors.d.ts.map +1 -0
  25. package/esm/errors.js +30 -0
  26. package/esm/errors.js.map +1 -0
  27. package/esm/errors.spec.d.ts +2 -0
  28. package/esm/errors.spec.d.ts.map +1 -0
  29. package/esm/errors.spec.js +30 -0
  30. package/esm/errors.spec.js.map +1 -0
  31. package/esm/in-process-cross-node-bus.d.ts +58 -0
  32. package/esm/in-process-cross-node-bus.d.ts.map +1 -0
  33. package/esm/in-process-cross-node-bus.js +196 -0
  34. package/esm/in-process-cross-node-bus.js.map +1 -0
  35. package/esm/in-process-cross-node-bus.spec.d.ts +2 -0
  36. package/esm/in-process-cross-node-bus.spec.d.ts.map +1 -0
  37. package/esm/in-process-cross-node-bus.spec.js +737 -0
  38. package/esm/in-process-cross-node-bus.spec.js.map +1 -0
  39. package/esm/index.d.ts +8 -0
  40. package/esm/index.d.ts.map +1 -0
  41. package/esm/index.js +7 -0
  42. package/esm/index.js.map +1 -0
  43. package/esm/memory-broker.d.ts +74 -0
  44. package/esm/memory-broker.d.ts.map +1 -0
  45. package/esm/memory-broker.js +156 -0
  46. package/esm/memory-broker.js.map +1 -0
  47. package/esm/memory-broker.spec.d.ts +2 -0
  48. package/esm/memory-broker.spec.d.ts.map +1 -0
  49. package/esm/memory-broker.spec.js +497 -0
  50. package/esm/memory-broker.spec.js.map +1 -0
  51. package/esm/testing/create-in-process-bus-network.d.ts +49 -0
  52. package/esm/testing/create-in-process-bus-network.d.ts.map +1 -0
  53. package/esm/testing/create-in-process-bus-network.js +54 -0
  54. package/esm/testing/create-in-process-bus-network.js.map +1 -0
  55. package/esm/testing/create-in-process-bus-network.spec.d.ts +2 -0
  56. package/esm/testing/create-in-process-bus-network.spec.d.ts.map +1 -0
  57. package/esm/testing/create-in-process-bus-network.spec.js +142 -0
  58. package/esm/testing/create-in-process-bus-network.spec.js.map +1 -0
  59. package/esm/testing/index.d.ts +2 -0
  60. package/esm/testing/index.d.ts.map +1 -0
  61. package/esm/testing/index.js +2 -0
  62. package/esm/testing/index.js.map +1 -0
  63. package/esm/types.d.ts +35 -0
  64. package/esm/types.d.ts.map +1 -0
  65. package/esm/types.js +2 -0
  66. package/esm/types.js.map +1 -0
  67. package/package.json +56 -0
  68. package/src/cross-node-bus-telemetry.spec.ts +44 -0
  69. package/src/cross-node-bus-telemetry.ts +69 -0
  70. package/src/cross-node-bus.spec.ts +41 -0
  71. package/src/cross-node-bus.ts +92 -0
  72. package/src/define-in-process-cross-node-bus.ts +38 -0
  73. package/src/errors.spec.ts +32 -0
  74. package/src/errors.ts +38 -0
  75. package/src/in-process-cross-node-bus.spec.ts +428 -0
  76. package/src/in-process-cross-node-bus.ts +248 -0
  77. package/src/index.ts +7 -0
  78. package/src/memory-broker.spec.ts +282 -0
  79. package/src/memory-broker.ts +199 -0
  80. package/src/testing/create-in-process-bus-network.spec.ts +73 -0
  81. package/src/testing/create-in-process-bus-network.ts +87 -0
  82. package/src/testing/index.ts +1 -0
  83. package/src/types.ts +35 -0
@@ -0,0 +1,428 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { CrossNodeBusTelemetry } from './cross-node-bus-telemetry.js'
3
+ import { ReplayWindowExceededError } from './errors.js'
4
+ import { InProcessCrossNodeBus } from './in-process-cross-node-bus.js'
5
+ import { MemoryBroker } from './memory-broker.js'
6
+ import type { BusMessage } from './types.js'
7
+
8
+ const collect = async (iterable: AsyncIterable<BusMessage>): Promise<BusMessage[]> => {
9
+ const out: BusMessage[] = []
10
+ for await (const message of iterable) out.push(message)
11
+ return out
12
+ }
13
+
14
+ describe('InProcessCrossNodeBus', () => {
15
+ describe('basics', () => {
16
+ it('exposes a non-empty nodeId by default', () => {
17
+ using bus = new InProcessCrossNodeBus()
18
+ expect(typeof bus.nodeId).toBe('string')
19
+ expect(bus.nodeId.length).toBeGreaterThan(0)
20
+ expect(bus.nodeId.startsWith('local-')).toBe(true)
21
+ })
22
+
23
+ it('honors a caller-supplied nodeId', () => {
24
+ using bus = new InProcessCrossNodeBus({ nodeId: 'fixed' })
25
+ expect(bus.nodeId).toBe('fixed')
26
+ })
27
+
28
+ it('declares replay + assignsSequence capabilities and non-persistent', () => {
29
+ using bus = new InProcessCrossNodeBus()
30
+ expect(bus.capabilities).toEqual({ persistent: false, replay: true, assignsSequence: true })
31
+ })
32
+ })
33
+
34
+ describe('publish + subscribe', () => {
35
+ it('delivers self-published messages by default', async () => {
36
+ using bus = new InProcessCrossNodeBus({ nodeId: 'self' })
37
+ const handler = vi.fn()
38
+ using _sub = bus.subscribe('topic', handler)
39
+
40
+ await bus.publish('topic', { n: 1 })
41
+
42
+ expect(handler).toHaveBeenCalledTimes(1)
43
+ const message = handler.mock.calls[0]?.[0] as BusMessage
44
+ expect(message.originId).toBe('self')
45
+ expect(message.payload).toEqual({ n: 1 })
46
+ expect(message.seq).toBe('1')
47
+ })
48
+
49
+ it('multiplexes a single broker subscription across local handlers', async () => {
50
+ const broker = new MemoryBroker()
51
+ const brokerSubscribe = vi.spyOn(broker, 'subscribe')
52
+ try {
53
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'self' })
54
+ using _a = bus.subscribe('topic', () => undefined)
55
+ using _b = bus.subscribe('topic', () => undefined)
56
+ using _c = bus.subscribe('topic', () => undefined)
57
+
58
+ expect(brokerSubscribe).toHaveBeenCalledTimes(1)
59
+ } finally {
60
+ broker[Symbol.dispose]()
61
+ }
62
+ })
63
+
64
+ it('releases the broker subscription when the last local handler disposes', async () => {
65
+ using broker = new MemoryBroker()
66
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'self' })
67
+ const handler = vi.fn()
68
+
69
+ const sub = bus.subscribe('topic', handler)
70
+ sub[Symbol.dispose]()
71
+ await bus.publish('topic', null)
72
+ expect(handler).not.toHaveBeenCalled()
73
+
74
+ const handler2 = vi.fn()
75
+ using _sub2 = bus.subscribe('topic', handler2)
76
+ await bus.publish('topic', null)
77
+ expect(handler2).toHaveBeenCalledTimes(1)
78
+ })
79
+
80
+ it('isolates one local handler error from another and from the bus', async () => {
81
+ using bus = new InProcessCrossNodeBus({ nodeId: 'self' })
82
+ const ok = vi.fn()
83
+ using _failing = bus.subscribe('topic', () => {
84
+ throw new Error('boom')
85
+ })
86
+ using _ok = bus.subscribe('topic', ok)
87
+
88
+ await expect(bus.publish('topic', null)).resolves.toBeUndefined()
89
+ expect(ok).toHaveBeenCalledTimes(1)
90
+ })
91
+
92
+ it('subscribeRemoteOnly skips messages originating on this node', async () => {
93
+ using broker = new MemoryBroker()
94
+ using busA = new InProcessCrossNodeBus({ broker, nodeId: 'a' })
95
+ using busB = new InProcessCrossNodeBus({ broker, nodeId: 'b' })
96
+ const handler = vi.fn()
97
+ using _sub = busA.subscribeRemoteOnly('topic', handler)
98
+
99
+ await busA.publish('topic', { from: 'a' })
100
+ await busB.publish('topic', { from: 'b' })
101
+
102
+ expect(handler).toHaveBeenCalledTimes(1)
103
+ expect((handler.mock.calls[0]?.[0] as BusMessage).originId).toBe('b')
104
+ })
105
+ })
106
+
107
+ describe('topicPrefix + subscribeForeign', () => {
108
+ it('prefixes topics on the wire and isolates buses with different prefixes', async () => {
109
+ using broker = new MemoryBroker()
110
+ using svcA = new InProcessCrossNodeBus({ broker, nodeId: 'a', topicPrefix: 'svc-a/' })
111
+ using svcB = new InProcessCrossNodeBus({ broker, nodeId: 'b', topicPrefix: 'svc-b/' })
112
+ const onA = vi.fn()
113
+ const onB = vi.fn()
114
+ using _subA = svcA.subscribe('events', onA)
115
+ using _subB = svcB.subscribe('events', onB)
116
+
117
+ await svcA.publish('events', { from: 'a' })
118
+
119
+ expect(onA).toHaveBeenCalledTimes(1)
120
+ expect(onB).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('subscribeForeign delivers messages from a foreign prefix on demand', async () => {
124
+ using broker = new MemoryBroker()
125
+ using svcA = new InProcessCrossNodeBus({ broker, nodeId: 'a', topicPrefix: 'svc-a/' })
126
+ using svcB = new InProcessCrossNodeBus({ broker, nodeId: 'b', topicPrefix: 'svc-b/' })
127
+ const eavesdrop = vi.fn()
128
+ using _foreign = svcB.subscribeForeign('svc-a/', 'events', eavesdrop)
129
+
130
+ await svcA.publish('events', { from: 'a' })
131
+
132
+ expect(eavesdrop).toHaveBeenCalledTimes(1)
133
+ expect((eavesdrop.mock.calls[0]?.[0] as BusMessage).originId).toBe('a')
134
+ })
135
+ })
136
+
137
+ describe('replay', () => {
138
+ it('yields messages newer than fromSeq', async () => {
139
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a' })
140
+ await bus.publish('topic', { n: 1 })
141
+ await bus.publish('topic', { n: 2 })
142
+ await bus.publish('topic', { n: 3 })
143
+
144
+ const yielded = await collect(bus.replay('topic', '1'))
145
+ expect(yielded.map((m) => (m.payload as { n: number }).n)).toEqual([2, 3])
146
+ })
147
+
148
+ it('throws ReplayWindowExceededError when fromSeq is too old', () => {
149
+ const bus = new InProcessCrossNodeBus({ replayWindow: 2 })
150
+ try {
151
+ void bus.publish('topic', null)
152
+ void bus.publish('topic', null)
153
+ void bus.publish('topic', null)
154
+ expect(() => bus.replay('topic', '0')).toThrow(ReplayWindowExceededError)
155
+ } finally {
156
+ bus[Symbol.dispose]()
157
+ }
158
+ })
159
+ })
160
+
161
+ describe('telemetry', () => {
162
+ it('emits onCrossNodePublished with byteLength after a successful publish', async () => {
163
+ const telemetry = new CrossNodeBusTelemetry()
164
+ try {
165
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry })
166
+ const events: Array<{ topic: string; originId: string; byteLength: number }> = []
167
+ using _sub = telemetry.subscribe('onCrossNodePublished', (event) => {
168
+ events.push(event)
169
+ })
170
+
171
+ await bus.publish('topic', { hi: 'there' })
172
+
173
+ expect(events).toEqual([
174
+ { topic: 'topic', originId: 'a', byteLength: Buffer.byteLength(JSON.stringify({ hi: 'there' })) },
175
+ ])
176
+ } finally {
177
+ telemetry[Symbol.dispose]()
178
+ }
179
+ })
180
+
181
+ it('emits onCrossNodeReceived once per arrival regardless of handler count', async () => {
182
+ const telemetry = new CrossNodeBusTelemetry()
183
+ try {
184
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry })
185
+ const arrivals = vi.fn()
186
+ using _sub = telemetry.subscribe('onCrossNodeReceived', arrivals)
187
+ using _h1 = bus.subscribe('topic', () => undefined)
188
+ using _h2 = bus.subscribe('topic', () => undefined)
189
+ using _h3 = bus.subscribe('topic', () => undefined)
190
+
191
+ await bus.publish('topic', null)
192
+ expect(arrivals).toHaveBeenCalledTimes(1)
193
+ } finally {
194
+ telemetry[Symbol.dispose]()
195
+ }
196
+ })
197
+
198
+ it('emits onCrossNodeError with phase=subscribe when a local handler throws', async () => {
199
+ const telemetry = new CrossNodeBusTelemetry()
200
+ try {
201
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry })
202
+ const errors: Array<{ phase: string; topic: string; error: unknown }> = []
203
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
204
+ errors.push(event)
205
+ })
206
+ using _failing = bus.subscribe('topic', () => {
207
+ throw new Error('boom')
208
+ })
209
+
210
+ await bus.publish('topic', null)
211
+ expect(errors).toHaveLength(1)
212
+ expect(errors[0]?.phase).toBe('subscribe')
213
+ expect(errors[0]?.topic).toBe('topic')
214
+ } finally {
215
+ telemetry[Symbol.dispose]()
216
+ }
217
+ })
218
+
219
+ it('emits onCrossNodeError with phase=subscribe when a direct broker subscriber throws during fan-out', async () => {
220
+ const telemetry = new CrossNodeBusTelemetry()
221
+ using broker = new MemoryBroker()
222
+ try {
223
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'a', telemetry })
224
+ const errors: Array<{ phase: string; topic: string }> = []
225
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
226
+ errors.push(event)
227
+ })
228
+ // A direct broker subscriber bypasses the bus's `#deliver` try/catch;
229
+ // its throw routes through the broker's `onSubscriberError` sink.
230
+ using _failing = broker.subscribe('topic', () => {
231
+ throw new Error('boom')
232
+ })
233
+
234
+ await bus.publish('topic', null)
235
+ expect(errors.some((event) => event.phase === 'subscribe' && event.topic === 'topic')).toBe(true)
236
+ } finally {
237
+ telemetry[Symbol.dispose]()
238
+ }
239
+ })
240
+
241
+ it('emits onCrossNodeError with phase=subscribe when broker.subscribe throws on first hookup', () => {
242
+ const telemetry = new CrossNodeBusTelemetry()
243
+ const broker = new MemoryBroker()
244
+ try {
245
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'a', telemetry })
246
+ const errors: Array<{ phase: string }> = []
247
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
248
+ errors.push(event)
249
+ })
250
+ broker[Symbol.dispose]()
251
+ expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/)
252
+ expect(errors.some((event) => event.phase === 'subscribe')).toBe(true)
253
+ } finally {
254
+ telemetry[Symbol.dispose]()
255
+ }
256
+ })
257
+
258
+ it('emits onCrossNodeError with phase=publish when the broker rejects publish', async () => {
259
+ const telemetry = new CrossNodeBusTelemetry()
260
+ const broker = new MemoryBroker()
261
+ try {
262
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'a', telemetry })
263
+ const errors: Array<{ phase: string }> = []
264
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
265
+ errors.push(event)
266
+ })
267
+ broker[Symbol.dispose]()
268
+ await expect(bus.publish('topic', null)).rejects.toThrow(/disposed/)
269
+ expect(errors.some((event) => event.phase === 'publish')).toBe(true)
270
+ } finally {
271
+ telemetry[Symbol.dispose]()
272
+ }
273
+ })
274
+
275
+ it('emits onCrossNodeError with phase=replay when the underlying broker rejects', () => {
276
+ const telemetry = new CrossNodeBusTelemetry()
277
+ using broker = new MemoryBroker({ replayWindow: 1 })
278
+ try {
279
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'a', telemetry })
280
+ const errors: Array<{ phase: string }> = []
281
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
282
+ errors.push(event)
283
+ })
284
+ void bus.publish('topic', null)
285
+ void bus.publish('topic', null)
286
+ expect(() => bus.replay('topic', '0')).toThrow()
287
+ expect(errors.some((event) => event.phase === 'replay')).toBe(true)
288
+ } finally {
289
+ telemetry[Symbol.dispose]()
290
+ }
291
+ })
292
+
293
+ it('emits onCrossNodeWindowEvicted with displayTopic when a private broker rolls', async () => {
294
+ const telemetry = new CrossNodeBusTelemetry()
295
+ try {
296
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry, replayWindow: 2 })
297
+ const evictions: Array<{ topic: string; evictedSeq: string; retainedCount: number }> = []
298
+ using _sub = telemetry.subscribe('onCrossNodeWindowEvicted', (event) => {
299
+ evictions.push(event)
300
+ })
301
+ // No subscriber → displayTopic falls back to wire (= 'topic' here, no prefix)
302
+ await bus.publish('topic', null) // seq 1
303
+ await bus.publish('topic', null) // seq 2
304
+ await bus.publish('topic', null) // seq 3 → evicts seq 1
305
+ expect(evictions).toEqual([{ topic: 'topic', evictedSeq: '1', retainedCount: 2 }])
306
+ } finally {
307
+ telemetry[Symbol.dispose]()
308
+ }
309
+ })
310
+
311
+ it('attributes window-evicted topic to the original subscribe call when topicPrefix is set', async () => {
312
+ const telemetry = new CrossNodeBusTelemetry()
313
+ try {
314
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry, replayWindow: 2, topicPrefix: 'svc-a/' })
315
+ using _h = bus.subscribe('events', () => undefined)
316
+ const evictions: Array<{ topic: string }> = []
317
+ using _sub = telemetry.subscribe('onCrossNodeWindowEvicted', (event) => {
318
+ evictions.push({ topic: event.topic })
319
+ })
320
+ await bus.publish('events', null)
321
+ await bus.publish('events', null)
322
+ await bus.publish('events', null) // first eviction
323
+ expect(evictions).toEqual([{ topic: 'events' }])
324
+ } finally {
325
+ telemetry[Symbol.dispose]()
326
+ }
327
+ })
328
+
329
+ it('does not emit onCrossNodeWindowEvicted when the broker is supplied externally', async () => {
330
+ const telemetry = new CrossNodeBusTelemetry()
331
+ using broker = new MemoryBroker({ replayWindow: 2 })
332
+ try {
333
+ using bus = new InProcessCrossNodeBus({ broker, nodeId: 'a', telemetry })
334
+ const onEvicted = vi.fn()
335
+ using _sub = telemetry.subscribe('onCrossNodeWindowEvicted', onEvicted)
336
+ await bus.publish('topic', null)
337
+ await bus.publish('topic', null)
338
+ await bus.publish('topic', null)
339
+ expect(onEvicted).not.toHaveBeenCalled()
340
+ } finally {
341
+ telemetry[Symbol.dispose]()
342
+ }
343
+ })
344
+
345
+ it('emits onCrossNodeError with phase=serialize when JSON.stringify throws', async () => {
346
+ const telemetry = new CrossNodeBusTelemetry()
347
+ try {
348
+ using bus = new InProcessCrossNodeBus({ nodeId: 'a', telemetry })
349
+ const errors: Array<{ phase: string }> = []
350
+ using _sub = telemetry.subscribe('onCrossNodeError', (event) => {
351
+ errors.push(event)
352
+ })
353
+
354
+ const cycle: Record<string, unknown> = {}
355
+ cycle.self = cycle
356
+
357
+ await bus.publish('topic', cycle)
358
+ expect(errors.some((event) => event.phase === 'serialize')).toBe(true)
359
+ } finally {
360
+ telemetry[Symbol.dispose]()
361
+ }
362
+ })
363
+ })
364
+
365
+ describe('compareSeq', () => {
366
+ it('returns negative, zero, positive for a<b, a==b, a>b', () => {
367
+ using bus = new InProcessCrossNodeBus()
368
+ expect(bus.compareSeq('1', '2')).toBeLessThan(0)
369
+ expect(bus.compareSeq('5', '5')).toBe(0)
370
+ expect(bus.compareSeq('10', '2')).toBeGreaterThan(0)
371
+ })
372
+ })
373
+
374
+ describe('oldestSeq', () => {
375
+ it('returns undefined before any publish', () => {
376
+ using bus = new InProcessCrossNodeBus()
377
+ expect(bus.oldestSeq('topic')).toBeUndefined()
378
+ })
379
+
380
+ it('returns the oldest retained seq honoring topicPrefix', async () => {
381
+ using broker = new MemoryBroker()
382
+ using bus = new InProcessCrossNodeBus({ broker, topicPrefix: 'svc/' })
383
+ await bus.publish('topic', null)
384
+ await bus.publish('topic', null)
385
+ expect(bus.oldestSeq('topic')).toBe('1')
386
+ // direct broker access proves the prefix was applied on the wire.
387
+ expect(broker.oldestSeq('svc/topic')).toBe('1')
388
+ })
389
+
390
+ it('throws after dispose', () => {
391
+ const bus = new InProcessCrossNodeBus()
392
+ bus[Symbol.dispose]()
393
+ expect(() => bus.oldestSeq('topic')).toThrow(/disposed/)
394
+ })
395
+ })
396
+
397
+ describe('disposal', () => {
398
+ it('rejects publish/subscribe/replay after dispose', async () => {
399
+ const bus = new InProcessCrossNodeBus()
400
+ bus[Symbol.dispose]()
401
+ expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/)
402
+ expect(() => bus.subscribeForeign('p/', 'topic', () => undefined)).toThrow(/disposed/)
403
+ expect(() => bus.replay('topic', '0')).toThrow(/disposed/)
404
+ await expect(bus.publish('topic', null)).rejects.toThrow(/disposed/)
405
+ })
406
+
407
+ it('is idempotent', () => {
408
+ const bus = new InProcessCrossNodeBus()
409
+ bus[Symbol.dispose]()
410
+ expect(() => bus[Symbol.dispose]()).not.toThrow()
411
+ })
412
+
413
+ it('disposes its private broker when it owns one', () => {
414
+ const bus = new InProcessCrossNodeBus()
415
+ const handler = vi.fn()
416
+ const sub = bus.subscribe('topic', handler)
417
+ bus[Symbol.dispose]()
418
+ expect(() => sub[Symbol.dispose]()).not.toThrow()
419
+ })
420
+
421
+ it('does not dispose a shared broker', () => {
422
+ using broker = new MemoryBroker()
423
+ const bus = new InProcessCrossNodeBus({ broker })
424
+ bus[Symbol.dispose]()
425
+ expect(() => broker.publish('topic', 'a', null)).not.toThrow()
426
+ })
427
+ })
428
+ })
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { CrossNodeBusTelemetry } from './cross-node-bus-telemetry.js'
3
+ import { MemoryBroker } from './memory-broker.js'
4
+ import type { CrossNodeBus } from './cross-node-bus.js'
5
+ import type { BusMessage, CrossNodeBusCapabilities } from './types.js'
6
+
7
+ /**
8
+ * Options accepted by {@link InProcessCrossNodeBus}.
9
+ */
10
+ export type InProcessCrossNodeBusOptions = {
11
+ /**
12
+ * Shared {@link MemoryBroker}. When omitted a private broker is minted —
13
+ * single-instance behavior matches an isolated EventHub. The testing
14
+ * harness passes the same broker to N buses so they can observe each
15
+ * other's publishes without an external transport.
16
+ */
17
+ broker?: MemoryBroker
18
+ /** Stable, per-process identifier. Defaults to `local-${crypto.randomUUID()}`. */
19
+ nodeId?: string
20
+ /**
21
+ * Wire-level prefix applied to every topic on `publish` / `subscribe`.
22
+ * Defaults to `''` (no prefix). Multi-service simulations pick distinct
23
+ * prefixes per bus so cross-service eavesdrop can be exercised against a
24
+ * single shared broker.
25
+ */
26
+ topicPrefix?: string
27
+ /**
28
+ * Convenience for the common case of "one bus, fresh private broker, this
29
+ * many retained messages per topic". Ignored when `broker` is provided.
30
+ */
31
+ replayWindow?: number
32
+ /** Sink for `onCrossNodePublished` / `onCrossNodeReceived` / `onCrossNodeError`. */
33
+ telemetry?: CrossNodeBusTelemetry
34
+ }
35
+
36
+ const CAPABILITIES: CrossNodeBusCapabilities = Object.freeze({
37
+ persistent: false,
38
+ replay: true,
39
+ assignsSequence: true,
40
+ })
41
+
42
+ type LocalEntry = {
43
+ handler: (message: BusMessage) => void
44
+ /** Original topic the caller subscribed with — used for telemetry attribution. */
45
+ displayTopic: string
46
+ }
47
+
48
+ /**
49
+ * In-process default {@link CrossNodeBus} adapter. Backs single-node
50
+ * deployments out of the box and powers the multi-instance testing harness
51
+ * exposed at `@furystack/cross-node-bus/testing`.
52
+ *
53
+ * Local fan-out is multiplexed: regardless of how many handlers subscribe
54
+ * to a single wire topic, the bus opens exactly one broker subscription and
55
+ * dispatches arrivals to its own handler set. This keeps `onCrossNodeReceived`
56
+ * counting one event per arrival rather than per handler invocation, and
57
+ * mirrors the consumer-group shape future network adapters will use.
58
+ */
59
+ export class InProcessCrossNodeBus implements CrossNodeBus {
60
+ public readonly nodeId: string
61
+ public readonly capabilities: CrossNodeBusCapabilities = CAPABILITIES
62
+
63
+ readonly #broker: MemoryBroker
64
+ readonly #ownsBroker: boolean
65
+ readonly #topicPrefix: string
66
+ readonly #telemetry: CrossNodeBusTelemetry | undefined
67
+
68
+ /** displayTopic per wire topic — keyed by wire string for O(1) routing. */
69
+ readonly #localHandlers: Map<string, Set<LocalEntry>> = new Map()
70
+ /** Live broker subscription per wire topic, opened on first local handler. */
71
+ readonly #brokerHandles: Map<string, Disposable> = new Map()
72
+
73
+ #disposed = false
74
+
75
+ constructor(options: InProcessCrossNodeBusOptions = {}) {
76
+ this.#ownsBroker = options.broker === undefined
77
+ this.nodeId = options.nodeId ?? `local-${randomUUID()}`
78
+ this.#topicPrefix = options.topicPrefix ?? ''
79
+ this.#telemetry = options.telemetry
80
+ this.#broker =
81
+ options.broker ??
82
+ new MemoryBroker({
83
+ replayWindow: options.replayWindow,
84
+ onEviction: (wire, evictedSeq, retainedCount) =>
85
+ this.#telemetry?.emit('onCrossNodeWindowEvicted', {
86
+ topic: this.#displayTopicFor(wire),
87
+ evictedSeq,
88
+ retainedCount,
89
+ }),
90
+ })
91
+ }
92
+
93
+ public async publish(topic: string, payload: unknown): Promise<void> {
94
+ this.#ensureLive()
95
+ const wire = this.#wireTopic(topic)
96
+ let byteLength = 0
97
+ if (this.#telemetry) {
98
+ try {
99
+ byteLength = Buffer.byteLength(JSON.stringify(payload) ?? '')
100
+ } catch (error) {
101
+ this.#telemetry.emit('onCrossNodeError', { topic, error, phase: 'serialize' })
102
+ }
103
+ }
104
+ try {
105
+ this.#broker.publish(wire, this.nodeId, payload, (error) => {
106
+ this.#telemetry?.emit('onCrossNodeError', { topic, error, phase: 'subscribe' })
107
+ })
108
+ } catch (error) {
109
+ this.#telemetry?.emit('onCrossNodeError', { topic, error, phase: 'publish' })
110
+ throw error
111
+ }
112
+ this.#telemetry?.emit('onCrossNodePublished', { topic, originId: this.nodeId, byteLength })
113
+ }
114
+
115
+ public subscribe(topic: string, handler: (message: BusMessage) => void): Disposable {
116
+ this.#ensureLive()
117
+ return this.#subscribeWire(this.#wireTopic(topic), topic, handler, 'subscribe')
118
+ }
119
+
120
+ public subscribeRemoteOnly(topic: string, handler: (message: BusMessage) => void): Disposable {
121
+ return this.subscribe(topic, (message) => {
122
+ if (message.originId !== this.nodeId) handler(message)
123
+ })
124
+ }
125
+
126
+ public subscribeForeign(prefix: string, topic: string, handler: (message: BusMessage) => void): Disposable {
127
+ this.#ensureLive()
128
+ return this.#subscribeWire(`${prefix}${topic}`, topic, handler, 'subscribeForeign')
129
+ }
130
+
131
+ public replay(topic: string, fromSeq: string): AsyncIterable<BusMessage> {
132
+ this.#ensureLive()
133
+ try {
134
+ return this.#broker.replay(this.#wireTopic(topic), fromSeq)
135
+ } catch (error) {
136
+ this.#telemetry?.emit('onCrossNodeError', { topic, error, phase: 'replay' })
137
+ throw error
138
+ }
139
+ }
140
+
141
+ public compareSeq(a: string, b: string): number {
142
+ return Number(a) - Number(b)
143
+ }
144
+
145
+ public oldestSeq(topic: string): string | undefined {
146
+ this.#ensureLive()
147
+ return this.#broker.oldestSeq(this.#wireTopic(topic))
148
+ }
149
+
150
+ public [Symbol.dispose](): void {
151
+ if (this.#disposed) return
152
+ this.#disposed = true
153
+ for (const handle of this.#brokerHandles.values()) {
154
+ handle[Symbol.dispose]()
155
+ }
156
+ this.#brokerHandles.clear()
157
+ this.#localHandlers.clear()
158
+ if (this.#ownsBroker) {
159
+ this.#broker[Symbol.dispose]()
160
+ }
161
+ }
162
+
163
+ #wireTopic(topic: string): string {
164
+ return `${this.#topicPrefix}${topic}`
165
+ }
166
+
167
+ /**
168
+ * Reverse-resolve the caller-facing display topic for a wire topic. When
169
+ * local handlers exist their displayTopic is authoritative — `subscribe`
170
+ * always yields `prefix+t` and `subscribeForeign` always yields `p+t`, so
171
+ * the first entry is representative. Falls back to the wire string when no
172
+ * subscriber on this bus has registered (publish-only buses never need to
173
+ * round-trip a display name; the wire is the most accurate label).
174
+ */
175
+ #displayTopicFor(wire: string): string {
176
+ const handlers = this.#localHandlers.get(wire)
177
+ if (!handlers) return wire
178
+ const first = handlers.values().next().value
179
+ return first ? first.displayTopic : wire
180
+ }
181
+
182
+ #subscribeWire(
183
+ wire: string,
184
+ displayTopic: string,
185
+ handler: (message: BusMessage) => void,
186
+ phase: 'subscribe' | 'subscribeForeign',
187
+ ): Disposable {
188
+ const entry: LocalEntry = { handler, displayTopic }
189
+ let handlers = this.#localHandlers.get(wire)
190
+ if (!handlers) {
191
+ handlers = new Set()
192
+ this.#localHandlers.set(wire, handlers)
193
+ try {
194
+ const brokerHandle = this.#broker.subscribe(wire, (message) => this.#deliver(wire, message))
195
+ this.#brokerHandles.set(wire, brokerHandle)
196
+ } catch (error) {
197
+ this.#localHandlers.delete(wire)
198
+ this.#telemetry?.emit('onCrossNodeError', { topic: displayTopic, error, phase })
199
+ throw error
200
+ }
201
+ }
202
+ handlers.add(entry)
203
+ return {
204
+ [Symbol.dispose]: () => {
205
+ const current = this.#localHandlers.get(wire)
206
+ if (!current) return
207
+ current.delete(entry)
208
+ if (current.size === 0) {
209
+ this.#localHandlers.delete(wire)
210
+ this.#brokerHandles.get(wire)?.[Symbol.dispose]()
211
+ this.#brokerHandles.delete(wire)
212
+ }
213
+ },
214
+ }
215
+ }
216
+
217
+ #deliver(wire: string, message: BusMessage): void {
218
+ const handlers = this.#localHandlers.get(wire)
219
+ if (!handlers || handlers.size === 0) return
220
+ if (this.#telemetry) {
221
+ // Multiple subscribers on the same wire share a displayTopic that maps
222
+ // 1:1 to the wire string: `subscribe(t)` always yields prefix+t and
223
+ // `subscribeForeign(p, t)` always yields p+t. The first entry's
224
+ // displayTopic is therefore representative.
225
+ const first = handlers.values().next().value
226
+ if (first) {
227
+ this.#telemetry.emit('onCrossNodeReceived', {
228
+ topic: first.displayTopic,
229
+ originId: message.originId,
230
+ lagMs: Date.now() - Date.parse(message.emittedAt),
231
+ })
232
+ }
233
+ }
234
+ for (const entry of handlers) {
235
+ try {
236
+ entry.handler(message)
237
+ } catch (error) {
238
+ this.#telemetry?.emit('onCrossNodeError', { topic: entry.displayTopic, error, phase: 'subscribe' })
239
+ }
240
+ }
241
+ }
242
+
243
+ #ensureLive(): void {
244
+ if (this.#disposed) {
245
+ throw new Error('InProcessCrossNodeBus has been disposed')
246
+ }
247
+ }
248
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './cross-node-bus-telemetry.js'
2
+ export * from './cross-node-bus.js'
3
+ export * from './define-in-process-cross-node-bus.js'
4
+ export * from './errors.js'
5
+ export * from './in-process-cross-node-bus.js'
6
+ export * from './memory-broker.js'
7
+ export type { BusMessage, CrossNodeBusCapabilities } from './types.js'