@dxos/messaging 0.9.1-staging.ee54ba693a → 0.11.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 (88) hide show
  1. package/dist/lib/chunk-memory-signal-manager.mjs +652 -0
  2. package/dist/lib/chunk-memory-signal-manager.mjs.map +1 -0
  3. package/dist/lib/index.mjs +377 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +288 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/index.d.ts +0 -1
  8. package/dist/types/src/index.d.ts.map +1 -1
  9. package/dist/types/src/messenger.blueprint-test.d.ts.map +1 -1
  10. package/dist/types/src/messenger.d.ts +1 -1
  11. package/dist/types/src/messenger.d.ts.map +1 -1
  12. package/dist/types/src/signal-manager/edge-signal-manager.d.ts +21 -4
  13. package/dist/types/src/signal-manager/edge-signal-manager.d.ts.map +1 -1
  14. package/dist/types/src/signal-manager/edge-signal-manager.test.d.ts +2 -0
  15. package/dist/types/src/signal-manager/edge-signal-manager.test.d.ts.map +1 -0
  16. package/dist/types/src/signal-manager/index.d.ts +1 -2
  17. package/dist/types/src/signal-manager/index.d.ts.map +1 -1
  18. package/dist/types/src/signal-manager/memory-signal-manager.d.ts +12 -10
  19. package/dist/types/src/signal-manager/memory-signal-manager.d.ts.map +1 -1
  20. package/dist/types/src/signal-manager/memory-signal-manager.test.d.ts +2 -0
  21. package/dist/types/src/signal-manager/memory-signal-manager.test.d.ts.map +1 -0
  22. package/dist/types/src/signal-manager/signal-manager.d.ts +5 -0
  23. package/dist/types/src/signal-manager/signal-manager.d.ts.map +1 -1
  24. package/dist/types/src/signal-manager/utils.d.ts +6 -4
  25. package/dist/types/src/signal-manager/utils.d.ts.map +1 -1
  26. package/dist/types/src/signal-methods.d.ts +33 -22
  27. package/dist/types/src/signal-methods.d.ts.map +1 -1
  28. package/dist/types/src/testing/index.d.ts +1 -0
  29. package/dist/types/src/testing/index.d.ts.map +1 -1
  30. package/dist/types/src/testing/test-edge-mesh.d.ts +61 -0
  31. package/dist/types/src/testing/test-edge-mesh.d.ts.map +1 -0
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +19 -19
  34. package/src/index.ts +0 -1
  35. package/src/messenger.blueprint-test.ts +0 -25
  36. package/src/messenger.node.test.ts +1 -1
  37. package/src/messenger.ts +42 -18
  38. package/src/signal-manager/edge-signal-manager.test.ts +193 -0
  39. package/src/signal-manager/edge-signal-manager.ts +145 -12
  40. package/src/signal-manager/index.ts +1 -2
  41. package/src/signal-manager/memory-signal-manager.test.ts +199 -0
  42. package/src/signal-manager/memory-signal-manager.ts +87 -47
  43. package/src/signal-manager/signal-manager.ts +7 -0
  44. package/src/signal-manager/utils.ts +30 -21
  45. package/src/signal-methods.ts +37 -26
  46. package/src/testing/index.ts +1 -0
  47. package/src/testing/test-edge-mesh.ts +196 -0
  48. package/src/testing/utils.ts +2 -2
  49. package/dist/lib/neutral/chunk-EC4H56J5.mjs +0 -497
  50. package/dist/lib/neutral/chunk-EC4H56J5.mjs.map +0 -7
  51. package/dist/lib/neutral/index.mjs +0 -1249
  52. package/dist/lib/neutral/index.mjs.map +0 -7
  53. package/dist/lib/neutral/meta.json +0 -1
  54. package/dist/lib/neutral/testing/index.mjs +0 -155
  55. package/dist/lib/neutral/testing/index.mjs.map +0 -7
  56. package/dist/types/src/signal-client/index.d.ts +0 -2
  57. package/dist/types/src/signal-client/index.d.ts.map +0 -1
  58. package/dist/types/src/signal-client/signal-client-monitor.d.ts +0 -30
  59. package/dist/types/src/signal-client/signal-client-monitor.d.ts.map +0 -1
  60. package/dist/types/src/signal-client/signal-client.d.ts +0 -59
  61. package/dist/types/src/signal-client/signal-client.d.ts.map +0 -1
  62. package/dist/types/src/signal-client/signal-client.node.test.d.ts +0 -2
  63. package/dist/types/src/signal-client/signal-client.node.test.d.ts.map +0 -1
  64. package/dist/types/src/signal-client/signal-local-state.d.ts +0 -38
  65. package/dist/types/src/signal-client/signal-local-state.d.ts.map +0 -1
  66. package/dist/types/src/signal-client/signal-rpc-client-monitor.d.ts +0 -6
  67. package/dist/types/src/signal-client/signal-rpc-client-monitor.d.ts.map +0 -1
  68. package/dist/types/src/signal-client/signal-rpc-client.d.ts +0 -47
  69. package/dist/types/src/signal-client/signal-rpc-client.d.ts.map +0 -1
  70. package/dist/types/src/signal-client/signal-rpc-client.node.test.d.ts +0 -2
  71. package/dist/types/src/signal-client/signal-rpc-client.node.test.d.ts.map +0 -1
  72. package/dist/types/src/signal-manager/websocket-signal-manager-monitor.d.ts +0 -8
  73. package/dist/types/src/signal-manager/websocket-signal-manager-monitor.d.ts.map +0 -1
  74. package/dist/types/src/signal-manager/websocket-signal-manager.d.ts +0 -41
  75. package/dist/types/src/signal-manager/websocket-signal-manager.d.ts.map +0 -1
  76. package/dist/types/src/signal-manager/websocket-signal-manager.node.test.d.ts +0 -2
  77. package/dist/types/src/signal-manager/websocket-signal-manager.node.test.d.ts.map +0 -1
  78. package/src/signal-client/index.ts +0 -5
  79. package/src/signal-client/signal-client-monitor.ts +0 -111
  80. package/src/signal-client/signal-client.node.test.ts +0 -185
  81. package/src/signal-client/signal-client.ts +0 -339
  82. package/src/signal-client/signal-local-state.ts +0 -174
  83. package/src/signal-client/signal-rpc-client-monitor.ts +0 -15
  84. package/src/signal-client/signal-rpc-client.node.test.ts +0 -102
  85. package/src/signal-client/signal-rpc-client.ts +0 -227
  86. package/src/signal-manager/websocket-signal-manager-monitor.ts +0 -20
  87. package/src/signal-manager/websocket-signal-manager.node.test.ts +0 -112
  88. package/src/signal-manager/websocket-signal-manager.ts +0 -188
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/messaging",
3
- "version": "0.9.1-staging.ee54ba693a",
3
+ "version": "0.11.0",
4
4
  "description": "Messaging",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -16,12 +16,12 @@
