@zapo-js/fake-server 1.0.0 → 1.1.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.
@@ -3,122 +3,92 @@ import { WaFakeConnectionPipeline } from '../infra/WaFakeConnectionPipeline.js';
3
3
  import { WaFakeMediaHttpsServer } from '../infra/WaFakeMediaHttpsServer.js';
4
4
  import { WaFakeWsServer } from '../infra/WaFakeWsServer.js';
5
5
  import { generateFakeNoiseRootCa } from '../protocol/auth/cert-chain.js';
6
- import { WaFakeIqRouter } from '../protocol/iq/router.js';
7
6
  import { FakeMediaStore } from '../state/fake-media-store.js';
8
7
  import { X25519 } from '../transport/crypto.js';
9
- import { AppStateSyncManager } from './AppStateSyncManager.js';
10
8
  import { FakePairingDriver } from './FakePairingDriver.js';
11
9
  import { FakePeer } from './FakePeer.js';
12
- import { registerDefaultIqHandlers } from './iq-handlers.js';
13
- import { PreKeyDispenser } from './PreKeyDispenser.js';
10
+ import { FakeServerSession } from './FakeServerSession.js';
14
11
  import { Scenario } from './Scenario.js';
15
- import { ServerRegistries } from './ServerRegistries.js';
12
+ export { FakeServerSession } from './FakeServerSession.js';
16
13
  export class FakeWaServer {
17
14
  constructor(options = {}) {
18
- this.registries = new ServerRegistries();
19
- this.preKeyDispenser = new PreKeyDispenser();
20
- this.appStateSync = new AppStateSyncManager();
21
15
  this.pipelines = new Set();
22
- this.iqRouter = new WaFakeIqRouter();
23
- this.capturedStanzas = [];
24
- this.pendingIqExpectations = new Set();
25
- this.pendingStanzaExpectations = new Set();
16
+ this.pipelineSessions = new WeakMap();
26
17
  this.authenticatedListeners = new Set();
27
- this.inboundStanzaListeners = new Set();
28
- this.capturedStanzaListeners = new Set();
18
+ this.sessions = new Map();
19
+ this.globalIqHandlers = new Set();
29
20
  this.rootCa = null;
30
21
  this.serverStaticKeyPair = null;
31
22
  this.listenInfo = null;
32
- this.pipelineListener = null;
23
+ this.pipelineListeners = new Set();
33
24
  this.rejectMode = null;
34
25
  this.mediaStore = new FakeMediaStore();
35
26
  this.mediaHttpsServer = new WaFakeMediaHttpsServer();
36
27
  this.capturedMediaUploads = [];
37
28
  this.nextUploadCounter = 0;
38
29
  this.cachedMediaProxyAgent = null;
30
+ this.options = options;
39
31
  this.wsServer = new WaFakeWsServer(options);
40
32
  this.wsServer.onConnection((connection) => this.handleConnection(connection));
41
- registerDefaultIqHandlers(this.iqRouter, this.buildIqHandlerDeps());
33
+ this.defaultSession = this.createSession('__default__');
42
34
  }
43
- buildIqHandlerDeps() {
44
- const reg = this.registries;
45
- const preKey = this.preKeyDispenser;
46
- const appState = this.appStateSync;
47
- return {
48
- get peerRegistry() {
49
- return reg.peerRegistry;
50
- },
51
- get groupRegistry() {
52
- return reg.groupRegistry;
53
- },
54
- get privacySettings() {
55
- return reg.privacySettings;
56
- },
57
- get blocklistJids() {
58
- return reg.blocklistJids;
59
- },
60
- get profilePicturesByJid() {
61
- return reg.profilePicturesByJid;
62
- },
63
- get businessProfilesByJid() {
64
- return reg.businessProfilesByJid;
65
- },
66
- get abPropsInput() {
67
- return reg.abPropsInput;
68
- },
69
- get issuedPrivacyTokens() {
70
- return reg.issuedPrivacyTokens;
71
- },
72
- get latestStatusText() {
73
- return reg.latestStatusText;
74
- },
75
- setLatestStatusText: (text) => {
76
- reg.latestStatusText = text;
77
- },
78
- lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
79
- notifyGroupOp: (op) => reg.notifyGroupOp(op),
80
- mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
81
- mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
82
- notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
83
- handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
84
- notifyStatusSet: (text) => reg.notifyStatusSet(text),
85
- notifyLogout: () => reg.notifyLogout(),
86
- notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
87
- notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
88
- notifyPrivacySet: (change) => {
89
- for (const listener of reg.privacySetListeners) {
90
- try {
91
- listener(change);
92
- }
93
- catch (error) {
94
- void error;
95
- }
96
- }
97
- },
98
- notifyBlocklistChange: (change) => {
99
- for (const listener of reg.blocklistChangeListeners) {
100
- try {
101
- listener(change);
102
- }
103
- catch (error) {
104
- void error;
105
- }
106
- }
107
- },
108
- capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
109
- consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
110
- get appStateCollectionProviders() {
111
- return appState.appStateCollectionProviders;
112
- },
113
- requireMediaHttpsInfo: () => this.requireMediaHttpsInfo()
114
- };
35
+ /** State of the single default session (the only session for one client). */
36
+ get registries() {
37
+ return this.defaultSession.registries;
38
+ }
39
+ get preKeyDispenser() {
40
+ return this.defaultSession.preKeyDispenser;
41
+ }
42
+ get appStateSync() {
43
+ return this.defaultSession.appStateSync;
44
+ }
45
+ /**
46
+ * Returns the isolated session for `id`, creating it on first use. Every
47
+ * session gets its own registries, prekey dispenser, app-state, IQ router,
48
+ * and stanza capture. Use with the `sessionKey` option to run several
49
+ * `WaClient`s against one server without them sharing any state.
50
+ */
51
+ session(id) {
52
+ const existing = this.sessions.get(id);
53
+ if (existing) {
54
+ return existing;
55
+ }
56
+ return this.createSession(id);
57
+ }
58
+ /** The session a connection is bound to (default before authentication). */
59
+ sessionFor(pipeline) {
60
+ return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
61
+ }
62
+ createSession(id) {
63
+ const session = new FakeServerSession(id, { requireMediaHttpsInfo: () => this.requireMediaHttpsInfo() }, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
64
+ this.sessions.set(id, session);
65
+ // Server-level custom handlers apply to every session, present and future.
66
+ for (const handler of this.globalIqHandlers) {
67
+ handler.unregisterBySession.set(session, session.registerIqHandler(handler.matcher, handler.respond, handler.label));
68
+ }
69
+ return session;
115
70
  }
116
71
  registerIqHandler(matcher, respond, label) {
117
- const handler = { matcher, respond, label };
118
- return this.iqRouter.register(handler, { priority: 'high' });
72
+ const entry = {
73
+ matcher,
74
+ respond,
75
+ label,
76
+ unregisterBySession: new Map()
77
+ };
78
+ for (const session of this.sessions.values()) {
79
+ entry.unregisterBySession.set(session, session.registerIqHandler(matcher, respond, label));
80
+ }
81
+ this.globalIqHandlers.add(entry);
82
+ return () => {
83
+ for (const unregister of entry.unregisterBySession.values()) {
84
+ unregister();
85
+ }
86
+ entry.unregisterBySession.clear();
87
+ this.globalIqHandlers.delete(entry);
88
+ };
119
89
  }
120
90
  async routeIqForTest(iq) {
121
- return this.iqRouter.route(iq);
91
+ return this.defaultSession.routeIq(iq);
122
92
  }
123
93
  onOutboundGroupOp(listener) {
124
94
  return this.registries.onOutboundGroupOp(listener);
@@ -175,7 +145,7 @@ export class FakeWaServer {
175
145
  return this.registries.createFakeGroup(input);
176
146
  }
177
147
  async triggerPreKeyUpload(pipeline, options = {}) {
178
- return this.preKeyDispenser.triggerPreKeyUpload(pipeline, options);
148
+ return this.sessionFor(pipeline).preKeyDispenser.triggerPreKeyUpload(pipeline, options);
179
149
  }
180
150
  awaitPreKeyBundle(timeoutMs = 15000) {
181
151
  return this.preKeyDispenser.awaitPreKeyBundle(timeoutMs);
@@ -205,7 +175,7 @@ export class FakeWaServer {
205
175
  return this.appStateSync.provideAppStateCollection(name, provider);
206
176
  }
207
177
  async pushServerSyncNotification(pipeline, input) {
208
- return this.appStateSync.pushServerSyncNotification(pipeline, input);
178
+ return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
209
179
  }
210
180
  onAuthenticatedPipeline(listener) {
211
181
  this.authenticatedListeners.add(listener);
@@ -215,78 +185,24 @@ export class FakeWaServer {
215
185
  configure(new Scenario(this));
216
186
  }
217
187
  expectIq(matcher, options = {}) {
218
- const timeoutMs = options.timeoutMs ?? 2000;
219
- for (const captured of this.capturedStanzas) {
220
- if (matchesIq(captured, matcher)) {
221
- return Promise.resolve(captured);
222
- }
223
- }
224
- return new Promise((resolve, reject) => {
225
- const expectation = {
226
- matcher,
227
- resolve: (iq) => {
228
- this.pendingIqExpectations.delete(expectation);
229
- clearTimeout(expectation.timer);
230
- resolve(iq);
231
- },
232
- reject: (error) => {
233
- this.pendingIqExpectations.delete(expectation);
234
- clearTimeout(expectation.timer);
235
- reject(error);
236
- },
237
- timer: setTimeout(() => {
238
- this.pendingIqExpectations.delete(expectation);
239
- reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
240
- }, timeoutMs)
241
- };
242
- this.pendingIqExpectations.add(expectation);
243
- });
188
+ return this.defaultSession.expectIq(matcher, options);
244
189
  }
245
190
  capturedStanzaSnapshot() {
246
- return this.capturedStanzas.slice();
191
+ return this.defaultSession.capturedStanzaSnapshot();
247
192
  }
248
193
  /**
249
- * Subscribes to every stanza captured from the client side (the lib).
250
- * The listener is called synchronously for each new stanza as it
251
- * arrives, in addition to `capturedStanzas` being appended. Returns
252
- * an unsubscribe function. Useful for benches that need to count or
253
- * react to a stream of receipts/messages without paying the O(N²)
254
- * cost of polling `capturedStanzaSnapshot()` or queuing one
255
- * `expectStanza(...)` per iteration.
194
+ * Subscribes to every stanza captured from the client side (the lib) of
195
+ * the default session. The listener is called synchronously for each new
196
+ * stanza as it arrives. Returns an unsubscribe function. Useful for
197
+ * benches that need to count or react to a stream of receipts/messages
198
+ * without paying the O(N²) cost of polling `capturedStanzaSnapshot()` or
199
+ * queuing one `expectStanza(...)` per iteration.
256
200
  */
257
201
  onCapturedStanza(listener) {
258
- this.capturedStanzaListeners.add(listener);
259
- return () => {
260
- this.capturedStanzaListeners.delete(listener);
261
- };
202
+ return this.defaultSession.onCapturedStanza(listener);
262
203
  }
263
204
  expectStanza(matcher, options = {}) {
264
- const timeoutMs = options.timeoutMs ?? 2000;
265
- for (const captured of this.capturedStanzas) {
266
- if (matchesStanza(captured, matcher)) {
267
- return Promise.resolve(captured);
268
- }
269
- }
270
- return new Promise((resolve, reject) => {
271
- const expectation = {
272
- matcher,
273
- resolve: (node) => {
274
- this.pendingStanzaExpectations.delete(expectation);
275
- clearTimeout(expectation.timer);
276
- resolve(node);
277
- },
278
- reject: (error) => {
279
- this.pendingStanzaExpectations.delete(expectation);
280
- clearTimeout(expectation.timer);
281
- reject(error);
282
- },
283
- timer: setTimeout(() => {
284
- this.pendingStanzaExpectations.delete(expectation);
285
- reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
286
- }, timeoutMs)
287
- };
288
- this.pendingStanzaExpectations.add(expectation);
289
- });
205
+ return this.defaultSession.expectStanza(matcher, options);
290
206
  }
291
207
  async broadcastStanza(node) {
292
208
  const tasks = [];
@@ -348,8 +264,9 @@ export class FakeWaServer {
348
264
  return this.capturedMediaUploads.slice();
349
265
  }
350
266
  async createFakePeer(options, pipeline) {
351
- const peer = await FakePeer.create(options, this.buildFakePeerDeps(pipeline));
352
- this.registries.registerPeer(peer);
267
+ const session = this.sessionFor(pipeline);
268
+ const peer = await FakePeer.create(options, this.buildFakePeerDeps(pipeline, session));
269
+ session.registries.registerPeer(peer);
353
270
  return peer;
354
271
  }
355
272
  async createFakePeerWithDevices(input, pipeline) {
@@ -362,6 +279,7 @@ export class FakeWaServer {
362
279
  }
363
280
  const userPart = input.userJid.slice(0, atIdx);
364
281
  const server = input.userJid.slice(atIdx + 1);
282
+ const session = this.sessionFor(pipeline);
365
283
  const peers = [];
366
284
  for (const deviceId of input.deviceIds) {
367
285
  const deviceJid = deviceId === 0 ? input.userJid : `${userPart}:${deviceId}@${server}`;
@@ -369,36 +287,27 @@ export class FakeWaServer {
369
287
  jid: deviceJid,
370
288
  displayName: input.displayName,
371
289
  skipOneTimePreKey: input.skipOneTimePreKey
372
- }, this.buildFakePeerDeps(pipeline));
373
- this.registries.registerPeer(peer);
290
+ }, this.buildFakePeerDeps(pipeline, session));
291
+ session.registries.registerPeer(peer);
374
292
  peers.push(peer);
375
293
  }
376
294
  return peers;
377
295
  }
378
- buildFakePeerDeps(pipeline) {
296
+ buildFakePeerDeps(pipeline, session) {
379
297
  return {
380
- bundleResolver: () => this.preKeyDispenser.awaitPreKeyBundle(),
381
- reserveOneTimePreKey: () => this.preKeyDispenser.dispenseOneTimePreKey(),
298
+ bundleResolver: () => session.preKeyDispenser.awaitPreKeyBundle(),
299
+ reserveOneTimePreKey: () => session.preKeyDispenser.dispenseOneTimePreKey(),
382
300
  pushStanza: (stanza) => pipeline.sendStanza(stanza),
383
- subscribeInboundMessages: (listener) => {
384
- const wrapped = (node) => {
385
- if (node.tag !== 'message')
386
- return;
387
- listener(node);
388
- };
389
- this.inboundStanzaListeners.add(wrapped);
390
- return () => {
391
- this.inboundStanzaListeners.delete(wrapped);
392
- };
393
- }
301
+ subscribeInboundMessages: (listener) => session.subscribeInboundMessages(listener)
394
302
  };
395
303
  }
396
304
  async runPairing(pipeline, options, companionMaterialResolver) {
305
+ const session = this.sessionFor(pipeline);
397
306
  const driver = new FakePairingDriver(options, {
398
307
  pipeline,
399
308
  companionMaterialResolver,
400
309
  waitForPairDeviceAck: async (pairDeviceIqId) => {
401
- await this.expectIq({
310
+ await session.expectIq({
402
311
  id: pairDeviceIqId
403
312
  }, {
404
313
  timeoutMs: 15000
@@ -435,8 +344,15 @@ export class FakeWaServer {
435
344
  const root = this.requireRootCa();
436
345
  return { publicKey: root.publicKey, serial: root.serial };
437
346
  }
347
+ /**
348
+ * Subscribes to every new connection pipeline (pre-auth). Listeners
349
+ * fan out; returns an unsubscribe function.
350
+ */
438
351
  onPipeline(listener) {
439
- this.pipelineListener = listener;
352
+ this.pipelineListeners.add(listener);
353
+ return () => {
354
+ this.pipelineListeners.delete(listener);
355
+ };
440
356
  }
441
357
  async listen() {
442
358
  if (this.listenInfo) {
@@ -478,53 +394,51 @@ export class FakeWaServer {
478
394
  connection,
479
395
  rootCa: this.rootCa,
480
396
  serverStaticKeyPair: this.serverStaticKeyPair,
481
- iqRouter: this.iqRouter
397
+ routeIq: (node) => this.sessionFor(pipeline).routeIq(node),
398
+ ...(this.options.successNodeAttributes !== undefined
399
+ ? { successNodeAttributes: this.options.successNodeAttributes }
400
+ : {})
482
401
  });
483
402
  this.pipelines.add(pipeline);
403
+ this.pipelineSessions.set(pipeline, this.defaultSession);
484
404
  pipeline.setEvents({
485
405
  onAuthenticated: () => {
406
+ this.bindPipelineSession(pipeline);
486
407
  for (const listener of this.authenticatedListeners) {
487
- void listener(pipeline);
408
+ try {
409
+ void Promise.resolve(listener(pipeline)).catch(() => undefined);
410
+ }
411
+ catch {
412
+ /* ignore */
413
+ }
488
414
  }
489
415
  },
490
- onStanza: (node) => this.handleCapturedStanza(node),
416
+ onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
491
417
  onClose: () => this.pipelines.delete(pipeline)
492
418
  });
493
- this.pipelineListener?.(pipeline);
494
- }
495
- handleCapturedStanza(node) {
496
- this.capturedStanzas.push(node);
497
- for (const listener of this.capturedStanzaListeners) {
419
+ for (const listener of this.pipelineListeners) {
498
420
  try {
499
- listener(node);
421
+ void Promise.resolve(listener(pipeline)).catch(() => undefined);
500
422
  }
501
- catch (error) {
502
- void error;
423
+ catch {
424
+ /* ignore */
503
425
  }
504
426
  }
505
- for (const listener of this.inboundStanzaListeners) {
506
- try {
507
- listener(node);
508
- }
509
- catch (error) {
510
- void error;
511
- }
512
- }
513
- for (const expectation of this.pendingStanzaExpectations) {
514
- if (matchesStanza(node, expectation.matcher)) {
515
- expectation.resolve(node);
516
- break;
517
- }
518
- }
519
- if (node.tag !== 'iq') {
427
+ }
428
+ /**
429
+ * Rebinds a connection to its keyed session once authenticated. Runs
430
+ * before user `onAuthenticated` listeners and before any client stanza, so
431
+ * captures and IQ routing land in the right session from the first frame.
432
+ * No-op without a `sessionKey` resolver (single default session).
433
+ */
434
+ bindPipelineSession(pipeline) {
435
+ const resolveKey = this.options.sessionKey;
436
+ const clientPayload = pipeline.clientPayload;
437
+ if (!resolveKey || !clientPayload) {
520
438
  return;
521
439
  }
522
- for (const expectation of this.pendingIqExpectations) {
523
- if (matchesIq(node, expectation.matcher)) {
524
- expectation.resolve(node);
525
- return;
526
- }
527
- }
440
+ const id = resolveKey({ clientPayload, pipeline });
441
+ this.pipelineSessions.set(pipeline, this.session(id));
528
442
  }
529
443
  buildMediaRequestHandler() {
530
444
  return (req, res) => {
@@ -612,69 +526,3 @@ function parseQueryParam(query, name) {
612
526
  }
613
527
  return undefined;
614
528
  }
615
- function matchesIq(iq, matcher) {
616
- if (iq.tag !== 'iq')
617
- return false;
618
- if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
619
- return false;
620
- if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
621
- return false;
622
- if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
623
- return false;
624
- if (matcher.childTag !== undefined) {
625
- const children = Array.isArray(iq.content) ? iq.content : null;
626
- if (!children || children.length === 0)
627
- return false;
628
- if (children[0].tag !== matcher.childTag)
629
- return false;
630
- }
631
- return true;
632
- }
633
- function describeMatcher(matcher) {
634
- const parts = [];
635
- if (matcher.id !== undefined)
636
- parts.push(`id=${matcher.id}`);
637
- if (matcher.type !== undefined)
638
- parts.push(`type=${matcher.type}`);
639
- if (matcher.xmlns !== undefined)
640
- parts.push(`xmlns=${matcher.xmlns}`);
641
- if (matcher.childTag !== undefined)
642
- parts.push(`childTag=${matcher.childTag}`);
643
- return parts.length > 0 ? parts.join(', ') : 'any iq';
644
- }
645
- function matchesStanza(node, matcher) {
646
- if (matcher.tag !== undefined && node.tag !== matcher.tag)
647
- return false;
648
- if (matcher.type !== undefined && node.attrs.type !== matcher.type)
649
- return false;
650
- if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
651
- return false;
652
- if (matcher.from !== undefined && node.attrs.from !== matcher.from)
653
- return false;
654
- if (matcher.to !== undefined && node.attrs.to !== matcher.to)
655
- return false;
656
- if (matcher.childTag !== undefined) {
657
- const children = Array.isArray(node.content) ? node.content : null;
658
- if (!children || children.length === 0)
659
- return false;
660
- if (children[0].tag !== matcher.childTag)
661
- return false;
662
- }
663
- return true;
664
- }
665
- function describeStanzaMatcher(matcher) {
666
- const parts = [];
667
- if (matcher.tag !== undefined)
668
- parts.push(`tag=${matcher.tag}`);
669
- if (matcher.type !== undefined)
670
- parts.push(`type=${matcher.type}`);
671
- if (matcher.xmlns !== undefined)
672
- parts.push(`xmlns=${matcher.xmlns}`);
673
- if (matcher.from !== undefined)
674
- parts.push(`from=${matcher.from}`);
675
- if (matcher.to !== undefined)
676
- parts.push(`to=${matcher.to}`);
677
- if (matcher.childTag !== undefined)
678
- parts.push(`childTag=${matcher.childTag}`);
679
- return parts.length > 0 ? parts.join(', ') : 'any stanza';
680
- }
@@ -93,6 +93,29 @@ export function registerDefaultIqHandlers(router, deps) {
93
93
  matcher: { xmlns: 'encrypt', type: 'get', childTag: 'digest' },
94
94
  respond: (iq) => buildIqError(iq, { code: 404, text: 'item-not-found' })
95
95
  });
96
+ // Baileys-family and whatsmeow clients block on this query before
97
+ // reporting the connection as open; zapo-js uses <digest> instead.
98
+ router.register({
99
+ label: 'prekey-count',
100
+ matcher: { xmlns: 'encrypt', type: 'get', childTag: 'count' },
101
+ respond: (iq) => {
102
+ const result = buildIqResult(iq);
103
+ return {
104
+ ...result,
105
+ content: [
106
+ {
107
+ tag: 'count',
108
+ attrs: { value: String(deps.countServerPreKeys()) }
109
+ }
110
+ ]
111
+ };
112
+ }
113
+ });
114
+ router.register({
115
+ label: 'passive-mode',
116
+ matcher: { xmlns: 'passive', type: 'set' },
117
+ respond: (iq) => buildIqResult(iq)
118
+ });
96
119
  router.register({
97
120
  label: 'media-conn',
98
121
  matcher: { xmlns: 'w:m', type: 'set', childTag: 'media_conn' },