libp2p-mesh 2026.5.12

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/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "libp2p-mesh",
3
+ "version": "2026.5.12",
4
+ "description": "OpenClaw libp2p P2P mesh network plugin for cross-instance agent communication",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json",
10
+ "prepack": "npm run build"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "index.ts",
15
+ "api.ts",
16
+ "src",
17
+ "openclaw.plugin.json",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "dependencies": {
22
+ "@libp2p/bootstrap": "^10.1.5",
23
+ "@libp2p/mdns": "^9.0.14",
24
+ "@libp2p/mplex": "^9.0.12",
25
+ "@libp2p/noise": "^12.0.1",
26
+ "@libp2p/peer-id-factory": "^3.0.11",
27
+ "@libp2p/tcp": "^8.0.13",
28
+ "@libp2p/websockets": "^8.2.0",
29
+ "@multiformats/multiaddr": "^12.5.1",
30
+ "it-length-prefixed": "^10.0.1",
31
+ "it-pipe": "^3.0.1",
32
+ "libp2p": "^0.46.16",
33
+ "uint8arraylist": "^2.4.8"
34
+ },
35
+ "devDependencies": {
36
+ "openclaw": "^2026.5.7",
37
+ "tsx": "^4.0.0",
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "peerDependencies": {
41
+ "openclaw": ">=2026.3.24"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "openclaw": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "openclaw": {
49
+ "extensions": [
50
+ "./dist/index.js"
51
+ ],
52
+ "channel": {
53
+ "id": "libp2p-mesh",
54
+ "label": "P2P Mesh",
55
+ "selectionLabel": "P2P Mesh",
56
+ "detailLabel": "P2P Mesh",
57
+ "docsPath": "/channels/libp2p-mesh",
58
+ "docsLabel": "libp2p-mesh",
59
+ "blurb": "libp2p mesh network for cross-instance agent communication.",
60
+ "systemImage": "network"
61
+ },
62
+ "install": {
63
+ "npmSpec": "openclaw-libp2p-mesh",
64
+ "defaultChoice": "npm",
65
+ "minHostVersion": ">=2026.3.24"
66
+ },
67
+ "bundle": {
68
+ "stageRuntimeDependencies": true
69
+ },
70
+ "release": {
71
+ "publishToNpm": true
72
+ }
73
+ },
74
+ "exports": {
75
+ ".": {
76
+ "types": "./dist/index.d.ts",
77
+ "default": "./dist/index.js"
78
+ },
79
+ "./api": {
80
+ "types": "./dist/api.d.ts",
81
+ "default": "./dist/api.js"
82
+ }
83
+ },
84
+ "keywords": [
85
+ "openclaw",
86
+ "openclaw-plugin",
87
+ "libp2p",
88
+ "p2p",
89
+ "mesh",
90
+ "decentralized"
91
+ ],
92
+ "author": "",
93
+ "license": "MIT",
94
+ "repository": {
95
+ "type": "git",
96
+ "url": "git+https://github.com/lilingfy/openclaw-libp2p-mesh.git"
97
+ },
98
+ "bugs": {
99
+ "url": "https://github.com/lilingfy/openclaw-libp2p-mesh/issues"
100
+ },
101
+ "homepage": "https://github.com/lilingfy/openclaw-libp2p-mesh#readme"
102
+ }
@@ -0,0 +1,116 @@
1
+ import type { MeshNetwork } from "./types.js";
2
+
3
+ export function buildP2PTools(mesh: MeshNetwork) {
4
+ return [
5
+ {
6
+ name: "p2p_send_message",
7
+ label: "P2P Send Message",
8
+ description: "Send a direct message to another agent via the P2P mesh network.",
9
+ parameters: {
10
+ type: "object" as const,
11
+ properties: {
12
+ peerId: {
13
+ type: "string" as const,
14
+ description: "Target peer ID (libp2p Peer ID string)",
15
+ },
16
+ message: {
17
+ type: "string" as const,
18
+ description: "Message content to send",
19
+ },
20
+ },
21
+ required: ["peerId", "message"],
22
+ },
23
+ async execute(_toolCallId: string, params: { peerId: string; message: string }) {
24
+ try {
25
+ await mesh.sendToPeer(params.peerId, params.message);
26
+ return {
27
+ content: [{ type: "text" as const, text: `Message sent to ${params.peerId}` }],
28
+ details: { sent: true, peerId: params.peerId },
29
+ };
30
+ } catch (err) {
31
+ return {
32
+ content: [
33
+ {
34
+ type: "text" as const,
35
+ text: `Failed to send message to ${params.peerId}: ${String(err)}`,
36
+ },
37
+ ],
38
+ details: { sent: false, peerId: params.peerId, error: String(err) },
39
+ isError: true,
40
+ };
41
+ }
42
+ },
43
+ },
44
+ {
45
+ name: "p2p_broadcast",
46
+ label: "P2P Broadcast",
47
+ description: "Broadcast a message to all peers on a topic via the P2P mesh network.",
48
+ parameters: {
49
+ type: "object" as const,
50
+ properties: {
51
+ topic: {
52
+ type: "string" as const,
53
+ description: "Topic name to broadcast on",
54
+ },
55
+ message: {
56
+ type: "string" as const,
57
+ description: "Message content to broadcast",
58
+ },
59
+ },
60
+ required: ["topic", "message"],
61
+ },
62
+ async execute(_toolCallId: string, params: { topic: string; message: string }) {
63
+ try {
64
+ await mesh.publishToTopic(params.topic, params.message);
65
+ return {
66
+ content: [{ type: "text" as const, text: `Broadcast sent to topic ${params.topic}` }],
67
+ details: { broadcast: true, topic: params.topic },
68
+ };
69
+ } catch (err) {
70
+ return {
71
+ content: [
72
+ {
73
+ type: "text" as const,
74
+ text: `Failed to broadcast to topic ${params.topic}: ${String(err)}`,
75
+ },
76
+ ],
77
+ details: { broadcast: false, topic: params.topic, error: String(err) },
78
+ isError: true,
79
+ };
80
+ }
81
+ },
82
+ },
83
+ {
84
+ name: "p2p_list_peers",
85
+ label: "P2P List Peers",
86
+ description: "List currently connected peers in the P2P mesh network.",
87
+ parameters: {
88
+ type: "object" as const,
89
+ properties: {},
90
+ },
91
+ async execute(_toolCallId: string) {
92
+ try {
93
+ const peers = mesh.getConnectedPeers();
94
+ const text =
95
+ peers.length === 0
96
+ ? "No peers currently connected."
97
+ : `Connected peers (${peers.length}): ${peers.join(", ")}`;
98
+ return {
99
+ content: [{ type: "text" as const, text }],
100
+ details: {
101
+ localPeerId: mesh.getLocalPeerId(),
102
+ connectedPeers: peers,
103
+ count: peers.length,
104
+ },
105
+ };
106
+ } catch (err) {
107
+ return {
108
+ content: [{ type: "text" as const, text: `Failed to list peers: ${String(err)}` }],
109
+ details: { error: String(err) },
110
+ isError: true,
111
+ };
112
+ }
113
+ },
114
+ },
115
+ ];
116
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
3
+ import type { MeshNetwork, MeshAccount } from "./types.js";
4
+ import { sendViaMesh } from "./send.js";
5
+
6
+ export function createLibp2pMeshChannel(mesh: MeshNetwork): ChannelPlugin {
7
+ return createChatChannelPlugin<MeshAccount>({
8
+ base: {
9
+ id: "libp2p-mesh",
10
+ meta: {
11
+ id: "libp2p-mesh",
12
+ label: "P2P Mesh",
13
+ selectionLabel: "P2P Mesh",
14
+ docsPath: "/channels/libp2p-mesh",
15
+ docsLabel: "libp2p-mesh",
16
+ blurb: "libp2p mesh network for cross-instance agent communication.",
17
+ systemImage: "network",
18
+ },
19
+ capabilities: {
20
+ chatTypes: ["direct"],
21
+ media: false,
22
+ blockStreaming: false,
23
+ },
24
+ configSchema: {
25
+ schema: {
26
+ type: "object",
27
+ additionalProperties: false,
28
+ properties: {},
29
+ },
30
+ },
31
+ config: {
32
+ listAccountIds: () => ["default"],
33
+ resolveAccount: () => ({
34
+ accountId: "default",
35
+ configured: true,
36
+ enabled: true,
37
+ }),
38
+ isConfigured: () => true,
39
+ isEnabled: () => true,
40
+ describeAccount: () => ({
41
+ accountId: "default",
42
+ name: "default",
43
+ configured: true,
44
+ enabled: true,
45
+ connected: mesh.getConnectedPeers().length > 0,
46
+ }),
47
+ },
48
+ messaging: {
49
+ normalizeTarget: (raw: string) => raw.trim(),
50
+ targetResolver: {
51
+ looksLikeId: () => true,
52
+ hint: "peer-id",
53
+ },
54
+ },
55
+ },
56
+ outbound: {
57
+ deliveryMode: "gateway",
58
+ sendText: async ({ to, text }) => {
59
+ try {
60
+ await sendViaMesh(mesh, to, text);
61
+ return { channel: "libp2p-mesh", messageId: `p2p-${Date.now()}` };
62
+ } catch (err) {
63
+ return { channel: "libp2p-mesh", messageId: `p2p-${Date.now()}`, meta: { error: String(err) } };
64
+ }
65
+ },
66
+ },
67
+ }) as ChannelPlugin;
68
+ }
@@ -0,0 +1,13 @@
1
+ import type { MeshConfig } from "./types.js";
2
+
3
+ export function resolveDiscoveryConfig(config?: MeshConfig): {
4
+ enabled: boolean;
5
+ mechanism: "mdns" | "bootstrap" | "dht";
6
+ bootstrapList: string[];
7
+ } {
8
+ return {
9
+ enabled: true,
10
+ mechanism: config?.discovery ?? "mdns",
11
+ bootstrapList: config?.bootstrapList ?? [],
12
+ };
13
+ }
package/src/inbound.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { P2PMessage } from "./types.js";
2
+
3
+ export type InboundHandlerDeps = {
4
+ logger?: {
5
+ info?: (msg: string) => void;
6
+ debug?: (msg: string) => void;
7
+ warn?: (msg: string) => void;
8
+ error?: (msg: string) => void;
9
+ };
10
+ sendToChannel?: (channelId: string, target: string, text: string) => Promise<void>;
11
+ };
12
+
13
+ export function handleP2PInbound(msg: P2PMessage, deps: InboundHandlerDeps): void {
14
+ const { logger, sendToChannel } = deps;
15
+ if (msg.type === "broadcast") {
16
+ logger?.info?.(`[libp2p-mesh] Broadcast from ${msg.from} on topic ${msg.topic ?? "(none)"}: ${msg.payload}`);
17
+ return;
18
+ }
19
+
20
+ // Direct message — log and forward to local channel
21
+ logger?.info?.(`[libp2p-mesh] Direct message from ${msg.from}: ${msg.payload}`);
22
+
23
+ if (!sendToChannel || !msg.payload) {
24
+ return;
25
+ }
26
+
27
+ const text = `[来自 ${msg.from}]\n${msg.payload}`;
28
+ sendToChannel("libp2p-mesh", msg.from, text).catch((err) => {
29
+ logger?.error?.(`[libp2p-mesh] Failed to forward direct message from ${msg.from}: ${err}`);
30
+ });
31
+ }
package/src/mesh.ts ADDED
@@ -0,0 +1,338 @@
1
+ // Polyfill for Node.js < 22 (libp2p dependencies use Promise.withResolvers)
2
+ if (!Promise.withResolvers) {
3
+ Promise.withResolvers = function <T>() {
4
+ let resolve: (value?: T | PromiseLike<T> | undefined) => void;
5
+ let reject: (reason?: unknown) => void;
6
+ const promise = new Promise<T>((res, rej) => {
7
+ resolve = res as typeof resolve;
8
+ reject = rej;
9
+ });
10
+ return { promise, resolve: resolve!, reject: reject! };
11
+ };
12
+ }
13
+
14
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import path from "node:path";
17
+ import { mdns } from "@libp2p/mdns";
18
+ import { mplex } from "@libp2p/mplex";
19
+ import { noise } from "@libp2p/noise";
20
+ import {
21
+ createEd25519PeerId,
22
+ createFromProtobuf,
23
+ exportToProtobuf,
24
+ } from "@libp2p/peer-id-factory";
25
+ import { tcp } from "@libp2p/tcp";
26
+ import { webSockets } from "@libp2p/websockets";
27
+ import { bootstrap } from "@libp2p/bootstrap";
28
+ import { encode, decode } from "it-length-prefixed";
29
+ import { pipe } from "it-pipe";
30
+ import { createLibp2p } from "libp2p";
31
+ import { Uint8ArrayList } from "uint8arraylist";
32
+ import type { Libp2p } from "libp2p";
33
+ import type { MeshConfig, MeshNetwork, P2PMessage } from "./types.js";
34
+
35
+ const PROTOCOL = "/openclaw-msg/1.0.0";
36
+ const MAX_SEEN_MESSAGES = 1000;
37
+
38
+ function resolvePeerIdPath(customPath?: string): string {
39
+ if (customPath) return customPath;
40
+ const stateDir = process.env.OPENCLAW_STATE_DIR;
41
+ if (stateDir) {
42
+ return path.join(stateDir, "libp2p", "peer-id.json");
43
+ }
44
+ return path.join(homedir(), ".openclaw", "libp2p", "peer-id.json");
45
+ }
46
+
47
+ async function loadOrCreatePeerId(customPath?: string): Promise<ReturnType<typeof createEd25519PeerId> extends Promise<infer T> ? T : never> {
48
+ const peerIdPath = resolvePeerIdPath(customPath);
49
+ try {
50
+ const saved = JSON.parse(await readFile(peerIdPath, "utf8")) as { protobuf: string };
51
+ const peerId = await createFromProtobuf(Buffer.from(saved.protobuf, "base64"));
52
+ return peerId as ReturnType<typeof createEd25519PeerId> extends Promise<infer T> ? T : never;
53
+ } catch {
54
+ const peerId = await createEd25519PeerId();
55
+ const protobuf = Buffer.from(exportToProtobuf(peerId)).toString("base64");
56
+ await mkdir(path.dirname(peerIdPath), { recursive: true });
57
+ await writeFile(peerIdPath, JSON.stringify({ protobuf }, null, 2));
58
+ return peerId;
59
+ }
60
+ }
61
+
62
+ export function createMeshNetwork(options: {
63
+ config?: MeshConfig;
64
+ logger?: { info?: (msg: string) => void; debug?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void };
65
+ }): MeshNetwork {
66
+ const config = options.config ?? {};
67
+ const logger = options.logger;
68
+
69
+ // Use an object property instead of a bare `let` so all closures share
70
+ // the same mutable reference even if the bundler rewrites scopes.
71
+ const state = {
72
+ node: null as Libp2p | null,
73
+ };
74
+
75
+ const seenMessages = new Set<string>();
76
+ const messageHandlers = new Set<(msg: P2PMessage) => void>();
77
+ const topicHandlers = new Map<string, Set<(msg: string) => void>>();
78
+
79
+ async function start(): Promise<void> {
80
+ const peerId = await loadOrCreatePeerId(config.peerIdPath);
81
+
82
+ // Build transports dynamically
83
+ const transports: any[] = [tcp()];
84
+ if (config.enableWebSocket) {
85
+ transports.push(webSockets());
86
+ }
87
+
88
+ // Build peer discovery dynamically
89
+ const peerDiscovery: any[] = [];
90
+ const discoveryMechanism = config.discovery ?? "mdns";
91
+ if (discoveryMechanism === "mdns") {
92
+ peerDiscovery.push(mdns({ interval: 1000 }));
93
+ logger?.info?.("[libp2p-mesh] Using mDNS discovery (LAN)");
94
+ } else if (discoveryMechanism === "bootstrap") {
95
+ const bootstrapList = config.bootstrapList ?? [];
96
+ if (bootstrapList.length > 0) {
97
+ peerDiscovery.push(bootstrap({ list: bootstrapList }));
98
+ logger?.info?.(`[libp2p-mesh] Using bootstrap discovery (${bootstrapList.length} node(s))`);
99
+ } else {
100
+ logger?.warn?.("[libp2p-mesh] discovery=bootstrap but bootstrapList is empty; falling back to mDNS");
101
+ peerDiscovery.push(mdns({ interval: 1000 }));
102
+ }
103
+ } else if (discoveryMechanism === "dht") {
104
+ logger?.warn?.("[libp2p-mesh] DHT discovery is not yet implemented; falling back to mDNS");
105
+ peerDiscovery.push(mdns({ interval: 1000 }));
106
+ }
107
+
108
+ state.node = await createLibp2p({
109
+ peerId,
110
+ start: false,
111
+ transports,
112
+ connectionEncryption: [noise()],
113
+ streamMuxers: [mplex()],
114
+ addresses: {
115
+ listen: config.listenAddrs ?? ["/ip4/0.0.0.0/tcp/0"],
116
+ },
117
+ peerDiscovery,
118
+ });
119
+
120
+ state.node.addEventListener("peer:connect", (evt) => {
121
+ const peerIdStr = evt.detail.toString();
122
+ logger?.debug?.(`[libp2p-mesh] Peer connected: ${peerIdStr}`);
123
+ });
124
+
125
+ state.node.addEventListener("peer:disconnect", (evt) => {
126
+ const peerIdStr = evt.detail.toString();
127
+ logger?.debug?.(`[libp2p-mesh] Peer disconnected: ${peerIdStr}`);
128
+ });
129
+
130
+ await state.node.handle(PROTOCOL, async ({ stream, connection }) => {
131
+ try {
132
+ await pipe(
133
+ stream.source,
134
+ decode,
135
+ async (source) => {
136
+ for await (const msg of source) {
137
+ const data = new TextDecoder().decode(msg.subarray());
138
+ let parsed: P2PMessage;
139
+ try {
140
+ parsed = JSON.parse(data) as P2PMessage;
141
+ } catch {
142
+ logger?.warn?.(`[libp2p-mesh] Failed to parse message from ${connection.remotePeer.toString()}`);
143
+ continue;
144
+ }
145
+
146
+ if (seenMessages.has(parsed.id)) {
147
+ continue;
148
+ }
149
+ if (seenMessages.size >= MAX_SEEN_MESSAGES) {
150
+ seenMessages.clear();
151
+ }
152
+ seenMessages.add(parsed.id);
153
+
154
+ // Enrich with local timestamp if missing
155
+ if (!parsed.timestamp) {
156
+ parsed.timestamp = Date.now();
157
+ }
158
+
159
+ logger?.debug?.(`[libp2p-mesh] Received ${parsed.type} from ${parsed.from}`);
160
+
161
+ // Notify direct message handlers
162
+ for (const handler of messageHandlers) {
163
+ try {
164
+ handler(parsed);
165
+ } catch (err) {
166
+ logger?.error?.(`[libp2p-mesh] Message handler error: ${String(err)}`);
167
+ }
168
+ }
169
+
170
+ // Handle broadcast / topic subscription
171
+ if (parsed.type === "broadcast" && parsed.topic) {
172
+ const handlers = topicHandlers.get(parsed.topic);
173
+ if (handlers) {
174
+ for (const h of handlers) {
175
+ try {
176
+ h(parsed.payload);
177
+ } catch (err) {
178
+ logger?.error?.(`[libp2p-mesh] Topic handler error: ${String(err)}`);
179
+ }
180
+ }
181
+ }
182
+ // Flood-fill forward to other connected peers (with TTL guard)
183
+ await forwardBroadcast(parsed, connection.remotePeer.toString());
184
+ }
185
+ }
186
+ },
187
+ );
188
+ } catch (err) {
189
+ logger?.error?.(`[libp2p-mesh] Protocol handler error: ${String(err)}`);
190
+ }
191
+ });
192
+
193
+ await state.node.start();
194
+
195
+ logger?.info?.(`[libp2p-mesh] Node started. Peer ID: ${state.node.peerId.toString()}`);
196
+ logger?.info?.(`[libp2p-mesh] Listening on: ${state.node.getMultiaddrs().map((ma) => ma.toString()).join(", ")}`);
197
+ }
198
+
199
+ async function stop(): Promise<void> {
200
+ if (state.node) {
201
+ await state.node.stop();
202
+ state.node = null;
203
+ logger?.info?.("[libp2p-mesh] Node stopped.");
204
+ }
205
+ }
206
+
207
+ async function sendToPeer(peerId: string, message: string): Promise<void> {
208
+ if (!state.node) {
209
+ throw new Error("Mesh network is not started");
210
+ }
211
+
212
+ const msg: P2PMessage = {
213
+ id: crypto.randomUUID(),
214
+ type: "direct",
215
+ from: state.node.peerId.toString(),
216
+ to: peerId,
217
+ payload: message,
218
+ timestamp: Date.now(),
219
+ };
220
+
221
+ const data = new TextEncoder().encode(JSON.stringify(msg));
222
+
223
+ const abortController = new AbortController();
224
+ const timeout = setTimeout(() => abortController.abort(), 8000);
225
+
226
+ try {
227
+ const { peerIdFromString } = await import("@libp2p/peer-id");
228
+ logger?.debug?.(`[libp2p-mesh] dialProtocol to ${peerId}`);
229
+ const stream = await state.node.dialProtocol(peerIdFromString(peerId) as any, PROTOCOL, {
230
+ signal: abortController.signal,
231
+ });
232
+ if (!stream) {
233
+ throw new Error(`Failed to establish stream to ${peerId}; peer may be unreachable`);
234
+ }
235
+ logger?.debug?.(`[libp2p-mesh] stream opened to ${peerId}`);
236
+ await pipe([new Uint8ArrayList(data)], encode, stream.sink);
237
+ logger?.debug?.(`[libp2p-mesh] message sent to ${peerId}`);
238
+ } catch (err) {
239
+ logger?.error?.(`[libp2p-mesh] sendToPeer error: ${String(err)}`);
240
+ if (abortController.signal.aborted) {
241
+ throw new Error(`Send to ${peerId} timed out after 8s`);
242
+ }
243
+ throw err;
244
+ } finally {
245
+ clearTimeout(timeout);
246
+ }
247
+ }
248
+
249
+ async function publishToTopic(topic: string, message: string): Promise<void> {
250
+ if (!state.node) {
251
+ throw new Error("Mesh network is not started");
252
+ }
253
+
254
+ const msg: P2PMessage = {
255
+ id: crypto.randomUUID(),
256
+ type: "broadcast",
257
+ from: state.node.peerId.toString(),
258
+ topic,
259
+ payload: message,
260
+ timestamp: Date.now(),
261
+ };
262
+
263
+ const data = new TextEncoder().encode(JSON.stringify(msg));
264
+ const connections = state.node.getConnections();
265
+ let sent = 0;
266
+
267
+ for (const conn of connections) {
268
+ const abortController = new AbortController();
269
+ const timeout = setTimeout(() => abortController.abort(), 5000);
270
+ try {
271
+ const stream = await conn.newStream(PROTOCOL, { signal: abortController.signal });
272
+ await pipe([new Uint8ArrayList(data)], encode, stream.sink);
273
+ sent++;
274
+ } catch {
275
+ // Ignore individual forwarding errors
276
+ } finally {
277
+ clearTimeout(timeout);
278
+ }
279
+ }
280
+
281
+ logger?.debug?.(`[libp2p-mesh] Broadcast sent to ${sent} peer(s) on topic ${topic}`);
282
+ }
283
+
284
+ async function forwardBroadcast(msg: P2PMessage, fromPeerId: string): Promise<void> {
285
+ if (!state.node) return;
286
+ // Simple flood-fill: forward to all connected peers except the sender
287
+ const data = new TextEncoder().encode(JSON.stringify(msg));
288
+ for (const conn of state.node.getConnections()) {
289
+ const remotePeerId = conn.remotePeer.toString();
290
+ if (remotePeerId === fromPeerId) continue;
291
+ const abortController = new AbortController();
292
+ const timeout = setTimeout(() => abortController.abort(), 5000);
293
+ try {
294
+ const stream = await conn.newStream(PROTOCOL, { signal: abortController.signal });
295
+ await pipe([new Uint8ArrayList(data)], encode, stream.sink);
296
+ } catch {
297
+ // Ignore forwarding errors
298
+ } finally {
299
+ clearTimeout(timeout);
300
+ }
301
+ }
302
+ }
303
+
304
+ function onMessage(handler: (msg: P2PMessage) => void): () => void {
305
+ messageHandlers.add(handler);
306
+ return () => {
307
+ messageHandlers.delete(handler);
308
+ };
309
+ }
310
+
311
+ async function subscribeToTopic(topic: string, handler: (msg: string) => void): Promise<void> {
312
+ if (!topicHandlers.has(topic)) {
313
+ topicHandlers.set(topic, new Set());
314
+ }
315
+ topicHandlers.get(topic)!.add(handler);
316
+ }
317
+
318
+ function getLocalPeerId(): string {
319
+ return state.node?.peerId.toString() ?? "";
320
+ }
321
+
322
+ function getConnectedPeers(): string[] {
323
+ if (!state.node) return [];
324
+ const peers = state.node.getConnections().map((c) => c.remotePeer.toString());
325
+ return [...new Set(peers)];
326
+ }
327
+
328
+ return {
329
+ start,
330
+ stop,
331
+ sendToPeer,
332
+ onMessage,
333
+ publishToTopic,
334
+ subscribeToTopic,
335
+ getLocalPeerId,
336
+ getConnectedPeers,
337
+ };
338
+ }