16
16
  ".": {
17
17
  "source": "./src/index.ts",
18
18
  "types": "./dist/types/src/index.d.ts",
19
- "default": "./dist/lib/neutral/index.mjs"
19
+ "import": "./dist/lib/index.mjs"
20
20
  },
21
21
  "./testing": {
22
22
  "source": "./src/testing/index.ts",
23
23
  "types": "./dist/types/src/testing/index.d.ts",
24
- "default": "./dist/lib/neutral/testing/index.mjs"
24
+ "import": "./dist/lib/testing.mjs"
25
25
  }
26
26
  },
27
27
  "types": "dist/types/src/index.d.ts",
@@ -31,26 +31,26 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
+ "effect": "3.21.4",
34
35
  "isomorphic-ws": "^5.0.0",
35
36
  "ws": "^8.17.1",
36
- "@dxos/async": "0.9.1-staging.ee54ba693a",
37
- "@dxos/codec-protobuf": "0.9.1-staging.ee54ba693a",
38
- "@dxos/edge-client": "0.9.1-staging.ee54ba693a",
39
- "@dxos/invariant": "0.9.1-staging.ee54ba693a",
40
- "@dxos/keys": "0.9.1-staging.ee54ba693a",
41
- "@dxos/credentials": "0.9.1-staging.ee54ba693a",
42
- "@dxos/context": "0.9.1-staging.ee54ba693a",
43
- "@dxos/log": "0.9.1-staging.ee54ba693a",
44
- "@dxos/node-std": "0.9.1-staging.ee54ba693a",
45
- "@dxos/protocols": "0.9.1-staging.ee54ba693a",
46
- "@dxos/rpc": "0.9.1-staging.ee54ba693a",
47
- "@dxos/util": "0.9.1-staging.ee54ba693a",
48
- "@dxos/tracing": "0.9.1-staging.ee54ba693a"
37
+ "@dxos/async": "0.11.0",
38
+ "@dxos/codec-protobuf": "0.11.0",
39
+ "@dxos/context": "0.11.0",
40
+ "@dxos/credentials": "0.11.0",
41
+ "@dxos/edge-client": "0.11.0",
42
+ "@dxos/invariant": "0.11.0",
43
+ "@dxos/keys": "0.11.0",
44
+ "@dxos/log": "0.11.0",
45
+ "@dxos/node-std": "0.11.0",
46
+ "@dxos/protocols": "0.11.0",
47
+ "@dxos/rpc": "0.11.0",
48
+ "@dxos/tracing": "0.11.0",
49
+ "@dxos/util": "0.11.0"
49
50
  },
