@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.
- package/CHANGELOG.md +78 -0
- package/README.md +49 -0
- package/esm/cross-node-bus-telemetry.d.ts +74 -0
- package/esm/cross-node-bus-telemetry.d.ts.map +1 -0
- package/esm/cross-node-bus-telemetry.js +28 -0
- package/esm/cross-node-bus-telemetry.js.map +1 -0
- package/esm/cross-node-bus-telemetry.spec.d.ts +2 -0
- package/esm/cross-node-bus-telemetry.spec.d.ts.map +1 -0
- package/esm/cross-node-bus-telemetry.spec.js +115 -0
- package/esm/cross-node-bus-telemetry.spec.js.map +1 -0
- package/esm/cross-node-bus.d.ts +78 -0
- package/esm/cross-node-bus.d.ts.map +1 -0
- package/esm/cross-node-bus.js +18 -0
- package/esm/cross-node-bus.js.map +1 -0
- package/esm/cross-node-bus.spec.d.ts +2 -0
- package/esm/cross-node-bus.spec.d.ts.map +1 -0
- package/esm/cross-node-bus.spec.js +123 -0
- package/esm/cross-node-bus.spec.js.map +1 -0
- package/esm/define-in-process-cross-node-bus.d.ts +26 -0
- package/esm/define-in-process-cross-node-bus.d.ts.map +1 -0
- package/esm/define-in-process-cross-node-bus.js +27 -0
- package/esm/define-in-process-cross-node-bus.js.map +1 -0
- package/esm/errors.d.ts +21 -0
- package/esm/errors.d.ts.map +1 -0
- package/esm/errors.js +30 -0
- package/esm/errors.js.map +1 -0
- package/esm/errors.spec.d.ts +2 -0
- package/esm/errors.spec.d.ts.map +1 -0
- package/esm/errors.spec.js +30 -0
- package/esm/errors.spec.js.map +1 -0
- package/esm/in-process-cross-node-bus.d.ts +58 -0
- package/esm/in-process-cross-node-bus.d.ts.map +1 -0
- package/esm/in-process-cross-node-bus.js +196 -0
- package/esm/in-process-cross-node-bus.js.map +1 -0
- package/esm/in-process-cross-node-bus.spec.d.ts +2 -0
- package/esm/in-process-cross-node-bus.spec.d.ts.map +1 -0
- package/esm/in-process-cross-node-bus.spec.js +737 -0
- package/esm/in-process-cross-node-bus.spec.js.map +1 -0
- package/esm/index.d.ts +8 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +7 -0
- package/esm/index.js.map +1 -0
- package/esm/memory-broker.d.ts +74 -0
- package/esm/memory-broker.d.ts.map +1 -0
- package/esm/memory-broker.js +156 -0
- package/esm/memory-broker.js.map +1 -0
- package/esm/memory-broker.spec.d.ts +2 -0
- package/esm/memory-broker.spec.d.ts.map +1 -0
- package/esm/memory-broker.spec.js +497 -0
- package/esm/memory-broker.spec.js.map +1 -0
- package/esm/testing/create-in-process-bus-network.d.ts +49 -0
- package/esm/testing/create-in-process-bus-network.d.ts.map +1 -0
- package/esm/testing/create-in-process-bus-network.js +54 -0
- package/esm/testing/create-in-process-bus-network.js.map +1 -0
- package/esm/testing/create-in-process-bus-network.spec.d.ts +2 -0
- package/esm/testing/create-in-process-bus-network.spec.d.ts.map +1 -0
- package/esm/testing/create-in-process-bus-network.spec.js +142 -0
- package/esm/testing/create-in-process-bus-network.spec.js.map +1 -0
- package/esm/testing/index.d.ts +2 -0
- package/esm/testing/index.d.ts.map +1 -0
- package/esm/testing/index.js +2 -0
- package/esm/testing/index.js.map +1 -0
- package/esm/types.d.ts +35 -0
- package/esm/types.d.ts.map +1 -0
- package/esm/types.js +2 -0
- package/esm/types.js.map +1 -0
- package/package.json +56 -0
- package/src/cross-node-bus-telemetry.spec.ts +44 -0
- package/src/cross-node-bus-telemetry.ts +69 -0
- package/src/cross-node-bus.spec.ts +41 -0
- package/src/cross-node-bus.ts +92 -0
- package/src/define-in-process-cross-node-bus.ts +38 -0
- package/src/errors.spec.ts +32 -0
- package/src/errors.ts +38 -0
- package/src/in-process-cross-node-bus.spec.ts +428 -0
- package/src/in-process-cross-node-bus.ts +248 -0
- package/src/index.ts +7 -0
- package/src/memory-broker.spec.ts +282 -0
- package/src/memory-broker.ts +199 -0
- package/src/testing/create-in-process-bus-network.spec.ts +73 -0
- package/src/testing/create-in-process-bus-network.ts +87 -0
- package/src/testing/index.ts +1 -0
- 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
|
+
}
|