fakeswarm 0.1.2

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/package.json +39 -0
  4. package/src/index.js +307 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fakeswarm contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # fakeswarm
2
+
3
+ Deterministic, single-process, in-memory fake of a Hyperswarm-like swarm. Useful for tests, examples, or labs where you need predictable peer connections without any real DHT, NAT, or network traffic.
4
+
5
+ **What it is**
6
+ - In-memory swarm that pairs peers by shared topics in a shared Map.
7
+ - Deterministic dial election so only one side initiates.
8
+ - Uses `@hyperswarm/secret-stream` Noise streams for realistic socket shape.
9
+
10
+ **What it is not**
11
+ - No real networking, NAT traversal, DHT, peer discovery, or reconnection logic.
12
+ - Not a drop-in for production Hyperswarm; it is a lab fake.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install fakeswarm
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ Two swarms join the same topic and exchange a message:
23
+
24
+ ```js
25
+ import { createFakeSwarm } from 'fakeswarm';
26
+ import crypto from 'crypto';
27
+
28
+ const topic = crypto.randomBytes(32);
29
+ const swarmA = createFakeSwarm();
30
+ const swarmB = createFakeSwarm();
31
+
32
+ swarmA.join(topic);
33
+ swarmB.join(topic);
34
+
35
+ swarmB.on('connection', (socket, peerInfo) => {
36
+ socket.on('data', (data) => {
37
+ console.log('B got:', data.toString(), 'from', peerInfo.id);
38
+ });
39
+ });
40
+
41
+ swarmA.on('connection', (socket) => {
42
+ socket.write('hello');
43
+ });
44
+ ```
45
+
46
+ Always call `close()` when done to unpublish topics and destroy sockets.
47
+
48
+ ## API
49
+
50
+ ### createFakeSwarm(seed?, topics?)
51
+ - `seed` (Buffer | Uint8Array | null): optional seed passed to `hypercore-crypto.keyPair` for deterministic keys.
52
+ - `topics` (Map) optional shared topics map. By default a module-level Map is used so multiple swarms share the same space.
53
+
54
+ Returns an object with:
55
+ - `join(topic, opts?)` -> discovery handle: joins a topic (Buffer). `opts` mirrors Hyperswarm with defaults `{ server: true, client: true }`.
56
+ - If `server: false`, the swarm will not publish/accept on this topic.
57
+ - If `client: false`, the swarm will not dial on this topic.
58
+ - Handle exposes `leave()` / `destroy()` (unpublish), and `refresh()` / `flushed()` (immediate no-ops for parity).
59
+ - `leave(topic)`: unpublish without the handle.
60
+ - `connections`: `Map<peerId, { socket, peerInfo }>` of active sockets.
61
+ - `topics`: Set of encoded topics joined (any mode).
62
+ - `on(event, fn)` / `off(event, fn)`: EventEmitter style. Events:
63
+ - `connection` `(socket, peerInfo)` where `peerInfo = { publicKey, id, initiator }`.
64
+ - `close` when the swarm closes.
65
+ - `update` when the connection set changes.
66
+ - `flush(timeoutMs?)`: wait (best-effort) for in-flight dials to settle; useful in tests to let the tick loop quiesce.
67
+ - `close()` / `destroy()`: unpublish, stop ticking, destroy sockets, and emit `close`.
68
+ - `keyPair`: the generated keypair.
69
+ - `id`: `idEncoding.encode(publicKey)` convenience string.
70
+
71
+ ### Join / leave semantics
72
+ - Joining registers this swarm in the shared topics map; peers that share a topic will connect.
73
+ - Leaving (or closing) removes the entry from the shared map to avoid ghost peers.
74
+
75
+ ### Determinism notes
76
+ - Dial election: only the lexicographically smaller peerId initiates (`peerId < remotePeerId`), preventing double connects.
77
+ - Backfill: the first `connection` listener added receives existing connections immediately (if any).
78
+ - Flush: waits for connecting dials to drain; it does not advance time on its own.
79
+
80
+ ### Resource cleanup
81
+ - Always call `close()` (or `destroy()`) to unpublish topics and destroy sockets.
82
+ - `flush()` can be used in tests to wait until the swarm has no in-flight dials before asserting.
83
+
84
+ ## Limitations
85
+ - No NAT traversal, DHT, hole-punching, or real network I/O.
86
+ - No reconnection, banning, or peer prioritization.
87
+ - No peer discovery beyond the shared in-memory topics map you provide.
88
+ - Single-process only; for multi-process/multi-machine tests use real Hyperswarm.
89
+
90
+ ## License
91
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "fakeswarm",
3
+ "version": "0.1.2",
4
+ "description": "Deterministic in-memory fake hyperswarm for tests and demos.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "test": "brittle \"test/**/*.test.js\"",
15
+ "lint": "eslint \"src/**/*.js\" \"test/**/*.js\""
16
+ },
17
+ "keywords": [
18
+ "hyperswarm",
19
+ "test",
20
+ "fake",
21
+ "noise"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git@github.com:zacharygriffee/fakeswarm.git"
28
+ },
29
+ "dependencies": {
30
+ "@hyperswarm/secret-stream": "^6.9.1",
31
+ "hypercore-crypto": "^3.6.1",
32
+ "hypercore-id-encoding": "^1.3.0"
33
+ },
34
+ "devDependencies": {
35
+ "b4a": "^1.7.3",
36
+ "brittle": "^3.19.1",
37
+ "eslint": "^9.39.2"
38
+ }
39
+ }
package/src/index.js ADDED
@@ -0,0 +1,307 @@
1
+ import EventEmitter from "events";
2
+ import idEncoding from "hypercore-id-encoding";
3
+ import Krypto from "hypercore-crypto";
4
+ import NoiseStream from "@hyperswarm/secret-stream";
5
+
6
+ const defaultTopics = new Map();
7
+
8
+ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
9
+ const keyPair = Krypto.keyPair(seed);
10
+ const peerId = idEncoding.encode(keyPair.publicKey);
11
+
12
+ // remotePeerId -> { socket, peerInfo }
13
+ const connections = new Map();
14
+
15
+ // remotePeerId -> Promise<void> (prevents duplicate concurrent dials)
16
+ const connecting = new Map();
17
+
18
+ const emitter = new EventEmitter();
19
+ const joinedTopics = new Set(); // all topics we joined (regardless of mode)
20
+ const dialTopics = new Set(); // topics where we will attempt outbound dials (client mode)
21
+ const publishedTopics = new Set(); // topics we publish into the shared map (server mode)
22
+
23
+ let closed = false;
24
+ let closing = false;
25
+
26
+ // only used for backfilling existing connections to the *first* connection listener
27
+ let didBackfillConnections = false;
28
+
29
+ function join(topic, opts = {}) {
30
+ if (closing || closed) return;
31
+ const topicId = idEncoding.encode(topic);
32
+ const { server = true, client = true } = opts ?? {};
33
+ joinedTopics.add(topicId);
34
+
35
+ if (client) dialTopics.add(topicId);
36
+ else dialTopics.delete(topicId);
37
+
38
+ // Publish only in server mode.
39
+ if (server) {
40
+ const peers = topics.get(topicId) ?? new Map();
41
+ peers.set(peerId, { makeConnection: _incoming });
42
+ topics.set(topicId, peers);
43
+ publishedTopics.add(topicId);
44
+ } else {
45
+ // If previously published and now called with server=false, unpublish.
46
+ unpublish(topicId);
47
+ }
48
+
49
+ const discovery = {
50
+ leave: () => leave(topic),
51
+ destroy: () => leave(topic),
52
+ refresh: () => Promise.resolve(),
53
+ flushed: () => Promise.resolve()
54
+ };
55
+
56
+ return discovery;
57
+ }
58
+
59
+ function leave(topic) {
60
+ if (closing || closed) return;
61
+
62
+ const topicId = idEncoding.encode(topic);
63
+ joinedTopics.delete(topicId);
64
+ dialTopics.delete(topicId);
65
+ unpublish(topicId);
66
+ }
67
+
68
+ function unpublish(topicId) {
69
+ publishedTopics.delete(topicId);
70
+ const peers = topics.get(topicId);
71
+ if (!peers) return;
72
+ peers.delete(peerId);
73
+ if (peers.size === 0) topics.delete(topicId);
74
+ }
75
+
76
+ function on(event, cb) {
77
+ emitter.on(event, cb);
78
+
79
+ // Backfill only for the first "connection" listener.
80
+ if (event === "connection" && !didBackfillConnections) {
81
+ didBackfillConnections = true;
82
+ for (const { socket, peerInfo } of connections.values()) {
83
+ emitter.emit("connection", socket, peerInfo);
84
+ }
85
+ }
86
+ }
87
+
88
+ function off(event, cb) {
89
+ emitter.off(event, cb);
90
+ }
91
+
92
+ function trackConnection(peer, socket) {
93
+ const drop = () => {
94
+ connections.delete(peer);
95
+ emitter.emit("update");
96
+ };
97
+ socket.once("close", drop);
98
+ socket.once("error", drop);
99
+ }
100
+
101
+ async function _incoming(conn) {
102
+ if (closing || closed) return;
103
+ if (!conn || !conn.id) return;
104
+ if (conn.id === peerId) return;
105
+ if (connections.has(conn.id)) return;
106
+
107
+ // Guard: if we're already in-flight dialing this peer, don't also accept/construct.
108
+ // (This is conservative; the dial-election below in loop() is the main duplication killer.)
109
+ if (connecting.has(conn.id)) return;
110
+
111
+ const ssLocal = new NoiseStream(false); // incoming side: non-initiator
112
+ const ssRemote = new NoiseStream(true); // remote side: initiator (returned to caller)
113
+
114
+ ssLocal.rawStream.pipe(ssRemote.rawStream).pipe(ssLocal.rawStream);
115
+
116
+ const remotePublicKey = conn.publicKey ?? idEncoding.decode(conn.id);
117
+
118
+ const peerInfo = {
119
+ id: conn.id,
120
+ publicKey: remotePublicKey,
121
+ initiator: false
122
+ };
123
+
124
+ connections.set(conn.id, { socket: ssLocal, peerInfo });
125
+ trackConnection(conn.id, ssLocal);
126
+ emitter.emit("update");
127
+
128
+ ssLocal.once("open", () => {
129
+ if (closing || closed) return;
130
+ ssLocal.remotePublicKey = remotePublicKey;
131
+ emitter.emit("connection", ssLocal, peerInfo);
132
+ });
133
+
134
+ return ssRemote;
135
+ }
136
+
137
+ function shouldDial(remotePeerId) {
138
+ // Deterministic dial election: only one side initiates.
139
+ // If both sides run the same code, this prevents double-connect.
140
+ return peerId < remotePeerId;
141
+ }
142
+
143
+ async function dial(remotePeerId, makeConnection) {
144
+ if (closing || closed) return;
145
+ if (remotePeerId === peerId) return;
146
+ if (connections.has(remotePeerId)) return;
147
+
148
+ if (!shouldDial(remotePeerId)) return;
149
+ if (connecting.has(remotePeerId)) return;
150
+
151
+ const p = (async () => {
152
+ const socket = await makeConnection({
153
+ id: peerId,
154
+ publicKey: keyPair.publicKey
155
+ });
156
+
157
+ if (closing || closed) {
158
+ if (socket?.destroy) await destroySocket(socket);
159
+ return;
160
+ }
161
+ if (!socket) return;
162
+
163
+ // It's possible we raced and connected through the other side while awaiting.
164
+ if (connections.has(remotePeerId)) {
165
+ if (socket?.destroy) await destroySocket(socket);
166
+ return;
167
+ }
168
+
169
+ const remotePublicKey = idEncoding.decode(remotePeerId);
170
+
171
+ const peerInfo = {
172
+ id: remotePeerId,
173
+ publicKey: remotePublicKey,
174
+ initiator: true
175
+ };
176
+
177
+ connections.set(remotePeerId, { socket, peerInfo });
178
+ trackConnection(remotePeerId, socket);
179
+ emitter.emit("update");
180
+
181
+ socket.once("open", () => {
182
+ if (closing || closed) return;
183
+ socket.remotePublicKey = remotePublicKey;
184
+ emitter.emit("connection", socket, peerInfo);
185
+ });
186
+ })().finally(() => {
187
+ connecting.delete(remotePeerId);
188
+ });
189
+
190
+ connecting.set(remotePeerId, p);
191
+ await p;
192
+ }
193
+
194
+ let tickTimer;
195
+
196
+ function tick() {
197
+ if (closing || closed) return;
198
+
199
+ for (const [topicId, peers] of topics.entries()) {
200
+ if (!dialTopics.has(topicId)) continue;
201
+
202
+ for (const [remotePeerId, { makeConnection }] of peers.entries()) {
203
+ if (remotePeerId === peerId) continue; // FIX: continue, not return
204
+ if (connections.has(remotePeerId)) continue; // FIX: continue, not return
205
+ if (closing || closed) return;
206
+
207
+ // Fire and forget; connection is guarded by connecting map
208
+ // (but still awaits inside dial for sequencing correctness).
209
+ void dial(remotePeerId, makeConnection);
210
+ }
211
+ }
212
+
213
+ if (closing || closed) return;
214
+ tickTimer = setTimeout(tick, 10);
215
+ }
216
+
217
+ async function flush(timeoutMs = 100) {
218
+ if (closing || closed) return;
219
+
220
+ const start = Date.now();
221
+ let stableZero = 0;
222
+
223
+ while (!closed && !closing) {
224
+ // Wait for any in-flight dial promises.
225
+ await Promise.allSettled(Array.from(connecting.values()));
226
+
227
+ if (connecting.size === 0) {
228
+ stableZero += 1;
229
+ if (stableZero >= 2) return; // two consecutive quiet checks
230
+ } else {
231
+ stableZero = 0;
232
+ }
233
+
234
+ if (Date.now() - start >= timeoutMs) return;
235
+ await new Promise((resolve) => setTimeout(resolve, 5));
236
+ }
237
+ }
238
+
239
+ async function destroySocket(s) {
240
+ if (!s) return;
241
+ await new Promise((resolve) => {
242
+ const done = () => resolve();
243
+
244
+ try {
245
+ if (s.destroyed) return resolve();
246
+ s.once?.("close", done);
247
+ s.once?.("error", done);
248
+ if (typeof s.destroy === "function") s.destroy();
249
+ else setImmediate(done);
250
+ // Fallback in case no events fire.
251
+ setTimeout(done, 10);
252
+ } catch {
253
+ resolve();
254
+ }
255
+ });
256
+ }
257
+
258
+ tick();
259
+
260
+ return {
261
+ async close() {
262
+ if (closed || closing) return;
263
+ closing = true;
264
+
265
+ if (tickTimer) clearTimeout(tickTimer);
266
+
267
+ // Stop publishing our topics (prevents new dials toward us)
268
+ for (const topicId of Array.from(publishedTopics)) {
269
+ unpublish(topicId);
270
+ }
271
+ publishedTopics.clear();
272
+ dialTopics.clear();
273
+ joinedTopics.clear();
274
+
275
+ // Wait for in-flight connects to settle (best-effort)
276
+ await Promise.allSettled(Array.from(connecting.values()));
277
+
278
+ // Destroy sockets best-effort.
279
+ await Promise.allSettled(
280
+ Array.from(connections.values()).map(({ socket }) => destroySocket(socket))
281
+ );
282
+
283
+ connections.clear();
284
+ emitter.emit("update");
285
+ closed = true;
286
+ emitter.emit("close");
287
+ },
288
+
289
+ // Alias used by some labs; accepts options for API parity but ignores them.
290
+ async destroy(_opts = {}) {
291
+ await this.close();
292
+ },
293
+
294
+ connections, // Map(remotePeerId -> { socket, peerInfo })
295
+ join,
296
+ leave,
297
+ flush,
298
+ topics: joinedTopics,
299
+ on,
300
+ off,
301
+ keyPair,
302
+ id: peerId
303
+ };
304
+ }
305
+
306
+ export { createFakeSwarm }
307
+ export default createFakeSwarm;