50
51
  "devDependencies": {
51
- "@dxos/signal": "0.9.1-staging.ee54ba693a",
52
- "@dxos/keyring": "0.9.1-staging.ee54ba693a",
53
- "@dxos/test-utils": "0.9.1-staging.ee54ba693a"
52
+ "@dxos/keyring": "0.11.0",
53
+ "@dxos/test-utils": "0.11.0"
54
54
  },
55
55
  "publishConfig": {
56
56
  "access": "public"
package/src/index.ts CHANGED
@@ -3,6 +3,5 @@
3
3
  //
4
4
 
5
5
  export * from './messenger';
6
- export * from './signal-client';
7
6
  export * from './signal-manager';
8
7
  export * from './signal-methods';
@@ -6,9 +6,7 @@ import { describe, expect, onTestFinished, test } from 'vitest';
6
6
 
7
7
  import { asyncTimeout, latch, sleep } from '@dxos/async';
8
8
  import { Context } from '@dxos/context';
9
- import { range } from '@dxos/util';
10
9
 
11
- import { WebsocketSignalManager } from './signal-manager';
12
10
  import { type Message } from './signal-methods';
13
11
  import { PAYLOAD_1, PAYLOAD_2, PAYLOAD_3, TestBuilder, messageEqual } from './testing';
14
12
 
@@ -313,27 +311,4 @@ export const messengerTests = (signalManagerFactory: TestBuilder['createSignalMa
313
311
  expect(count).toEqual(1);
314
312
  });
315
313
  });
316
-
317
- describe.skip('load', () => {
318
- test('many connections to KUBE', { timeout: 5_000 }, async () => {
319
- const builder = new TestBuilder({
320
- signalManagerFactory: async () =>
321
- new WebsocketSignalManager([{ server: 'wss://dev.kube.dxos.org/.well-known/dx/signal' }]),
322
- });
323
- void range(100).map(async () => {
324
- const peer = await builder.createPeer();
325
-
326
- void peer.messenger.sendMessage(Context.default(), {
327
- author: peer.peerInfo,
328
- recipient: peer.peerInfo,
329
- payload: {
330
- type_url: 'dxos.test',
331
- value: Buffer.from('TEST'),
332
- },
333
- });
334
- });
335
-
336
- await sleep(1000000);
337
- });
338
- });
339
314
  };
@@ -8,7 +8,7 @@ import { messengerTests } from './messenger.blueprint-test';
8
8
  import { MemorySignalManager, MemorySignalManagerContext } from './signal-manager';
9
9
 
10
10
  // TODO(mykola): Use EDGE signal server.
