fakeswarm 0.1.2 → 0.2.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.
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,6 +70,14 @@ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fakeswarm",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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,6 +25,13 @@ 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();
19
36
  const joinedTopics = new Set(); // all topics we joined (regardless of mode)
20
37
  const dialTopics = new Set(); // topics where we will attempt outbound dials (client mode)
@@ -89,10 +106,44 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
89
106
  emitter.off(event, cb);
90
107
  }
91
108
 
109
+ function retainStale(peer, socket) {
110
+ if (!reconnectRace.enabled) return;
111
+ const timer = setTimeout(async () => {
112
+ retentionTimers.delete(timer);
113
+ await destroySocket(socket);
114
+ const arr = staleSockets.get(peer);
115
+ if (arr) {
116
+ staleSockets.set(peer, arr.filter((s) => s !== socket));
117
+ if (staleSockets.get(peer).length === 0) staleSockets.delete(peer);
118
+ }
119
+ }, reconnectRace.staleRetentionMs);
120
+ retentionTimers.add(timer);
121
+ const arr = staleSockets.get(peer) ?? [];
122
+ arr.push(socket);
123
+ staleSockets.set(peer, arr);
124
+ }
125
+
126
+ function scheduleReconnectTick() {
127
+ if (!reconnectRace.enabled) return;
128
+ const timer = setTimeout(() => {
129
+ reconnectTimers.delete(timer);
130
+ if (!closing && !closed) tick();
131
+ }, reconnectRace.reconnectDelayMs);
132
+ reconnectTimers.add(timer);
133
+ }
134
+
92
135
  function trackConnection(peer, socket) {
93
136
  const drop = () => {
94
- connections.delete(peer);
95
- emitter.emit("update");
137
+ const current = connections.get(peer)?.socket;
138
+ if (current === socket) {
139
+ connections.delete(peer);
140
+ emitter.emit("update");
141
+ retainStale(peer, socket);
142
+ scheduleReconnectTick();
143
+ } else if (reconnectRace.enabled) {
144
+ // stale socket closed after new one took over
145
+ retainStale(peer, socket);
146
+ }
96
147
  };
97
148
  socket.once("close", drop);
98
149
  socket.once("error", drop);
@@ -102,7 +153,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
102
153
  if (closing || closed) return;
103
154
  if (!conn || !conn.id) return;
104
155
  if (conn.id === peerId) return;
105
- if (connections.has(conn.id)) return;
156
+ if (connections.has(conn.id)) {
157
+ if (!reconnectRace.duplicateConnection) return;
158
+ // allow duplicate event later; still accept for overlap
159
+ }
106
160
 
107
161
  // Guard: if we're already in-flight dialing this peer, don't also accept/construct.
108
162
  // (This is conservative; the dial-election below in loop() is the main duplication killer.)
@@ -143,7 +197,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
143
197
  async function dial(remotePeerId, makeConnection) {
144
198
  if (closing || closed) return;
145
199
  if (remotePeerId === peerId) return;
146
- if (connections.has(remotePeerId)) return;
200
+ if (connections.has(remotePeerId) && !reconnectRace.duplicateConnection) return;
147
201
 
148
202
  if (!shouldDial(remotePeerId)) return;
149
203
  if (connecting.has(remotePeerId)) return;
@@ -161,7 +215,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
161
215
  if (!socket) return;
162
216
 
163
217
  // It's possible we raced and connected through the other side while awaiting.
164
- if (connections.has(remotePeerId)) {
218
+ if (connections.has(remotePeerId) && !reconnectRace.duplicateConnection) {
165
219
  if (socket?.destroy) await destroySocket(socket);
166
220
  return;
167
221
  }
@@ -224,7 +278,7 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
224
278
  // Wait for any in-flight dial promises.
225
279
  await Promise.allSettled(Array.from(connecting.values()));
226
280
 
227
- if (connecting.size === 0) {
281
+ if (connecting.size === 0 && retentionTimers.size === 0 && reconnectTimers.size === 0) {
228
282
  stableZero += 1;
229
283
  if (stableZero >= 2) return; // two consecutive quiet checks
230
284
  } else {
@@ -263,6 +317,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
263
317
  closing = true;
264
318
 
265
319
  if (tickTimer) clearTimeout(tickTimer);
320
+ for (const t of Array.from(retentionTimers)) clearTimeout(t);
321
+ for (const t of Array.from(reconnectTimers)) clearTimeout(t);
322
+ retentionTimers.clear();
323
+ reconnectTimers.clear();
266
324
 
267
325
  // Stop publishing our topics (prevents new dials toward us)
268
326
  for (const topicId of Array.from(publishedTopics)) {
@@ -279,6 +337,10 @@ function createFakeSwarm(seed = undefined, topics = defaultTopics) {
279
337
  await Promise.allSettled(
280
338
  Array.from(connections.values()).map(({ socket }) => destroySocket(socket))
281
339
  );
340
+ await Promise.allSettled(
341
+ Array.from(staleSockets.values()).flat().map((socket) => destroySocket(socket))
342
+ );
343
+ staleSockets.clear();
282
344
 
283
345
  connections.clear();
284
346
  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
+ }