fakeswarm 0.1.2 → 0.2.1

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/README.md CHANGED
@@ -47,8 +47,10 @@ Always call `close()` when done to unpublish topics and destroy sockets.
47
47
 
48
48
  ## API
49
49
 
50
- ### createFakeSwarm(seed?, topics?)
51
- - `seed` (Buffer | Uint8Array | null): optional seed passed to `hypercore-crypto.keyPair` for deterministic keys.
50
+ ### createFakeSwarm(seedOrOpts?, topics?)
51
+ - `seedOrOpts` can be:
52
+ - `Buffer | Uint8Array | null` (legacy seed) or
53
+ - `{ seed?, net? }`
52
54
  - `topics` (Map) optional shared topics map. By default a module-level Map is used so multiple swarms share the same space.
53
55
 
54
56
  Returns an object with:
@@ -68,9 +70,18 @@ Returns an object with:
68
70
  - `keyPair`: the generated keypair.
69
71
  - `id`: `idEncoding.encode(publicKey)` convenience string.
70
72
 
73
+ #### Network profile (opt-in, default off)
74
+ `net.reconnectRace` lets you simulate a quick disconnect/reconnect overlap:
75
+ - `enabled` (boolean): default false. When false, behavior is unchanged.
76
+ - `staleRetentionMs` (default 25): how long a closed socket is retained internally before destroy.
77
+ - `reconnectDelayMs` (default 10): how soon a reconnect attempt is triggered.
78
+ - `duplicateConnection` (boolean): if true, emit a second `connection` event for the new socket even while the stale one is retained.
79
+ `connections` still exposes only the current socket per peer; stale sockets are kept internally and cleaned up after the retention window.
80
+
71
81
  ### Join / leave semantics
72
82
  - Joining registers this swarm in the shared topics map; peers that share a topic will connect.
73
83
  - Leaving (or closing) removes the entry from the shared map to avoid ghost peers.
84
+ - **Testing tip:** pass a fresh `topics = new Map()` per test to avoid cross-test bleed; the default module-level map is shared across all swarms in the process.
74
85
 
75
86
  ### Determinism notes
76
87
  - Dial election: only the lexicographically smaller peerId initiates (`peerId < remotePeerId`), preventing double connects.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fakeswarm",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Deterministic in-memory fake hyperswarm for tests and demos.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -2,10 +2,20 @@ import EventEmitter from "events";
2
2
  import idEncoding from "hypercore-id-encoding";
3
3
  import Krypto from "hypercore-crypto";
4
4
  import NoiseStream from "@hyperswarm/secret-stream";
5
+ import { normalizeArgs } from "./normalize.js";
5
6
 
6
7
  const defaultTopics = new Map();
7
8
 