11
- describe('Messenger with WebsocketSignalManager', () => {
11
+ describe('Messenger with MemorySignalManager', () => {
12
12
  let context: MemorySignalManagerContext;
13
13
  beforeEach(async () => {
14
14
  context = new MemorySignalManagerContext();
package/src/messenger.ts CHANGED
@@ -72,12 +72,6 @@ export class Messenger {
72
72
  this._ctx = new Context({
73
73
  onError: (err) => log.catch(err),
74
74
  });
75
- this._ctx.onDispose(
76
- this._signalManager.onMessage.on(async (message) => {
77
- log('received message', { from: message.author });
78
- await this._handleMessage(message);
79
- }),
80
- );
81
75
 
82
76
  // Clear the map periodically.
83
77
  scheduleTaskInterval(
@@ -97,11 +91,18 @@ export class Messenger {
97
91
  return;
98
92
  }
99
93
  this._closed = true;
94
+ // Disposing the context tears down every still-active subscription — each `listen` registers its
95
+ // transport unsubscribe via `onDispose`, and a handle unsubscribed manually has already cleared
96
+ // its registration. The offline/online cycle keeps the messenger open (only signaling toggles),
97
+ // so subscriptions are torn down only on a real close.
100
98
  await this._ctx.dispose();
101
99
  }
102
100
 
103
- async sendMessage(ctx: Context, { author, recipient, payload }: Message): Promise<void> {
101
+ async sendMessage(ctx: Context, message: Message): Promise<void> {
104
102
  invariant(!this._closed, 'Closed');
103
+ const { author, recipient, payload } = message;
104
+ // Messenger provides reliable point-to-point delivery; broadcasts are not routed here.
105
+ invariant(recipient, 'Recipient is required');
105
106
  const messageContext = this._ctx.derive();
106
107
 
107
108
  const reliablePayload: ReliablePayload = {
@@ -175,22 +176,39 @@ export class Messenger {
175
176
  onMessage: OnMessage;
176
177
  }): Promise<ListeningHandle> {
177
178
  invariant(!this._closed, 'Closed');
178
-
179
- await this._signalManager.subscribeMessages(peer);
180
- let listeners: Set<OnMessage> | undefined;
181
179
  invariant(peer.peerKey, 'Peer key is required');
180
+ const peerKey = peer.peerKey;
181
+
182
+ // Multiplexing is owned by the signal manager. The messenger keeps only reliable delivery
183
+ // (ACK/dedup) and payloadType routing, and passes the transport unsubscribe back through the
184
+ // handle. The unsubscribe is also registered on the messenger context so `close` tears every
185
+ // subscription down; a handle unsubscribed manually clears that registration first.
186
+ const unsubscribe = await this._signalManager.subscribeMessages({
187
+ peer,
188
+ onMessage: (message) => {
189
+ // Subscriptions can outlive a close (they are torn down on dispose, and survive offline/online
190
+ // cycles), so ignore late deliveries and never let a handler rejection escape as unhandled.
191
+ if (this._closed) {
192
+ return;
193
+ }
194
+ log('received message', { from: message.author });
195
+ void this._handleMessage(message).catch((err) => log.catch(err));
196
+ },
197
+ });
198
+ const clearDispose = this._ctx.onDispose(unsubscribe);
182
199
 
200
+ let listeners: Set<OnMessage> | undefined;
183
201
  if (!payloadType) {
184
- listeners = this._defaultListeners.get(peer.peerKey);
202
+ listeners = this._defaultListeners.get(peerKey);
185
203
  if (!listeners) {
186
204
  listeners = new Set();
187
- this._defaultListeners.set(peer.peerKey, listeners);
205
+ this._defaultListeners.set(peerKey, listeners);
188
206
  }
189
207
  } else {
190
- listeners = this._listeners.get({ peerId: peer.peerKey, payloadType });
208
+ listeners = this._listeners.get({ peerId: peerKey, payloadType });
191
209
  if (!listeners) {
192
210
  listeners = new Set();
193
- this._listeners.set({ peerId: peer.peerKey, payloadType }, listeners);
211
+ this._listeners.set({ peerId: peerKey, payloadType }, listeners);
194
212
  }
195
213
  }
196
214
 
@@ -198,7 +216,9 @@ export class Messenger {
198
216
 
199
217
  return {
200
218
  unsubscribe: async () => {
219
+ clearDispose();
201
220
  listeners!.delete(onMessage);
221
+ await unsubscribe();
202
222
  },
203
223
  };
204
224
  }
@@ -238,8 +258,10 @@ export class Messenger {
238
258
  }
239
259
  }
240
260
 
241
- private async _handleReliablePayload({ author, recipient, payload }: Message): Promise<void> {
261
+ private async _handleReliablePayload(message: Message): Promise<void> {
262
+ const { author, recipient, payload } = message;
242
263
  invariant(payload.type_url === 'dxos.mesh.messaging.ReliablePayload');
264
+ invariant(recipient, 'Recipient is required');
243
265
  const reliablePayload: ReliablePayload = ReliablePayload.decode(payload.value, { preserveAny: true });
244
266
 
245
267
  log('handling message', { messageId: reliablePayload.messageId });
@@ -299,9 +321,11 @@ export class Messenger {
299
321
  }
300
322
 
301
323
  private async _callListeners(message: Message): Promise<void> {
324
+ const { recipient } = message;
325
+ invariant(recipient?.peerKey, 'Peer key is required');
326
+ const peerKey = recipient.peerKey;
302
327
  {
303
- invariant(message.recipient.peerKey, 'Peer key is required');
304
- const defaultListenerMap = this._defaultListeners.get(message.recipient.peerKey);
328
+ const defaultListenerMap = this._defaultListeners.get(peerKey);
305
329
  if (defaultListenerMap) {
306
330
  for (const listener of defaultListenerMap) {
307
331
  await listener(message);
@@ -311,7 +335,7 @@ export class Messenger {
311
335
 
312
336
  {
313
337
  const listenerMap = this._listeners.get({
314
- peerId: message.recipient.peerKey,
338
+ peerId: peerKey,
315
339
  payloadType: message.payload.type_url,
316
340
  });
317
341
  if (listenerMap) {
@@ -0,0 +1,193 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Context } from '@dxos/context';
8
+ import { PublicKey } from '@dxos/keys';
9
+
10
+ import { type Message, type PeerInfo } from '../signal-methods';
11
+ import { TestEdgeMesh } from '../testing';
12
+ import { EdgeSignalManager } from './edge-signal-manager';
13
+
14
+ // DX-1125: exercise the broadcast pub/sub paths of EdgeSignalManager against an in-memory edge.
15
+
16
+ const TRACE_TAG = 'type:status.update';
17
+
18
+ const payload = (value: number[]) => ({ type_url: 'dxos.compute.TraceMessage', value: new Uint8Array(value) });
19
+
20
+ /**
21
+ * A capture sink for a single subscription. Delivered messages are classified by shape: a broadcast
22
+ * carries `tags` and no `recipient`; a point-to-point message carries a `recipient` (DX-1125).
23
+ */
24
+ const capture = () => {
25
+ const messages: Message[] = [];
26
+ const broadcasts: Message[] = [];
27
+ const onMessage = (message: Message) => {
28
+ if (message.recipient != null) {
29
+ messages.push(message);
30
+ } else {
31
+ broadcasts.push(message);
32
+ }
33
+ };
34
+ return { messages, broadcasts, onMessage };
35
+ };
36
+
37
+ /**
38
+ * Wire an EdgeSignalManager to a mesh connection and join `topic`. Returns the manager and its peer
39
+ * info; subscriptions (with their capture sinks) are created per-test.
40
+ */
41
+ const setupPeer = async (mesh: TestEdgeMesh, topic: PublicKey, name: string) => {
42
+ const peer: PeerInfo = { peerKey: PublicKey.random().toHex(), identityDid: `did:test:${name}` };
43
+ const connection = mesh.createConnection({ peerKey: peer.peerKey!, identityDid: peer.identityDid! });
44
+ const manager = new EdgeSignalManager({ edgeConnection: connection as any });
45
+ await manager.open();
46
+ await manager.join(Context.default(), { topic, peer });
47
+ return { manager, peer };
48
+ };
49
+
50
+ // A broadcast addresses its target swarm via `author.swarmKey` (DX-1125).
51
+ const broadcast = (peer: PeerInfo, topic: PublicKey, tags: string[], value: number[]) => ({
52
+ author: { ...peer, swarmKey: topic.toHex() },
53
+ tags,
54
+ payload: payload(value),
55
+ });
56
+
57
+ describe('EdgeSignalManager broadcast (DX-1125)', () => {
58
+ test('delivers a tag broadcast to a subscriber whose tags intersect', async ({ expect }) => {
59
+ const mesh = new TestEdgeMesh();
60
+ const topic = PublicKey.random();
61
+ const publisher = await setupPeer(mesh, topic, 'publisher');
62
+ const subscriber = await setupPeer(mesh, topic, 'subscriber');
63
+
64
+ const sink = capture();
65
+ await subscriber.manager.subscribeMessages({ peer: subscriber.peer, tags: [TRACE_TAG], onMessage: sink.onMessage });
66
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [1, 2, 3]));
67
+
68
+ expect(sink.broadcasts).toHaveLength(1);
69
+ expect(sink.broadcasts[0].tags).toContain(TRACE_TAG);
70
+ expect(sink.broadcasts[0].author.peerKey).toBe(publisher.peer.peerKey);
71
+ expect([...sink.broadcasts[0].payload.value]).toEqual([1, 2, 3]);
72
+ // Broadcasts are not point-to-point messages.
73
+ expect(sink.messages).toHaveLength(0);
74
+ });
75
+
76
+ test('does not deliver broadcasts whose tags do not intersect the subscription', async ({ expect }) => {
77
+ const mesh = new TestEdgeMesh();
78
+ const topic = PublicKey.random();
79
+ const publisher = await setupPeer(mesh, topic, 'publisher');
80
+ const subscriber = await setupPeer(mesh, topic, 'subscriber');
81
+
82
+ const sink = capture();
83
+ await subscriber.manager.subscribeMessages({
84
+ peer: subscriber.peer,
85
+ tags: ['type:operation.input'],
86
+ onMessage: sink.onMessage,
87
+ });
88
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [9]));
89
+
90
+ expect(sink.broadcasts).toHaveLength(0);
91
+ });
92
+
93
+ test('excludes the author from its own broadcast', async ({ expect }) => {
94
+ const mesh = new TestEdgeMesh();
95
+ const topic = PublicKey.random();
96
+ const publisher = await setupPeer(mesh, topic, 'publisher');
97
+
98
+ // The publisher is also a subscriber for the same tag.
99
+ const sink = capture();
100
+ await publisher.manager.subscribeMessages({ peer: publisher.peer, tags: [TRACE_TAG], onMessage: sink.onMessage });
101
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [1]));
102
+
103
+ expect(sink.broadcasts).toHaveLength(0);
104
+ });
105
+
106
+ test('re-establishes the subscription across reconnect', async ({ expect }) => {
107
+ const mesh = new TestEdgeMesh();
108
+ const topic = PublicKey.random();
109
+ const publisher = await setupPeer(mesh, topic, 'publisher');
110
+ const subscriber = await setupPeer(mesh, topic, 'subscriber');
111
+
112
+ const sink = capture();
113
+ await subscriber.manager.subscribeMessages({ peer: subscriber.peer, tags: [TRACE_TAG], onMessage: sink.onMessage });
114
+ // Simulate a reconnect: the manager re-joins swarms and re-sends its subscription.
115
+ await (subscriber.manager as any)._rejoinAllSwarms();
116
+
117
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [7]));
118
+
119
+ expect(sink.broadcasts).toHaveLength(1);
120
+ });
121
+
122
+ test('one consumer unsubscribing does not clobber another consumer of the same tag', async ({ expect }) => {
123
+ const mesh = new TestEdgeMesh();
124
+ const topic = PublicKey.random();
125
+ const publisher = await setupPeer(mesh, topic, 'publisher');
126
+ const subscriber = await setupPeer(mesh, topic, 'subscriber');
127
+
128
+ // Two consumers on the same client share the manager's tag subscription (e.g. trace-progress and
129
+ // a story module both watching status updates). The bug (DX-1125): one consumer's teardown
130
+ // wholesale-cleared the shared tag set, so the other went silent. The refcounted per-subscription
131
+ // teardown releases only the tags that subscription added.
132
+ const first = capture();
133
+ const second = capture();
134
+ await subscriber.manager.subscribeMessages({
135
+ peer: subscriber.peer,
136
+ tags: [TRACE_TAG],
137
+ onMessage: first.onMessage,
138
+ });
139
+ const unsubscribeSecond = await subscriber.manager.subscribeMessages({
140
+ peer: subscriber.peer,
141
+ tags: [TRACE_TAG, 'space:test-space'],
142
+ onMessage: second.onMessage,
143
+ });
144
+ await unsubscribeSecond();
145
+
146
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [5]));
147
+
148
+ // The first consumer's registration of TRACE_TAG survives; the released space tag does not.
149
+ expect(first.broadcasts).toHaveLength(1);
150
+ expect([...first.broadcasts[0].payload.value]).toEqual([5]);
151
+
152
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, ['space:test-space'], [6]));
153
+ expect(first.broadcasts).toHaveLength(1);
154
+ });
155
+
156
+ test('releasing the last registration of a tag stops delivery', async ({ expect }) => {
157
+ const mesh = new TestEdgeMesh();
158
+ const topic = PublicKey.random();
159
+ const publisher = await setupPeer(mesh, topic, 'publisher');
160
+ const subscriber = await setupPeer(mesh, topic, 'subscriber');
161
+
162
+ const sink = capture();
163
+ const unsubscribe = await subscriber.manager.subscribeMessages({
164
+ peer: subscriber.peer,
165
+ tags: [TRACE_TAG],
166
+ onMessage: sink.onMessage,
167
+ });
168
+ await unsubscribe();
169
+
170
+ await publisher.manager.sendMessage(Context.default(), broadcast(publisher.peer, topic, [TRACE_TAG], [8]));
171
+
172
+ expect(sink.broadcasts).toHaveLength(0);
173
+ });
174
+
175
+ test('still delivers point-to-point messages', async ({ expect }) => {
176
+ const mesh = new TestEdgeMesh();
177
+ const topic = PublicKey.random();
178
+ const sender = await setupPeer(mesh, topic, 'sender');
179
+ const recipient = await setupPeer(mesh, topic, 'recipient');
180
+
181
+ const sink = capture();
182
+ await recipient.manager.subscribeMessages({ peer: recipient.peer, onMessage: sink.onMessage });
183
+ await sender.manager.sendMessage(Context.default(), {
184
+ author: sender.peer,
185
+ recipient: recipient.peer,
186
+ payload: payload([4, 2]),
187
+ });
188
+
189
+ expect(sink.messages).toHaveLength(1);
190
+ expect(sink.messages[0].author.peerKey).toBe(sender.peer.peerKey);
191
+ expect(sink.broadcasts).toHaveLength(0);
192
+ });
193
+ });
@@ -24,16 +24,38 @@ import {
24
24
  import { type SwarmResponse } from '@dxos/protocols/proto/dxos/edge/messenger';
25
25
  import { ComplexMap, ComplexSet } from '@dxos/util';
26
26
 
27
- import { type Message, type PeerInfo, PeerInfoHash, type SwarmEvent } from '../signal-methods';
27
+ import {
28
+ type Message,
29
+ type PeerInfo,
30
+ PeerInfoHash,
31
+ type SubscribeMessagesParams,
32
+ type SwarmEvent,
33
+ type UnsubscribeCallback,
34
+ } from '../signal-methods';
28
35
  import { type SignalManager } from './signal-manager';
29
36
 
37
+ /**
38
+ * A single message subscription registered on an {@link EdgeSignalManager} (DX-1125). Point-to-point
39
+ * delivery matches `peerKey`; broadcast delivery matches any intersection with `tags`.
40
+ */
41
+ type MessageSubscription = {
42
+ peerKey: string;
43
+ tags: Set<string>;
44
+ onMessage: (message: Message) => void;
45
+ };
46
+
30
47
  export class EdgeSignalManager extends Resource implements SignalManager {
31
48
  /**
32
49
  * @deprecated
33
50
  */
34
51
  public swarmEvent = new Event<SwarmEvent>();
35
52
  public swarmState = new Event<SwarmResponse>();
36
- public onMessage = new Event<Message>();
53
+
54
+ /**
55
+ * Active message subscriptions. Routing is encapsulated here (DX-1125): each incoming message is
56
+ * dispatched to every subscription it matches, and each subscription owns its own teardown.
57
+ */
58
+ private readonly _subscriptions = new Set<MessageSubscription>();
37
59
 
38
60
  /**
39
61
  * Swarm key -> { peer: <own state payload>, joinedPeers: <state of swarm> }.
@@ -44,6 +66,15 @@ export class EdgeSignalManager extends Resource implements SignalManager {
44
66
  { lastState?: Uint8Array; joinedPeers: ComplexSet<PeerInfo> }
45
67
  >(PublicKey.hash);
46
68
 
69
+ /**
70
+ * OR-subscription tag refcounts for broadcast messages (DX-1125). One count per distinct tag across
71
+ * all subscribers, so one consumer's unsubscribe releases only its own registration rather than
72
+ * clobbering another consumer's identical subscription. The effective tag set (the keys) is shared
73
+ * across all joined swarms; the edge fans out any broadcast whose tags intersect it. Re-sent on
74
+ * reconnect and whenever a swarm is (re-)joined.
75
+ */
76
+ private readonly _subscribedTags = new Map<string, number>();
77
+
47
78
  private readonly _edgeConnection: EdgeConnection;
48
79
 
49
80
  constructor({ edgeConnection }: { edgeConnection: EdgeConnection }) {
@@ -89,6 +120,11 @@ export class EdgeSignalManager extends Resource implements SignalManager {
89
120
  payload: { action: SwarmRequestAction.JOIN, swarmKeys: [topic.toHex()] },
90
121
  }),
91
122
  );
123
+
124
+ // Re-establish any broadcast subscription on the newly-joined swarm (DX-1125).
125
+ if (this._subscribedTags.size > 0) {
126
+ await this._sendSubscription(ctx);
127
+ }
92
128
  }
93
129
 
94
130
  async leave(ctx: Context, { topic, peer }: { topic: PublicKey; peer: PeerInfo }): Promise<void> {
@@ -134,10 +170,16 @@ export class EdgeSignalManager extends Resource implements SignalManager {
134
170
  }
135
171
 
136
172
  async sendMessage(ctx: Context, message: Message): Promise<void> {
137
- if (!this._matchSelfPeerInfo(message.author)) {
173
+ const { author, recipient, tags, payload } = message;
174
+ // Exactly one of point-to-point (`recipient`) or broadcast (`tags`) delivery (DX-1125). A broadcast
175
+ // carries its target swarm in `author.swarmKey` and is published with no `target`; the edge fans it
176
+ // out to every peer whose subscription tags intersect.
177
+ invariant((recipient == null) !== !tags?.length, 'Exactly one of `recipient` or `tags` must be set');
178
+
179
+ if (!this._matchSelfPeerInfo(author)) {
138
180
  // NOTE: Could only join swarm with the same peer info as the edge connection.
139
181
  log.warn('ignoring author on send request', {
140
- author: message.author,
182
+ author,
141
183
  expected: { peerKey: this._edgeConnection.peerKey, identityDid: this._edgeConnection.identityDid },
142
184
  });
143
185
  }
@@ -146,19 +188,79 @@ export class EdgeSignalManager extends Resource implements SignalManager {
146
188
  ctx,
147
189
  protocol.createMessage(bufWkt.AnySchema, {
148
190
  serviceId: EdgeService.SIGNAL,
149
- source: message.author,
150
- target: [message.recipient],
151
- payload: { typeUrl: message.payload.type_url, value: message.payload.value },
191
+ source: author,
192
+ target: recipient != null ? [recipient] : undefined,
193
+ tags,
194
+ payload: { typeUrl: payload.type_url, value: payload.value },
152
195
  }),
153
196
  );
154
197
  }
155
198
 
156
- async subscribeMessages(peerInfo: PeerInfo): Promise<void> {
157
- // No-op.
199
+ async subscribeMessages({ peer, tags = [], onMessage }: SubscribeMessagesParams): Promise<UnsubscribeCallback> {
200
+ const subscription: MessageSubscription = { peerKey: peer.peerKey, tags: new Set(tags), onMessage };
201
+ this._subscriptions.add(subscription);
202
+
203
+ // Point-to-point delivery needs no edge registration (the edge relays targeted messages to this
204
+ // peer's socket). Only tag broadcasts require an OR-subscription registered on the swarm (DX-1125),
205
+ // refcounted so one subscriber's teardown does not clobber another's identical tags.
206
+ let changed = false;
207
+ for (const tag of subscription.tags) {
208
+ const count = this._subscribedTags.get(tag) ?? 0;
209
+ this._subscribedTags.set(tag, count + 1);
210
+ if (count === 0) {
211
+ changed = true;
212
+ }
213
+ }
214
+ if (changed) {
215
+ await this._sendSubscription(this._ctx);
216
+ }
217
+
218
+ return async () => {
219
+ this._subscriptions.delete(subscription);
220
+ // Release only this subscription's tag registrations; a tag stays live while another holds it.
221
+ let released = false;
222
+ for (const tag of subscription.tags) {
223
+ const count = this._subscribedTags.get(tag);
224
+ if (count === undefined) {
225
+ continue;
226
+ }
227
+ if (count > 1) {
228
+ this._subscribedTags.set(tag, count - 1);
229
+ } else {
230
+ this._subscribedTags.delete(tag);
231
+ released = true;
232
+ }
233
+ }
234
+ if (released) {
235
+ await this._sendSubscription(this._ctx);
236
+ }
237
+ };
158
238
  }
159
239
 
160
- async unsubscribeMessages(peerInfo: PeerInfo): Promise<void> {
161
- // No-op.
240
+ /**
241
+ * Send the current broadcast tag subscription to every joined swarm (DX-1125). An empty tag set
242
+ * clears the subscription on the edge.
243
+ */
244
+ private async _sendSubscription(ctx: Context): Promise<void> {
245
+ const swarmKeys = Array.from(this._swarmPeers.keys()).map((topic) => topic.toHex());
246
+ if (swarmKeys.length === 0) {
247
+ return;
248
+ }
249
+ await this._edgeConnection.send(
250
+ ctx,
251
+ protocol.createMessage(SwarmRequestSchema, {
252
+ serviceId: EdgeService.SWARM,
253
+ source: {
254
+ peerKey: this._edgeConnection.peerKey,
255
+ identityDid: this._edgeConnection.identityDid,
256
+ },
257
+ payload: {
258
+ action: SwarmRequestAction.SUBSCRIBE,
259
+ swarmKeys,
260
+ subscribeTags: Array.from(this._subscribedTags.keys()),
261
+ },
262
+ }),
263
+ );
162
264
  }
163
265
 
164
266
  private _onMessage(message: EdgeMessage): void {
@@ -215,10 +317,21 @@ export class EdgeSignalManager extends Resource implements SignalManager {
215
317
  invariant(protocol.getPayloadType(message) === bufWkt.AnySchema.typeName, 'Wrong payload type');
216
318
  const payload = protocol.getPayload(message, bufWkt.AnySchema);
217
319
  invariant(message.source, 'source is missing');
320
+
321
+ // Broadcasts (DX-1125) carry tags and no target; point-to-point messages carry exactly one target.
322
+ if ((message.tags?.length ?? 0) > 0 && (message.target?.length ?? 0) === 0) {
323
+ this._deliver({
324
+ author: message.source,
325
+ tags: message.tags ?? [],
326
+ payload: { type_url: payload.typeUrl, value: payload.value },
327
+ });
328
+ return;
329
+ }
330
+
218
331
  invariant(message.target, 'target is missing');
219
332
  invariant(message.target.length === 1, 'target should have exactly one item');
220
333
 
221
- this.onMessage.emit({
334
+ this._deliver({
222
335
  author: message.source,
223
336
  recipient: message.target[0],
224
337
  payload: {
@@ -228,6 +341,22 @@ export class EdgeSignalManager extends Resource implements SignalManager {
228
341
  });
229
342
  }
230
343
 
344
+ /**
345
+ * Route an incoming message to matching subscriptions (DX-1125): point-to-point by recipient
346
+ * `peerKey`, broadcasts by tag intersection.
347
+ */
348
+ private _deliver(message: Message): void {
349
+ for (const subscription of this._subscriptions) {
350
+ if (message.recipient != null) {
351
+ if (subscription.peerKey === message.recipient.peerKey) {
352
+ subscription.onMessage(message);
353
+ }
354
+ } else if (message.tags?.some((tag) => subscription.tags.has(tag))) {
355
+ subscription.onMessage(message);
356
+ }
357
+ }
358
+ }
359
+
231
360
  private _matchSelfPeerInfo(peer: PeerInfo): boolean {
232
361
  return Boolean(
233
362
  peer && (peer.peerKey === this._edgeConnection.peerKey || peer.identityDid === this._edgeConnection.identityDid),
@@ -246,6 +375,10 @@ export class EdgeSignalManager extends Resource implements SignalManager {
246
375
  },
247
376
  });
248
377
  }
378
+ // Re-establish the broadcast subscription across the rejoined swarms (DX-1125).
379
+ if (this._subscribedTags.size > 0) {
380
+ await this._sendSubscription(this._ctx);
381
+ }
249
382
  }
250
383
  }
251
384
 
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  export * from './memory-signal-manager';
6
- export type * from './signal-manager';
7
- export * from './websocket-signal-manager';
6
+ export * from './signal-manager';
8
7
  export * from './edge-signal-manager';
9
8
  export * from './utils';