@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,142 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { describe, expect, it, vi } from 'vitest';
54
+ import { createInProcessBusNetwork } from './create-in-process-bus-network.js';
55
+ describe('createInProcessBusNetwork', () => {
56
+ it('mints `count` buses sharing one broker', async () => {
57
+ const env_1 = { stack: [], error: void 0, hasError: false };
58
+ try {
59
+ const network = __addDisposableResource(env_1, createInProcessBusNetwork({ count: 3 }), false);
60
+ expect(network.buses).toHaveLength(3);
61
+ expect(network.broker).toBeDefined();
62
+ const handlerB = vi.fn();
63
+ const handlerC = vi.fn();
64
+ const _subB = __addDisposableResource(env_1, network.buses[1].subscribe('topic', handlerB), false);
65
+ const _subC = __addDisposableResource(env_1, network.buses[2].subscribe('topic', handlerC), false);
66
+ await network.buses[0].publish('topic', { from: 0 });
67
+ expect(handlerB).toHaveBeenCalledTimes(1);
68
+ expect(handlerC).toHaveBeenCalledTimes(1);
69
+ }
70
+ catch (e_1) {
71
+ env_1.error = e_1;
72
+ env_1.hasError = true;
73
+ }
74
+ finally {
75
+ __disposeResources(env_1);
76
+ }
77
+ });
78
+ it('honors per-bus topicPrefixes for multi-service simulation', async () => {
79
+ const env_2 = { stack: [], error: void 0, hasError: false };
80
+ try {
81
+ const network = __addDisposableResource(env_2, createInProcessBusNetwork({
82
+ count: 2,
83
+ topicPrefixes: ['svc-a/', 'svc-b/'],
84
+ }), false);
85
+ const [busA, busB] = network.buses;
86
+ const onB = vi.fn();
87
+ const eavesdrop = vi.fn();
88
+ const _subB = __addDisposableResource(env_2, busB.subscribe('events', onB), false);
89
+ const _foreign = __addDisposableResource(env_2, busB.subscribeForeign('svc-a/', 'events', eavesdrop), false);
90
+ await busA.publish('events', { from: 'a' });
91
+ expect(onB).not.toHaveBeenCalled();
92
+ expect(eavesdrop).toHaveBeenCalledTimes(1);
93
+ expect((eavesdrop.mock.calls[0]?.[0]).originId).toBe(busA.nodeId);
94
+ }
95
+ catch (e_2) {
96
+ env_2.error = e_2;
97
+ env_2.hasError = true;
98
+ }
99
+ finally {
100
+ __disposeResources(env_2);
101
+ }
102
+ });
103
+ it('honors per-bus nodeIds', () => {
104
+ const env_3 = { stack: [], error: void 0, hasError: false };
105
+ try {
106
+ const network = __addDisposableResource(env_3, createInProcessBusNetwork({
107
+ count: 2,
108
+ nodeIds: ['alice', 'bob'],
109
+ }), false);
110
+ expect(network.buses[0].nodeId).toBe('alice');
111
+ expect(network.buses[1].nodeId).toBe('bob');
112
+ }
113
+ catch (e_3) {
114
+ env_3.error = e_3;
115
+ env_3.hasError = true;
116
+ }
117
+ finally {
118
+ __disposeResources(env_3);
119
+ }
120
+ });
121
+ it('disposes every bus and the broker on teardown', () => {
122
+ const network = createInProcessBusNetwork({ count: 2 });
123
+ network[Symbol.dispose]();
124
+ for (const bus of network.buses) {
125
+ expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/);
126
+ }
127
+ expect(() => network.broker.publish('topic', 'a', null)).toThrow(/disposed/);
128
+ });
129
+ describe('input validation', () => {
130
+ it('rejects non-positive count', () => {
131
+ expect(() => createInProcessBusNetwork({ count: 0 })).toThrow(RangeError);
132
+ expect(() => createInProcessBusNetwork({ count: 1.5 })).toThrow(RangeError);
133
+ });
134
+ it('rejects mismatched topicPrefixes length', () => {
135
+ expect(() => createInProcessBusNetwork({ count: 2, topicPrefixes: ['only-one/'] })).toThrow(RangeError);
136
+ });
137
+ it('rejects mismatched nodeIds length', () => {
138
+ expect(() => createInProcessBusNetwork({ count: 2, nodeIds: ['only-one'] })).toThrow(RangeError);
139
+ });
140
+ });
141
+ });
142
+ //# sourceMappingURL=create-in-process-bus-network.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-in-process-bus-network.spec.js","sourceRoot":"","sources":["../../src/testing/create-in-process-bus-network.spec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export * from './create-in-process-bus-network.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAA"}
@@ -0,0 +1,2 @@
1
+ export * from './create-in-process-bus-network.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAA"}
package/esm/types.d.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
+ * Static description of a transport's behavior. Declared by every adapter
24
+ * and asserted by facades at registration time so misconfigured deployments
25
+ * fail loudly rather than serving stale data.
26
+ */
27
+ export type CrossNodeBusCapabilities = {
28
+ /** Messages survive process restarts. */
29
+ readonly persistent: boolean;
30
+ /** Replay returns retained messages on demand. */
31
+ readonly replay: boolean;
32
+ /** Adapter assigns a server-monotonic {@link BusMessage.seq}. */
33
+ readonly assignsSequence: boolean;
34
+ };
35
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,kEAAkE;IAClE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAA;IACb,iCAAiC;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAC1B,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,yCAAyC;IACzC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,iEAAiE;IACjE,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;CAClC,CAAA"}
package/esm/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@furystack/cross-node-bus",
3
+ "version": "1.0.0",
4
+ "description": "Transport-agnostic cross-process publish/subscribe primitive for FuryStack",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc --outDir ./esm"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./esm/index.d.ts",
12
+ "import": "./esm/index.js"
13
+ },
14
+ "./testing": {
15
+ "types": "./esm/testing/index.d.ts",
16
+ "import": "./esm/testing/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "esm",
21
+ "src"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/furystack/furystack.git"
26
+ },
27
+ "keywords": [
28
+ "FuryStack",
29
+ "pubsub",
30
+ "event-bus",
31
+ "cross-node",
32
+ "in-process"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "author": "Gallay Lajos <gallay.lajos@gmail.com>",
38
+ "license": "GPL-2.0",
39
+ "bugs": {
40
+ "url": "https://github.com/furystack/furystack/issues"
41
+ },
42
+ "homepage": "https://github.com/furystack/furystack",
43
+ "dependencies": {
44
+ "@furystack/inject": "^13.0.1",
45
+ "@furystack/utils": "^9.0.1"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.9.1",
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^4.1.7"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.0.0"
54
+ },
55
+ "sideEffects": false
56
+ }
@@ -0,0 +1,44 @@
1
+ import { createInjector } from '@furystack/inject'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { CrossNodeBusTelemetry, CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
4
+
5
+ describe('CrossNodeBusTelemetryToken', () => {
6
+ it('resolves a CrossNodeBusTelemetry instance', async () => {
7
+ await using injector = createInjector()
8
+ const telemetry = injector.get(CrossNodeBusTelemetryToken)
9
+ expect(telemetry).toBeInstanceOf(CrossNodeBusTelemetry)
10
+ })
11
+
12
+ it('disposes the telemetry instance with the injector scope', async () => {
13
+ const injector = createInjector()
14
+ const telemetry = injector.get(CrossNodeBusTelemetryToken)
15
+ const handler = vi.fn()
16
+ telemetry.addListener('onCrossNodePublished', handler)
17
+ await injector[Symbol.asyncDispose]()
18
+
19
+ telemetry.emit('onCrossNodePublished', { topic: 'x', originId: 'y', byteLength: 0 })
20
+ expect(handler).not.toHaveBeenCalled()
21
+ })
22
+
23
+ it('shares the singleton instance across child scopes', async () => {
24
+ await using injector = createInjector()
25
+ await using scopeA = injector.createScope({ owner: 'a' })
26
+ await using scopeB = injector.createScope({ owner: 'b' })
27
+
28
+ const telA = scopeA.get(CrossNodeBusTelemetryToken)
29
+ const telB = scopeB.get(CrossNodeBusTelemetryToken)
30
+
31
+ expect(telA).toBe(telB)
32
+ })
33
+
34
+ it('issues independent instances per root injector', () => {
35
+ const a = createInjector()
36
+ const b = createInjector()
37
+ try {
38
+ expect(a.get(CrossNodeBusTelemetryToken)).not.toBe(b.get(CrossNodeBusTelemetryToken))
39
+ } finally {
40
+ void a[Symbol.asyncDispose]()
41
+ void b[Symbol.asyncDispose]()
42
+ }
43
+ })
44
+ })
@@ -0,0 +1,69 @@
1
+ import { defineService, type Token } from '@furystack/inject'
2
+ import { EventHub, type ListenerErrorPayload } from '@furystack/utils'
3
+
4
+ /**
5
+ * Phase of the bus pipeline an `onCrossNodeError` event refers to. `serialize`
6
+ * is reserved for future adapters that fail when JSON-encoding payloads;
7
+ * the in-process default never produces it.
8
+ */
9
+ export type CrossNodeBusErrorPhase = 'publish' | 'subscribe' | 'subscribeForeign' | 'replay' | 'serialize'
10
+
11
+ /**
12
+ * Union of telemetry signals emitted by every {@link CrossNodeBus} adapter.
13
+ * Each adapter forwards into the same hub so subscribers can observe the
14
+ * whole bus surface from one place — independent of which transport is
15
+ * bound.
16
+ */
17
+ export type CrossNodeBusTelemetryEvents = {
18
+ /** Fired after a message has been accepted by the transport. */
19
+ onCrossNodePublished: { topic: string; originId: string; byteLength: number }
20
+ /**
21
+ * Fired once per message arriving at the bus, before local fan-out.
22
+ * `lagMs = Date.now() - Date.parse(message.emittedAt)`; clock skew can
23
+ * produce negative values, which adapters report verbatim.
24
+ */
25
+ onCrossNodeReceived: { topic: string; originId: string; lagMs: number }
26
+ onCrossNodeError: { topic: string; error: unknown; phase: CrossNodeBusErrorPhase }
27
+ /**
28
+ * Fired when an adapter that owns its replay buffer drops a retained
29
+ * message to honor the configured replay window. Operators alert on the
30
+ * trend so the window can be tuned before reconnecting clients start
31
+ * hitting `ReplayWindowExceededError`.
32
+ *
33
+ * Only adapters that own the buffer emit this signal — today that is
34
+ * {@link InProcessCrossNodeBus} via {@link MemoryBroker}. Network-broker
35
+ * adapters that delegate trimming to the broker (Redis Streams' `MAXLEN`,
36
+ * NATS JetStream's max-bytes) cannot observe individual evictions on the
37
+ * client side; consumers needing that signal should read it from the
38
+ * broker's native metrics (e.g. `redis_streams_length` from the Prom
39
+ * exporter).
40
+ */
41
+ onCrossNodeWindowEvicted: { topic: string; evictedSeq: string; retainedCount: number }
42
+ onListenerError: ListenerErrorPayload
43
+ }
44
+
45
+ /**
46
+ * Application-facing telemetry surface for the cross-node bus. Subscribers
47
+ * use the standard {@link EventHub} `addListener` / `subscribe` API.
48
+ */
49
+ export class CrossNodeBusTelemetry extends EventHub<CrossNodeBusTelemetryEvents> {}
50
+
51
+ /**
52
+ * DI token for the shared {@link CrossNodeBusTelemetry} instance.
53
+ *
54
+ * Singleton because the bus token itself is singleton — co-locating both at
55
+ * the root injector keeps every override factory (including transport
56
+ * adapters) free to inject telemetry without lifetime-compatibility
57
+ * gymnastics. Each test still gets isolation by minting its own root
58
+ * injector with `createInjector()`.
59
+ */
60
+ export const CrossNodeBusTelemetryToken: Token<CrossNodeBusTelemetry, 'singleton'> = defineService({
61
+ name: 'furystack/cross-node-bus/CrossNodeBusTelemetry',
62
+ lifetime: 'singleton',
63
+ factory: ({ onDispose }) => {
64
+ const telemetry = new CrossNodeBusTelemetry()
65
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
66
+ onDispose(() => telemetry[Symbol.dispose]())
67
+ return telemetry
68
+ },
69
+ })
@@ -0,0 +1,41 @@
1
+ import { createInjector } from '@furystack/inject'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { CrossNodeBus } from './cross-node-bus.js'
4
+ import { CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
5
+ import { defineInProcessCrossNodeBus } from './define-in-process-cross-node-bus.js'
6
+ import { InProcessCrossNodeBus } from './in-process-cross-node-bus.js'
7
+
8
+ describe('CrossNodeBus token', () => {
9
+ it('resolves an InProcessCrossNodeBus by default', async () => {
10
+ await using injector = createInjector()
11
+ const bus = injector.get(CrossNodeBus)
12
+ expect(bus).toBeInstanceOf(InProcessCrossNodeBus)
13
+ })
14
+
15
+ it('disposes the bus when the injector scope tears down', async () => {
16
+ const injector = createInjector()
17
+ const bus = injector.get(CrossNodeBus)
18
+ await injector[Symbol.asyncDispose]()
19
+ expect(() => bus.subscribe('topic', () => undefined)).toThrow(/disposed/)
20
+ })
21
+
22
+ it('honors override bindings via defineInProcessCrossNodeBus', async () => {
23
+ await using injector = createInjector()
24
+ injector.bind(CrossNodeBus, defineInProcessCrossNodeBus({ nodeId: 'svc-a-1', topicPrefix: 'svc-a/' }))
25
+
26
+ const bus = injector.get(CrossNodeBus)
27
+ expect(bus.nodeId).toBe('svc-a-1')
28
+ })
29
+
30
+ it('publishes drive the scoped CrossNodeBusTelemetryToken', async () => {
31
+ await using injector = createInjector()
32
+ const telemetry = injector.get(CrossNodeBusTelemetryToken)
33
+ const handler = vi.fn()
34
+ using _sub = telemetry.subscribe('onCrossNodePublished', handler)
35
+
36
+ const bus = injector.get(CrossNodeBus)
37
+ await bus.publish('topic', { hi: 'there' })
38
+
39
+ expect(handler).toHaveBeenCalledTimes(1)
40
+ })
41
+ })
@@ -0,0 +1,92 @@
1
+ import { defineService, type Token } from '@furystack/inject'
2
+ import { defineInProcessCrossNodeBus } from './define-in-process-cross-node-bus.js'
3
+ import type { BusMessage, CrossNodeBusCapabilities } from './types.js'
4
+
5
+ /**
6
+ * Transport-agnostic publish/subscribe primitive. Implementations talk to a
7
+ * concrete broker (in-process map, Redis Streams, …); facades layer typed
8
+ * event contracts on top.
9
+ *
10
+ * Self-delivery is on by default — a publisher receives its own messages.
11
+ * Subscribers that need a local-vs-remote distinction either filter on
12
+ * `message.originId === bus.nodeId` or use
13
+ * {@link CrossNodeBus.subscribeRemoteOnly}.
14
+ */
15
+ export interface CrossNodeBus extends Disposable {
16
+ /** Stable, unique id of this node. Included in every published message. */
17
+ readonly nodeId: string
18
+
19
+ /** Static description of what this adapter can do. */
20
+ readonly capabilities: CrossNodeBusCapabilities
21
+
22
+ /**
23
+ * Publishes `payload` on `topic`. Resolves once the message has been
24
+ * accepted by the underlying transport (not when it has been delivered to
25
+ * all subscribers).
26
+ */
27
+ publish(topic: string, payload: unknown): Promise<void>
28
+
29
+ /**
30
+ * Subscribes to every message published on `topic`, including ones
31
+ * originating from this node.
32
+ */
33
+ subscribe(topic: string, handler: (message: BusMessage) => void): Disposable
34
+
35
+ /**
36
+ * Convenience for the common "I only care about messages from other nodes"
37
+ * pattern. Equivalent to {@link CrossNodeBus.subscribe} + filter on
38
+ * `message.originId !== bus.nodeId`.
39
+ */
40
+ subscribeRemoteOnly(topic: string, handler: (message: BusMessage) => void): Disposable
41
+
42
+ /**
43
+ * Subscribe to a topic owned by another `topicPrefix`. Explicit, greppable
44
+ * cross-service eavesdrop. Adapters that lack the underlying capability
45
+ * throw at registration time.
46
+ */
47
+ subscribeForeign(prefix: string, topic: string, handler: (message: BusMessage) => void): Disposable
48
+
49
+ /**
50
+ * Replay messages on `topic` whose `seq` is greater than `fromSeq`. Throws
51
+ * synchronously when {@link CrossNodeBusCapabilities.replay} is `false` or
52
+ * when `fromSeq` is older than the adapter's retained window — facades
53
+ * fall back to a full snapshot in the latter case.
54
+ */
55
+ replay(topic: string, fromSeq: string): AsyncIterable<BusMessage>
56
+
57
+ /**
58
+ * Compares two adapter-issued seq tokens from the **same topic**. Returns a
59
+ * negative number when `a` precedes `b`, zero when equal, a positive number
60
+ * when `a` follows `b`. Facades use this for dedup and "have we seen newer?"
61
+ * checks without leaking the adapter-specific seq encoding.
62
+ *
63
+ * Behavior across topics, adapters, or for tokens this adapter never issued
64
+ * is undefined.
65
+ */
66
+ compareSeq(a: string, b: string): number
67
+
68
+ /**
69
+ * Returns the oldest retained seq for `topic`, or `undefined` when nothing
70
+ * is currently retained. Throws synchronously when
71
+ * {@link CrossNodeBusCapabilities.replay} is `false`. Facades use this to
72
+ * decide whether a delta replay is feasible before calling
73
+ * {@link CrossNodeBus.replay}.
74
+ */
75
+ oldestSeq(topic: string): string | undefined
76
+ }
77
+
78
+ /**
79
+ * Shared {@link CrossNodeBus} token. Resolves an `InProcessCrossNodeBus` by
80
+ * default — single-node deployments work without configuration. Multi-node
81
+ * deployments override the binding with a transport adapter, e.g.
82
+ * `defineRedisCrossNodeBusAdapter({ … })`.
83
+ *
84
+ * Singleton: a single bus per injector tree is the right semantic for
85
+ * cross-process publish/subscribe. Tests get isolation by minting their own
86
+ * root injector with `createInjector()`.
87
+ */
88
+ export const CrossNodeBus: Token<CrossNodeBus, 'singleton'> = defineService({
89
+ name: 'furystack/cross-node-bus/CrossNodeBus',
90
+ lifetime: 'singleton',
91
+ factory: defineInProcessCrossNodeBus(),
92
+ })
@@ -0,0 +1,38 @@
1
+ import type { ServiceFactory } from '@furystack/inject'
2
+ import { CrossNodeBusTelemetryToken } from './cross-node-bus-telemetry.js'
3
+ import { InProcessCrossNodeBus, type InProcessCrossNodeBusOptions } from './in-process-cross-node-bus.js'
4
+ import type { CrossNodeBus } from './cross-node-bus.js'
5
+
6
+ /**
7
+ * Options accepted by {@link defineInProcessCrossNodeBus}. `telemetry` is
8
+ * intentionally absent — the factory always injects
9
+ * {@link CrossNodeBusTelemetryToken} from the surrounding scope.
10
+ */
11
+ export type DefineInProcessCrossNodeBusOptions = Omit<InProcessCrossNodeBusOptions, 'telemetry'>
12
+
13
+ /**
14
+ * Returns a {@link ServiceFactory} bound to {@link CrossNodeBus}. Use it to
15
+ * override the default factory at boot:
16
+ *
17
+ * ```ts
18
+ * injector.bind(
19
+ * CrossNodeBus,
20
+ * defineInProcessCrossNodeBus({ topicPrefix: 'svc-a/' }),
21
+ * )
22
+ * ```
23
+ *
24
+ * Mirrors the `defineXxxCrossNodeBusAdapter` shape future transport
25
+ * adapters expose. Wires telemetry and disposal into the surrounding
26
+ * injector scope.
27
+ */
28
+ export const defineInProcessCrossNodeBus = (
29
+ options: DefineInProcessCrossNodeBusOptions = {},
30
+ ): ServiceFactory<CrossNodeBus> => {
31
+ return ({ inject, onDispose }) => {
32
+ const telemetry = inject(CrossNodeBusTelemetryToken)
33
+ const bus = new InProcessCrossNodeBus({ ...options, telemetry })
34
+ // eslint-disable-next-line furystack/prefer-using-wrapper -- delegated to onDispose
35
+ onDispose(() => bus[Symbol.dispose]())
36
+ return bus
37
+ }
38
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ReplayWindowExceededError, UnsupportedCapabilityError } from './errors.js'
3
+
4
+ describe('errors', () => {
5
+ describe('ReplayWindowExceededError', () => {
6
+ it('captures the topic, fromSeq and oldestRetainedSeq', () => {
7
+ const error = new ReplayWindowExceededError('topic', '5', '7')
8
+ expect(error.name).toBe('ReplayWindowExceededError')
9
+ expect(error.topic).toBe('topic')
10
+ expect(error.fromSeq).toBe('5')
11
+ expect(error.oldestRetainedSeq).toBe('7')
12
+ expect(error.message).toContain('topic')
13
+ expect(error.message).toContain('5')
14
+ expect(error.message).toContain('7')
15
+ })
16
+
17
+ it('reports "none" when no messages are retained', () => {
18
+ const error = new ReplayWindowExceededError('topic', '5', undefined)
19
+ expect(error.oldestRetainedSeq).toBeUndefined()
20
+ expect(error.message).toContain('none')
21
+ })
22
+ })
23
+
24
+ describe('UnsupportedCapabilityError', () => {
25
+ it('captures the missing capability name', () => {
26
+ const error = new UnsupportedCapabilityError('replay')
27
+ expect(error.name).toBe('UnsupportedCapabilityError')
28
+ expect(error.capability).toBe('replay')
29
+ expect(error.message).toContain('replay')
30
+ })
31
+ })
32
+ })
package/src/errors.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { CrossNodeBusCapabilities } from './types.js'
2
+
3
+ /**
4
+ * Thrown synchronously by {@link CrossNodeBus.replay} when `fromSeq` falls
5
+ * outside the adapter's retained window. Facades catch this and fall back
6
+ * to a full-snapshot path.
7
+ */
8
+ export class ReplayWindowExceededError extends Error {
9
+ public readonly topic: string
10
+ public readonly fromSeq: string
11
+ public readonly oldestRetainedSeq: string | undefined
12
+
13
+ constructor(topic: string, fromSeq: string, oldestRetainedSeq: string | undefined) {
14
+ super(
15
+ `Replay window exceeded for topic "${topic}": requested fromSeq=${fromSeq}, oldest retained=${
16
+ oldestRetainedSeq ?? 'none'
17
+ }`,
18
+ )
19
+ this.name = 'ReplayWindowExceededError'
20
+ this.topic = topic
21
+ this.fromSeq = fromSeq
22
+ this.oldestRetainedSeq = oldestRetainedSeq
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Thrown synchronously by an adapter when a caller invokes a method that
28
+ * requires a capability the adapter does not advertise.
29
+ */
30
+ export class UnsupportedCapabilityError extends Error {
31
+ public readonly capability: keyof CrossNodeBusCapabilities
32
+
33
+ constructor(capability: keyof CrossNodeBusCapabilities) {
34
+ super(`Adapter does not support capability: ${capability}`)
35
+ this.name = 'UnsupportedCapabilityError'
36
+ this.capability = capability
37
+ }
38
+ }