8
- function createFakeSwarm(seed = undefined, topics = defaultTopics) {
9
+ function createFakeSwarm(seedOrOpts = undefined, topicsArg = defaultTopics) {
10
+ const { seed, net, topics } = normalizeArgs(seedOrOpts, topicsArg);
11
+
12
+ const reconnectRace = {
13
+ enabled: !!net?.reconnectRace?.enabled,
14
+ staleRetentionMs: net?.reconnectRace?.staleRetentionMs ?? 25,
15
+ reconnectDelayMs: net?.reconnectRace?.reconnectDelayMs ?? 10,
16
+ duplicateConnection: !!net?.reconnectRace?.duplicateConnection
17
+ };
18
+
9
19
  const keyPair = Krypto.keyPair(seed);
10
20
  const peerId = idEncoding.encode(keyPair.publicKey);
11
21
 
@@ -15,7 +25,15 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
15
25
  // remotePeerId -> Promise<void> (prevents duplicate concurrent dials)
16
26
  const connecting = new Map();
17
27
 
28
+ // remotePeerId -> array of stale sockets kept temporarily
29
+ const staleSockets = new Map();
30
+
31
+ // timers we await during flush/close for determinism
32
+ const retentionTimers = new Set();
33
+ const reconnectTimers = new Set();
34
+
18
35
  const emitter = new EventEmitter();
36
+ emitter.setMaxListeners(0);
19
37
  const joinedTopics = new Set(); // all topics we joined (regardless of mode)
20
38
  const dialTopics = new Set(); // topics where we will attempt outbound dials (client mode)
21
39
  const publishedTopics = new Set(); // topics we publish into the shared map (server mode)
@@ -89,10 +107,44 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
89
107
  emitter.off(event, cb);
90
108
  }
91
109
 
110
+ function retainStale(peer, socket) {
111
+ if (!reconnectRace.enabled) return;
112
+ const timer = setTimeout(async () => {
113
+ retentionTimers.delete(timer);
114
+ await destroySocket(socket);
115
+ const arr = staleSockets.get(peer);
116
+ if (arr) {
117
+ staleSockets.set(peer, arr.filter((s) => s !== socket));
118
+ if (staleSockets.get(peer).length === 0) staleSockets.delete(peer);
119
+ }
120
+ }, reconnectRace.staleRetentionMs);
121
+ retentionTimers.add(timer);
122
+ const arr = staleSockets.get(peer) ?? [];
123
+ arr.push(socket);
124
+ staleSockets.set(peer, arr);
125
+ }
126
+
127
+ function scheduleReconnectTick() {
128
+ if (!reconnectRace.enabled) return;
129
+ const timer = setTimeout(() => {
130
+ reconnectTimers.delete(timer);
131
+ if (!closing && !closed) tick();
132
+ }, reconnectRace.reconnectDelayMs);
133
+ reconnectTimers.add(timer);
134
+ }
135
+
92
136
  function trackConnection(peer, socket) {
93
137
  const drop = () => {
94
- connections.delete(peer);
95
- emitter.emit("update");
138
+ const current = connections.get(peer)?.socket;
139
+ if (current === socket) {
140
+ connections.delete(peer);
141
+ emitter.emit("update");
142
+ retainStale(peer, socket);
143
+ scheduleReconnectTick();
144
+ } else if (reconnectRace.enabled) {
145
+ // stale socket closed after new one took over
146
+ retainStale(peer, socket);
147
+ }
96
148
  };
97
149
  socket.once("close", drop);
98
150
  socket.once("error", drop);
@@ -102,7 +154,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
102
154
  if (closing || closed) return;
103
155
  if (!conn || !conn.id) return;
104
156
  if (conn.id === peerId) return;
105
- if (connections.has(conn.id)) return;
157
+ if (connections.has(conn.id)) {
158
+ if (!reconnectRace.duplicateConnection) return;
159
+ // allow duplicate event later; still accept for overlap
160
+ }
106
161
 
107
162
  // Guard: if we're already in-flight dialing this peer, don't also accept/construct.
108
163
  // (This is conservative; the dial-election below in loop() is the main duplication killer.)
@@ -143,7 +198,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
143
198
  async function dial(remotePeerId, makeConnection) {
144
199
  if (closing || closed) return;
145
200
  if (remotePeerId === peerId) return;
146
- if (connections.has(remotePeerId)) return;
201
+ if (connections.has(remotePeerId) && !reconnectRace.duplicateConnection) return;
147
202
 
148
203
  if (!shouldDial(remotePeerId)) return;
149
204
  if (connecting.has(remotePeerId)) return;
@@ -161,7 +216,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
161
216
  if (!socket) return;
162
217
 
163
218
  // It's possible we raced and connected through the other side while awaiting.
164
- if (connections.has(remotePeerId)) {
219
+ if (connections.has(remotePeerId) && !reconnectRace.duplicateConnection) {
165
220
  if (socket?.destroy) await destroySocket(socket);
166
221
  return;
167
222
  }
@@ -224,7 +279,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
224
279
  // Wait for any in-flight dial promises.
225
280
  await Promise.allSettled(Array.from(connecting.values()));
226
281
 
227
- if (connecting.size === 0) {
282
+ if (connecting.size === 0 && retentionTimers.size === 0 && reconnectTimers.size === 0) {
228
283
  stableZero += 1;
229
284
  if (stableZero >= 2) return; // two consecutive quiet checks
230
285
  } else {
@@ -263,6 +318,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
263
318
  closing = true;
264
319
 
265
320
  if (tickTimer) clearTimeout(tickTimer);
321
+ for (const t of Array.from(retentionTimers)) clearTimeout(t);
322
+ for (const t of Array.from(reconnectTimers)) clearTimeout(t);
323
+ retentionTimers.clear();
324
+ reconnectTimers.clear();
266
325
 
267
326
  // Stop publishing our topics (prevents new dials toward us)
268
327
  for (const topicId of Array.from(publishedTopics)) {
@@ -279,6 +338,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
279
338
  await Promise.allSettled(
280
339
  Array.from(connections.values()).map(({ socket }) => destroySocket(socket))
281
340
  );
341
+ await Promise.allSettled(
342
+ Array.from(staleSockets.values()).flat().map((socket) => destroySocket(socket))
343
+ );
344
+ staleSockets.clear();
282
345
 
283
346
  connections.clear();
284
347
  emitter.emit("update");
@@ -0,0 +1,17 @@
1
+ export function normalizeArgs(seedOrOpts, topicsArg) {
2
+ const isBufferLike =
3
+ seedOrOpts &&
4
+ (Buffer.isBuffer(seedOrOpts) ||
5
+ seedOrOpts instanceof Uint8Array);
6
+
7
+ if (isBufferLike || seedOrOpts === null || seedOrOpts === undefined) {
8
+ return { seed: seedOrOpts, net: undefined, topics: topicsArg };
9
+ }
10
+
11
+ if (typeof seedOrOpts === "object") {
12
+ const { seed = undefined, net = undefined } = seedOrOpts;
13
+ return { seed, net, topics: topicsArg };
14
+ }
15
+
16
+ return { seed: undefined, net: undefined, topics: topicsArg };
17
+ }