@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,282 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { ReplayWindowExceededError } from './errors.js'
3
+ import { MemoryBroker } from './memory-broker.js'
4
+ import type { BusMessage } from './types.js'
5
+
6
+ const collect = async (iterable: AsyncIterable<BusMessage>): Promise<BusMessage[]> => {
7
+ const out: BusMessage[] = []
8
+ for await (const message of iterable) out.push(message)
9
+ return out
10
+ }
11
+
12
+ describe('MemoryBroker', () => {
13
+ describe('constructor', () => {
14
+ it('rejects non-positive replayWindow', () => {
15
+ expect(() => new MemoryBroker({ replayWindow: 0 })).toThrow(RangeError)
16
+ expect(() => new MemoryBroker({ replayWindow: -1 })).toThrow(RangeError)
17
+ expect(() => new MemoryBroker({ replayWindow: 1.5 })).toThrow(RangeError)
18
+ })
19
+
20
+ it('exposes the configured replayWindow', () => {
21
+ const broker = new MemoryBroker({ replayWindow: 42 })
22
+ expect(broker.replayWindow).toBe(42)
23
+ })
24
+
25
+ it('defaults replayWindow to 1000', () => {
26
+ const broker = new MemoryBroker()
27
+ expect(broker.replayWindow).toBe(1000)
28
+ })
29
+ })
30
+
31
+ describe('publish + subscribe', () => {
32
+ it('delivers messages to every subscriber on the topic', () => {
33
+ using broker = new MemoryBroker()
34
+ const a = vi.fn()
35
+ const b = vi.fn()
36
+ using _subA = broker.subscribe('topic', a)
37
+ using _subB = broker.subscribe('topic', b)
38
+
39
+ const message = broker.publish('topic', 'origin-1', { x: 1 })
40
+
41
+ expect(a).toHaveBeenCalledTimes(1)
42
+ expect(b).toHaveBeenCalledTimes(1)
43
+ expect(a.mock.calls[0]?.[0]).toBe(message)
44
+ })
45
+
46
+ it('does not deliver across topics', () => {
47
+ using broker = new MemoryBroker()
48
+ const handler = vi.fn()
49
+ using _sub = broker.subscribe('topic-a', handler)
50
+
51
+ broker.publish('topic-b', 'origin-1', null)
52
+ expect(handler).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('stamps version, originId, payload, ISO emittedAt and monotonic seq', () => {
56
+ using broker = new MemoryBroker()
57
+ const m1 = broker.publish('topic', 'origin-1', { n: 1 })
58
+ const m2 = broker.publish('topic', 'origin-2', { n: 2 })
59
+
60
+ expect(m1.v).toBe(1)
61
+ expect(m1.originId).toBe('origin-1')
62
+ expect(m1.payload).toEqual({ n: 1 })
63
+ expect(m1.seq).toBe('1')
64
+ expect(m2.seq).toBe('2')
65
+ expect(Number.isFinite(Date.parse(m1.emittedAt))).toBe(true)
66
+ })
67
+
68
+ it('keeps sequence counters per-topic', () => {
69
+ using broker = new MemoryBroker()
70
+ const a1 = broker.publish('a', 'o', null)
71
+ const b1 = broker.publish('b', 'o', null)
72
+ const a2 = broker.publish('a', 'o', null)
73
+
74
+ expect(a1.seq).toBe('1')
75
+ expect(b1.seq).toBe('1')
76
+ expect(a2.seq).toBe('2')
77
+ })
78
+
79
+ it('removes the subscriber on dispose', () => {
80
+ using broker = new MemoryBroker()
81
+ const handler = vi.fn()
82
+ const sub = broker.subscribe('topic', handler)
83
+ sub[Symbol.dispose]()
84
+
85
+ broker.publish('topic', 'origin-1', null)
86
+ expect(handler).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('isolates one subscriber from another that throws', () => {
90
+ using broker = new MemoryBroker()
91
+ const ok = vi.fn()
92
+ const onError = vi.fn()
93
+ const failing = vi.fn(() => {
94
+ throw new Error('boom')
95
+ })
96
+ using _subA = broker.subscribe('topic', failing)
97
+ using _subB = broker.subscribe('topic', ok)
98
+
99
+ broker.publish('topic', 'origin-1', null, onError)
100
+
101
+ expect(ok).toHaveBeenCalledTimes(1)
102
+ expect(onError).toHaveBeenCalledTimes(1)
103
+ expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error)
104
+ expect(onError.mock.calls[0]?.[1]).toBe(failing)
105
+ })
106
+
107
+ it('falls back to console.error when no error sink is provided', () => {
108
+ using broker = new MemoryBroker()
109
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
110
+ try {
111
+ using _sub = broker.subscribe('topic', () => {
112
+ throw new Error('boom')
113
+ })
114
+ broker.publish('topic', 'origin-1', null)
115
+ expect(error).toHaveBeenCalledTimes(1)
116
+ } finally {
117
+ error.mockRestore()
118
+ }
119
+ })
120
+ })
121
+
122
+ describe('replay', () => {
123
+ it('yields messages with seq greater than fromSeq', async () => {
124
+ using broker = new MemoryBroker()
125
+ broker.publish('topic', 'origin-1', { n: 1 })
126
+ broker.publish('topic', 'origin-1', { n: 2 })
127
+ broker.publish('topic', 'origin-1', { n: 3 })
128
+
129
+ const yielded = await collect(broker.replay('topic', '1'))
130
+ expect(yielded.map((m) => m.seq)).toEqual(['2', '3'])
131
+ })
132
+
133
+ it('returns an empty stream when nothing has been published', async () => {
134
+ using broker = new MemoryBroker()
135
+ const yielded = await collect(broker.replay('topic', '0'))
136
+ expect(yielded).toEqual([])
137
+ })
138
+
139
+ it('returns an empty stream when fromSeq matches the latest seq', async () => {
140
+ using broker = new MemoryBroker()
141
+ broker.publish('topic', 'origin-1', null)
142
+ const yielded = await collect(broker.replay('topic', '1'))
143
+ expect(yielded).toEqual([])
144
+ })
145
+
146
+ it('throws synchronously when fromSeq falls outside the retained window', () => {
147
+ const broker = new MemoryBroker({ replayWindow: 2 })
148
+ try {
149
+ broker.publish('topic', 'origin-1', null) // seq 1
150
+ broker.publish('topic', 'origin-1', null) // seq 2
151
+ broker.publish('topic', 'origin-1', null) // seq 3 — drops seq 1
152
+
153
+ // window now retains seqs 2, 3. Asking from seq 0 lost seq 1.
154
+ expect(() => broker.replay('topic', '0')).toThrow(ReplayWindowExceededError)
155
+ } finally {
156
+ broker[Symbol.dispose]()
157
+ }
158
+ })
159
+
160
+ it('does not throw when fromSeq+1 equals the oldest retained seq', async () => {
161
+ using broker = new MemoryBroker({ replayWindow: 2 })
162
+ broker.publish('topic', 'origin-1', null) // seq 1
163
+ broker.publish('topic', 'origin-1', null) // seq 2
164
+ broker.publish('topic', 'origin-1', null) // seq 3 — retains 2, 3
165
+
166
+ const yielded = await collect(broker.replay('topic', '1'))
167
+ expect(yielded.map((m) => m.seq)).toEqual(['2', '3'])
168
+ })
169
+
170
+ it('rejects malformed fromSeq values', () => {
171
+ using broker = new MemoryBroker()
172
+ expect(() => broker.replay('topic', 'abc')).toThrow(RangeError)
173
+ expect(() => broker.replay('topic', '-1')).toThrow(RangeError)
174
+ })
175
+
176
+ it('snapshots the buffer at call time', async () => {
177
+ using broker = new MemoryBroker()
178
+ broker.publish('topic', 'origin-1', { n: 1 })
179
+ const iterable = broker.replay('topic', '0')
180
+ broker.publish('topic', 'origin-1', { n: 2 })
181
+
182
+ const yielded = await collect(iterable)
183
+ expect(yielded.map((m) => (m.payload as { n: number }).n)).toEqual([1])
184
+ })
185
+ })
186
+
187
+ describe('oldestSeq', () => {
188
+ it('returns undefined when nothing has been published', () => {
189
+ using broker = new MemoryBroker()
190
+ expect(broker.oldestSeq('topic')).toBeUndefined()
191
+ })
192
+
193
+ it('returns the seq of the first retained message', () => {
194
+ using broker = new MemoryBroker()
195
+ broker.publish('topic', 'origin-1', null)
196
+ broker.publish('topic', 'origin-1', null)
197
+ expect(broker.oldestSeq('topic')).toBe('1')
198
+ })
199
+
200
+ it('advances after the ring buffer evicts older entries', () => {
201
+ using broker = new MemoryBroker({ replayWindow: 2 })
202
+ broker.publish('topic', 'origin-1', null) // seq 1, evicted
203
+ broker.publish('topic', 'origin-1', null) // seq 2
204
+ broker.publish('topic', 'origin-1', null) // seq 3
205
+ expect(broker.oldestSeq('topic')).toBe('2')
206
+ })
207
+ })
208
+
209
+ describe('onEviction', () => {
210
+ it('fires once per shifted message with topic, evictedSeq, and retainedCount', () => {
211
+ const evictions: Array<{ topic: string; evictedSeq: string; retainedCount: number }> = []
212
+ using broker = new MemoryBroker({
213
+ replayWindow: 2,
214
+ onEviction: (topic, evictedSeq, retainedCount) => {
215
+ evictions.push({ topic, evictedSeq, retainedCount })
216
+ },
217
+ })
218
+ broker.publish('topic', 'origin-1', null) // seq 1
219
+ broker.publish('topic', 'origin-1', null) // seq 2
220
+ expect(evictions).toEqual([])
221
+ broker.publish('topic', 'origin-1', null) // seq 3 → evicts seq 1
222
+ broker.publish('topic', 'origin-1', null) // seq 4 → evicts seq 2
223
+ expect(evictions).toEqual([
224
+ { topic: 'topic', evictedSeq: '1', retainedCount: 2 },
225
+ { topic: 'topic', evictedSeq: '2', retainedCount: 2 },
226
+ ])
227
+ })
228
+
229
+ it('does not fire while the buffer is below replayWindow', () => {
230
+ const onEviction = vi.fn()
231
+ using broker = new MemoryBroker({ replayWindow: 5, onEviction })
232
+ for (let i = 0; i < 5; i += 1) broker.publish('topic', 'origin-1', null)
233
+ expect(onEviction).not.toHaveBeenCalled()
234
+ })
235
+
236
+ it('attributes evictions to the right topic when several are active', () => {
237
+ const evictions: Array<{ topic: string; evictedSeq: string }> = []
238
+ using broker = new MemoryBroker({
239
+ replayWindow: 1,
240
+ onEviction: (topic, evictedSeq) => {
241
+ evictions.push({ topic, evictedSeq })
242
+ },
243
+ })
244
+ broker.publish('a', 'origin-1', null) // seq a/1
245
+ broker.publish('b', 'origin-1', null) // seq b/1
246
+ broker.publish('a', 'origin-1', null) // evicts a/1
247
+ broker.publish('b', 'origin-1', null) // evicts b/1
248
+ expect(evictions).toEqual([
249
+ { topic: 'a', evictedSeq: '1' },
250
+ { topic: 'b', evictedSeq: '1' },
251
+ ])
252
+ })
253
+ })
254
+
255
+ describe('disposal', () => {
256
+ it('clears subscribers and rejects further calls', () => {
257
+ const broker = new MemoryBroker()
258
+ const handler = vi.fn()
259
+ broker.subscribe('topic', handler)
260
+
261
+ broker[Symbol.dispose]()
262
+
263
+ expect(() => broker.publish('topic', 'origin-1', null)).toThrow(/disposed/)
264
+ expect(() => broker.subscribe('topic', handler)).toThrow(/disposed/)
265
+ expect(() => broker.replay('topic', '0')).toThrow(/disposed/)
266
+ expect(() => broker.oldestSeq('topic')).toThrow(/disposed/)
267
+ })
268
+
269
+ it('is idempotent', () => {
270
+ const broker = new MemoryBroker()
271
+ broker[Symbol.dispose]()
272
+ expect(() => broker[Symbol.dispose]()).not.toThrow()
273
+ })
274
+
275
+ it('disposing a subscription after broker disposal is a no-op', () => {
276
+ const broker = new MemoryBroker()
277
+ const sub = broker.subscribe('topic', () => undefined)
278
+ broker[Symbol.dispose]()
279
+ expect(() => sub[Symbol.dispose]()).not.toThrow()
280
+ })
281
+ })
282
+ })
@@ -0,0 +1,199 @@
1
+ import { ReplayWindowExceededError } from './errors.js'
2
+ import type { BusMessage } from './types.js'
3
+
4
+ /**
5
+ * Options accepted by {@link MemoryBroker}.
6
+ */
7
+ export type MemoryBrokerOptions = {
8
+ /**
9
+ * Maximum number of messages retained per topic for {@link MemoryBroker.replay}.
10
+ * Defaults to `1000`. Must be a positive integer.
11
+ */
12
+ replayWindow?: number
13
+ /**
14
+ * Invoked synchronously every time the per-topic ring buffer drops a message
15
+ * to honor `replayWindow`. Receives the evicted message's `seq` and the new
16
+ * retained count after eviction. {@link InProcessCrossNodeBus} wires this to
17
+ * `onCrossNodeWindowEvicted` telemetry; tests and bespoke setups can supply a
18
+ * direct callback. Mirrors the broker-agnostic shape of `onSubscriberError`
19
+ * on {@link MemoryBroker.publish}.
20
+ */
21
+ onEviction?: (topic: string, evictedSeq: string, retainedCount: number) => void
22
+ }
23
+
24
+ const DEFAULT_REPLAY_WINDOW = 1000
25
+
26
+ type Handler = (message: BusMessage) => void
27
+
28
+ type TopicState = {
29
+ /** Last assigned sequence as a number. Stored as `string` on the wire. */
30
+ lastSeq: number
31
+ /** Ring buffer ordered oldest → newest. Length is bounded by `replayWindow`. */
32
+ buffer: BusMessage[]
33
+ /** Subscribers receiving every message published on this topic. */
34
+ handlers: Set<Handler>
35
+ }
36
+
37
+ /**
38
+ * In-memory broker shared by one or more {@link InProcessCrossNodeBus}
39
+ * instances. Owns the per-topic monotonic sequence counter, the bounded
40
+ * replay buffer, and the subscriber registry.
41
+ *
42
+ * The default {@link CrossNodeBus} factory mints a private broker per bus,
43
+ * so single-instance deployments behave like an isolated EventHub. The
44
+ * testing harness shares a single broker across N buses so multi-node
45
+ * scenarios can be exercised without spinning up an external transport.
46
+ */
47
+ export class MemoryBroker implements Disposable {
48
+ readonly #replayWindow: number
49
+ readonly #onEviction: ((topic: string, evictedSeq: string, retainedCount: number) => void) | undefined
50
+ readonly #topics: Map<string, TopicState> = new Map()
51
+ #disposed = false
52
+
53
+ constructor(options: MemoryBrokerOptions = {}) {
54
+ const replayWindow = options.replayWindow ?? DEFAULT_REPLAY_WINDOW
55
+ if (!Number.isInteger(replayWindow) || replayWindow <= 0) {
56
+ throw new RangeError(`MemoryBroker.replayWindow must be a positive integer, got ${String(replayWindow)}`)
57
+ }
58
+ this.#replayWindow = replayWindow
59
+ this.#onEviction = options.onEviction
60
+ }
61
+
62
+ public get replayWindow(): number {
63
+ return this.#replayWindow
64
+ }
65
+
66
+ /**
67
+ * Stamps `payload` with a fresh per-topic sequence and a publisher-side
68
+ * `emittedAt`, retains the result in the ring buffer, then synchronously
69
+ * fans out to every subscriber on `topic`.
70
+ *
71
+ * Subscriber exceptions are caught and routed to `onSubscriberError` so a
72
+ * single faulty handler cannot interrupt fan-out to siblings or the bus
73
+ * that hosts them.
74
+ */
75
+ public publish(
76
+ topic: string,
77
+ originId: string,
78
+ payload: unknown,
79
+ onSubscriberError?: (error: unknown, handler: Handler) => void,
80
+ ): BusMessage {
81
+ this.#ensureLive()
82
+ const state = this.#getOrCreate(topic)
83
+ state.lastSeq += 1
84
+ const message: BusMessage = {
85
+ v: 1,
86
+ originId,
87
+ emittedAt: new Date().toISOString(),
88
+ seq: String(state.lastSeq),
89
+ payload,
90
+ }
91
+ state.buffer.push(message)
92
+ if (state.buffer.length > this.#replayWindow) {
93
+ const evicted = state.buffer.shift()
94
+ if (evicted && this.#onEviction) {
95
+ // `evicted.seq` is always defined for messages this broker minted —
96
+ // `BusMessage.seq` is optional only because non-sequencing adapters
97
+ // omit it. The non-null assertion is the cheapest narrowing here.
98
+ this.#onEviction(topic, evicted.seq as string, state.buffer.length)
99
+ }
100
+ }
101
+ for (const handler of state.handlers) {
102
+ try {
103
+ handler(message)
104
+ } catch (error) {
105
+ if (onSubscriberError) {
106
+ onSubscriberError(error, handler)
107
+ } else {
108
+ console.error('Unhandled MemoryBroker subscriber error', { topic, error })
109
+ }
110
+ }
111
+ }
112
+ return message
113
+ }
114
+
115
+ /**
116
+ * Registers `handler` for every message published on `topic`. The returned
117
+ * {@link Disposable} removes the handler on dispose.
118
+ */
119
+ public subscribe(topic: string, handler: Handler): Disposable {
120
+ this.#ensureLive()
121
+ const state = this.#getOrCreate(topic)
122
+ state.handlers.add(handler)
123
+ return {
124
+ [Symbol.dispose]: () => {
125
+ const current = this.#topics.get(topic)
126
+ if (!current) return
127
+ current.handlers.delete(handler)
128
+ if (current.handlers.size === 0 && current.buffer.length === 0 && current.lastSeq === 0) {
129
+ this.#topics.delete(topic)
130
+ }
131
+ },
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Returns the oldest retained seq for `topic`, or `undefined` when no
137
+ * messages are currently retained. Used by {@link InProcessCrossNodeBus}
138
+ * to back `oldestSeq` without forcing facades into throw-and-catch.
139
+ */
140
+ public oldestSeq(topic: string): string | undefined {
141
+ this.#ensureLive()
142
+ return this.#topics.get(topic)?.buffer[0]?.seq
143
+ }
144
+
145
+ /**
146
+ * Snapshot-then-stream replay. Validates the window synchronously: callers
147
+ * see {@link ReplayWindowExceededError} before they iterate, which keeps
148
+ * the fall-back-to-snapshot decision uniform with non-iterable failure
149
+ * modes elsewhere in the framework.
150
+ *
151
+ * Iteration walks a frozen snapshot of the buffer captured at call time;
152
+ * concurrent `publish` calls do not interleave into an in-flight replay.
153
+ */
154
+ public replay(topic: string, fromSeq: string): AsyncIterable<BusMessage> {
155
+ this.#ensureLive()
156
+ const fromSeqN = Number(fromSeq)
157
+ if (!Number.isFinite(fromSeqN) || fromSeqN < 0) {
158
+ throw new RangeError(`MemoryBroker.replay fromSeq must be a non-negative integer, got "${fromSeq}"`)
159
+ }
160
+ const state = this.#topics.get(topic)
161
+ const buffer = state?.buffer ?? []
162
+ const oldestRetained = buffer.length > 0 ? buffer[0].seq : undefined
163
+ const oldestRetainedN = oldestRetained !== undefined ? Number(oldestRetained) : (state?.lastSeq ?? 0) + 1
164
+ if (fromSeqN + 1 < oldestRetainedN) {
165
+ throw new ReplayWindowExceededError(topic, fromSeq, oldestRetained)
166
+ }
167
+ const snapshot = buffer.filter((message) => Number(message.seq) > fromSeqN)
168
+ return (async function* iterate(): AsyncIterable<BusMessage> {
169
+ for (const message of snapshot) {
170
+ yield message
171
+ }
172
+ })()
173
+ }
174
+
175
+ /**
176
+ * Drops all retained messages, sequence counters, and subscribers. After
177
+ * disposal every method throws — re-bind a fresh broker if you need one.
178
+ */
179
+ public [Symbol.dispose](): void {
180
+ if (this.#disposed) return
181
+ this.#disposed = true
182
+ this.#topics.clear()
183
+ }
184
+
185
+ #ensureLive(): void {
186
+ if (this.#disposed) {
187
+ throw new Error('MemoryBroker has been disposed')
188
+ }
189
+ }
190
+
191
+ #getOrCreate(topic: string): TopicState {
192
+ let state = this.#topics.get(topic)
193
+ if (!state) {
194
+ state = { lastSeq: 0, buffer: [], handlers: new Set() }
195
+ this.#topics.set(topic, state)
196
+ }
197
+ return state
198
+ }
199
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { BusMessage } from '../types.js'
3
+ import { createInProcessBusNetwork } from './create-in-process-bus-network.js'
4
+
5
+ describe('createInProcessBusNetwork', () => {
6
+ it('mints `count` buses sharing one broker', async () => {
7
+ using network = createInProcessBusNetwork({ count: 3 })
8
+ expect(network.buses).toHaveLength(3)
9
+ expect(network.broker).toBeDefined()
10
+
11
+ const handlerB = vi.fn()
12
+ const handlerC = vi.fn()
13
+ using _subB = network.buses[1].subscribe('topic', handlerB)
14
+ using _subC = network.buses[2].subscribe('topic', handlerC)
15
+
16
+ await network.buses[0].publish('topic', { from: 0 })
17
+
18
+ expect(handlerB).toHaveBeenCalledTimes(1)
19
+ expect(handlerC).toHaveBeenCalledTimes(1)
20
+ })
21
+
22
+ it('honors per-bus topicPrefixes for multi-service simulation', async () => {
23
+ using network = createInProcessBusNetwork({
24
+ count: 2,
25
+ topicPrefixes: ['svc-a/', 'svc-b/'],
26
+ })
27
+ const [busA, busB] = network.buses
28
+ const onB = vi.fn()
29
+ const eavesdrop = vi.fn()
30
+
31
+ using _subB = busB.subscribe('events', onB)
32
+ using _foreign = busB.subscribeForeign('svc-a/', 'events', eavesdrop)
33
+
34
+ await busA.publish('events', { from: 'a' })
35
+
36
+ expect(onB).not.toHaveBeenCalled()
37
+ expect(eavesdrop).toHaveBeenCalledTimes(1)
38
+ expect((eavesdrop.mock.calls[0]?.[0] as BusMessage).originId).toBe(busA.nodeId)
39
+ })
40
+
41
+ it('honors per-bus nodeIds', () => {
42
+ using network = createInProcessBusNetwork({
43
+ count: 2,
44
+ nodeIds: ['alice', 'bob'],
45
+ })
46
+ expect(network.buses[0].nodeId).toBe('alice')
47
+ expect(network.buses[1].nodeId).toBe('bob')
48
+ })
49
+
50
+ it('disposes every bus and the broker on teardown', () => {
51
+ const network = createInProcessBusNetwork({ count: 2 })
52
+ network[Symbol.dispose]()
53
+ for (const bus of network.buses) {
54
+ expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/)
55
+ }
56
+ expect(() => network.broker.publish('topic', 'a', null)).toThrow(/disposed/)
57
+ })
58
+
59
+ describe('input validation', () => {
60
+ it('rejects non-positive count', () => {
61
+ expect(() => createInProcessBusNetwork({ count: 0 })).toThrow(RangeError)
62
+ expect(() => createInProcessBusNetwork({ count: 1.5 })).toThrow(RangeError)
63
+ })
64
+
65
+ it('rejects mismatched topicPrefixes length', () => {
66
+ expect(() => createInProcessBusNetwork({ count: 2, topicPrefixes: ['only-one/'] })).toThrow(RangeError)
67
+ })
68
+
69
+ it('rejects mismatched nodeIds length', () => {
70
+ expect(() => createInProcessBusNetwork({ count: 2, nodeIds: ['only-one'] })).toThrow(RangeError)
71
+ })
72
+ })
73
+ })
@@ -0,0 +1,87 @@
1
+ import { InProcessCrossNodeBus } from '../in-process-cross-node-bus.js'
2
+ import { MemoryBroker } from '../memory-broker.js'
3
+ import type { CrossNodeBusTelemetry } from '../cross-node-bus-telemetry.js'
4
+
5
+ /**
6
+ * Options accepted by {@link createInProcessBusNetwork}.
7
+ */
8
+ export type CreateInProcessBusNetworkOptions = {
9
+ /** Number of buses to mint. Must be a positive integer. */
10
+ count: number
11
+ /** Forwarded to the shared {@link MemoryBroker}. Defaults to 1000. */
12
+ replayWindow?: number
13
+ /**
14
+ * Per-bus topic prefixes. When provided, length must equal `count`. Useful
15
+ * for simulating an N-services × M-nodes deployment against a single
16
+ * shared broker so cross-service eavesdrop can be exercised without an
17
+ * external transport.
18
+ */
19
+ topicPrefixes?: readonly string[]
20
+ /** Per-bus stable node ids. When provided, length must equal `count`. */
21
+ nodeIds?: readonly string[]
22
+ /** Per-bus telemetry sinks. When provided, length must equal `count`. */
23
+ telemetries?: readonly CrossNodeBusTelemetry[]
24
+ }
25
+
26
+ /**
27
+ * Disposable handle returned by {@link createInProcessBusNetwork}. Disposing
28
+ * it tears down every bus and the shared broker in reverse order.
29
+ */
30
+ export type InProcessBusNetwork = Disposable & {
31
+ readonly buses: readonly InProcessCrossNodeBus[]
32
+ readonly broker: MemoryBroker
33
+ }
34
+
35
+ /**
36
+ * Mints `count` {@link InProcessCrossNodeBus} instances backed by a single
37
+ * {@link MemoryBroker}. Publishes from any bus reach the others' subscribers
38
+ * exactly as they would over a real transport.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * using network = createInProcessBusNetwork({ count: 2 })
43
+ * const [a, b] = network.buses
44
+ *
45
+ * using sub = b.subscribe('topic', (message) => {
46
+ * // …
47
+ * })
48
+ * await a.publish('topic', { hello: 'world' })
49
+ * ```
50
+ */
51
+ export const createInProcessBusNetwork = (options: CreateInProcessBusNetworkOptions): InProcessBusNetwork => {
52
+ const { count, replayWindow, topicPrefixes, nodeIds, telemetries } = options
53
+ if (!Number.isInteger(count) || count <= 0) {
54
+ throw new RangeError(`createInProcessBusNetwork.count must be a positive integer, got ${String(count)}`)
55
+ }
56
+ for (const [name, list] of [
57
+ ['topicPrefixes', topicPrefixes],
58
+ ['nodeIds', nodeIds],
59
+ ['telemetries', telemetries],
60
+ ] as const) {
61
+ if (list !== undefined && list.length !== count) {
62
+ throw new RangeError(`createInProcessBusNetwork.${name} length must equal count (${count}), got ${list.length}`)
63
+ }
64
+ }
65
+ const broker = new MemoryBroker({ replayWindow })
66
+ const buses: InProcessCrossNodeBus[] = []
67
+ for (let index = 0; index < count; index += 1) {
68
+ buses.push(
69
+ new InProcessCrossNodeBus({
70
+ broker,
71
+ nodeId: nodeIds?.[index],
72
+ topicPrefix: topicPrefixes?.[index] ?? '',
73
+ telemetry: telemetries?.[index],
74
+ }),
75
+ )
76
+ }
77
+ return {
78
+ buses,
79
+ broker,
80
+ [Symbol.dispose]: () => {
81
+ for (let index = buses.length - 1; index >= 0; index -= 1) {
82
+ buses[index][Symbol.dispose]()
83
+ }
84
+ broker[Symbol.dispose]()
85
+ },
86
+ }
87
+ }
@@ -0,0 +1 @@
1
+ export * from './create-in-process-bus-network.js'
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Message envelope delivered to subscribers and emitted on the wire by every
3
+ * adapter. Adapters refuse messages whose {@link BusMessage.v} they do not
4
+ * recognise — see the rolling-deploy strategy in
5
+ * `docs/internal/cross-node-bus-spike.md` §12.
6
+ */
7
+ export type BusMessage = {
8
+ /** Wire-format version. Adapters refuse incompatible versions. */
9
+ readonly v: 1
10
+ /** `nodeId` of the publisher. */
11
+ readonly originId: string
12
+ /** ISO-8601 publish timestamp from the publisher's clock (diagnostic only). */
13
+ readonly emittedAt: string
14
+ /**
15
+ * Adapter-assigned per-topic monotonic id. Optional because non-sequencing
16
+ * adapters do not provide one.
17
+ */
18
+ readonly seq?: string
19
+ /** Caller-supplied payload. Must be JSON-serializable. */
20
+ readonly payload: unknown
21
+ }
22
+
23
+ /**
24
+ * Static description of a transport's behavior. Declared by every adapter
25
+ * and asserted by facades at registration time so misconfigured deployments
26
+ * fail loudly rather than serving stale data.
27
+ */
28
+ export type CrossNodeBusCapabilities = {
29
+ /** Messages survive process restarts. */
30
+ readonly persistent: boolean
31
+ /** Replay returns retained messages on demand. */
32
+ readonly replay: boolean
33
+ /** Adapter assigns a server-monotonic {@link BusMessage.seq}. */
34
+ readonly assignsSequence: boolean
35
+ }