@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.
package/dist/bin.js CHANGED
@@ -2,13 +2,17 @@
2
2
  "use strict";
3
3
  /** Standalone CLI for `@zapo-js/fake-server`. */
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
+ const node_readline_1 = require("node:readline");
5
6
  const FakeWaServer_1 = require("./api/FakeWaServer");
7
+ const pair_device_1 = require("./protocol/auth/pair-device");
8
+ const util_1 = require("./transport/util");
6
9
  function parseArgs(argv) {
7
10
  let host = '127.0.0.1';
8
11
  let port = 0;
9
12
  let path = '/ws/chat';
10
13
  const peerJids = [];
11
14
  const groupSpecs = [];
15
+ let pairJid = null;
12
16
  let log = false;
13
17
  let quiet = false;
14
18
  let json = false;
@@ -46,6 +50,9 @@ function parseArgs(argv) {
46
50
  case '--group':
47
51
  groupSpecs.push(next());
48
52
  break;
53
+ case '--pair':
54
+ pairJid = next();
55
+ break;
49
56
  case '--log':
50
57
  log = true;
51
58
  break;
@@ -59,7 +66,7 @@ function parseArgs(argv) {
59
66
  throw new Error(`unknown flag: ${arg}`);
60
67
  }
61
68
  }
62
- return { host, port, path, peerJids, groupSpecs, log, quiet, json, help };
69
+ return { host, port, path, peerJids, groupSpecs, pairJid, log, quiet, json, help };
63
70
  }
64
71
  function printHelp() {
65
72
  process.stdout.write(`@zapo-js/fake-server – standalone fake WhatsApp Web server
@@ -75,6 +82,12 @@ FLAGS
75
82
  --group <spec> Pre-create a fake group; spec format
76
83
  <group-jid>=<peer-jid>,<peer-jid>,...
77
84
  All peers must already be passed via --peer.
85
+ --pair <jid> Drive QR pairing for unregistered clients: the server
86
+ sends pair-device refs, then prompts on stdin for the
87
+ QR payload the client displays
88
+ (ref,noisePubB64,identityPubB64,advSecretB64,platform)
89
+ and answers pair-success assigning <jid> as the device
90
+ jid. Without this flag the CLI is login-only.
78
91
  --log Print every captured inbound stanza
79
92
  --quiet Suppress the startup info banner
80
93
  --json Print connection info as JSON
@@ -105,7 +118,7 @@ async function main() {
105
118
  args = parseArgs(process.argv.slice(2));
106
119
  }
107
120
  catch (error) {
108
- process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ process.stderr.write(`error: ${(0, util_1.toError)(error).message}\n`);
109
122
  process.stderr.write('run with --help to see usage.\n');
110
123
  process.exit(2);
111
124
  }
@@ -127,11 +140,39 @@ async function main() {
127
140
  port: args.port,
128
141
  path: args.path
129
142
  });
143
+ if (args.pairJid !== null) {
144
+ const pairJid = args.pairJid;
145
+ let pairingStarted = false;
146
+ server.onAuthenticatedPipeline(async (pipeline) => {
147
+ if (pairingStarted)
148
+ return;
149
+ if (pipeline.clientPayload?.kind !== 'registration')
150
+ return;
151
+ pairingStarted = true;
152
+ try {
153
+ await server.runPairing(pipeline, { deviceJid: pairJid }, async () => {
154
+ const qr = await readStdinLine('[fake-server] paste the QR payload displayed by the client and press Enter:\n');
155
+ const parsed = (0, pair_device_1.parsePairingQrString)(qr.trim());
156
+ return {
157
+ advSecretKey: parsed.advSecretKey,
158
+ identityPublicKey: parsed.identityPublicKey
159
+ };
160
+ });
161
+ process.stdout.write(`[fake-server] pair-success sent for ${pairJid}; the client will now reconnect and log in\n`);
162
+ }
163
+ catch (error) {
164
+ pairingStarted = false;
165
+ process.stderr.write(`pairing failed: ${(0, util_1.toError)(error).message}\n`);
166
+ }
167
+ });
168
+ }
130
169
  let setupComplete = false;
131
170
  const setupPromise = new Promise((resolve, reject) => {
132
171
  server.onAuthenticatedPipeline(async (pipeline) => {
133
172
  if (setupComplete)
134
173
  return;
174
+ if (pipeline.clientPayload?.kind !== 'login')
175
+ return;
135
176
  setupComplete = true;
136
177
  try {
137
178
  const peers = new Map();
@@ -157,7 +198,7 @@ async function main() {
157
198
  resolve();
158
199
  }
159
200
  catch (error) {
160
- reject(error instanceof Error ? error : new Error(String(error)));
201
+ reject((0, util_1.toError)(error));
161
202
  }
162
203
  });
163
204
  });
@@ -166,12 +207,11 @@ async function main() {
166
207
  process.exitCode = 1;
167
208
  });
168
209
  if (args.log) {
169
- server.onPipeline((pipeline) => {
170
- pipeline.setEvents({
171
- onStanza: (node) => {
172
- process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
173
- }
174
- });
210
+ // onCapturedStanza taps the server's own capture stream; overriding
211
+ // pipeline.setEvents here would replace the server's handlers and
212
+ // silently break --peer setup and stanza capture.
213
+ server.onCapturedStanza((node) => {
214
+ process.stdout.write(`[wire] ${node.tag}${node.attrs.id ? ` id=${node.attrs.id}` : ''}${node.attrs.type ? ` type=${node.attrs.type}` : ''}\n`);
175
215
  });
176
216
  }
177
217
  if (!args.quiet) {
@@ -184,10 +224,11 @@ async function main() {
184
224
  path: args.path,
185
225
  noiseRootCa: {
186
226
  serial: noiseRoot.serial,
187
- publicKeyHex: bytesToHex(noiseRoot.publicKey)
227
+ publicKeyHex: (0, util_1.bytesToHex)(noiseRoot.publicKey)
188
228
  },
189
229
  peers: args.peerJids,
190
- groups: parsedGroups
230
+ groups: parsedGroups,
231
+ pair: args.pairJid
191
232
  }, null, 2) + '\n');
192
233
  }
193
234
  else {
@@ -200,9 +241,10 @@ async function main() {
200
241
  `│ host ${server.host}`,
201
242
  `│ port ${server.port}`,
202
243
  `│ path ${args.path}`,
203
- `│ noise root ca serial=${noiseRoot.serial} pub=${bytesToHex(noiseRoot.publicKey).slice(0, 32)}…`,
244
+ `│ noise root ca serial=${noiseRoot.serial} pub=${(0, util_1.bytesToHex)(noiseRoot.publicKey).slice(0, 32)}…`,
204
245
  `│ peers ${args.peerJids.length === 0 ? '(none)' : args.peerJids.join(', ')}`,
205
246
  `│ groups ${parsedGroups.length === 0 ? '(none)' : parsedGroups.map((g) => g.groupJid).join(', ')}`,
247
+ `│ pair mode ${args.pairJid ?? '(off – login only)'}`,
206
248
  '├─────────────────────────────────────────────────────────────',
207
249
  '│ Wire your WaClient with:',
208
250
  '│ chatSocketUrls: [server.url]',
@@ -226,7 +268,7 @@ async function main() {
226
268
  process.exit(0);
227
269
  }
228
270
  catch (error) {
229
- process.stderr.write(`error during shutdown: ${error instanceof Error ? error.message : String(error)}\n`);
271
+ process.stderr.write(`error during shutdown: ${(0, util_1.toError)(error).message}\n`);
230
272
  process.exit(1);
231
273
  }
232
274
  };
@@ -234,15 +276,21 @@ async function main() {
234
276
  process.on('SIGTERM', () => void shutdown('SIGTERM'));
235
277
  await new Promise(() => undefined);
236
278
  }
237
- function bytesToHex(bytes) {
238
- let out = '';
239
- for (let index = 0; index < bytes.byteLength; index += 1) {
240
- const value = bytes[index];
241
- out += value < 16 ? `0${value.toString(16)}` : value.toString(16);
242
- }
243
- return out;
279
+ function readStdinLine(prompt) {
280
+ process.stdout.write(prompt);
281
+ return new Promise((resolve, reject) => {
282
+ const rl = (0, node_readline_1.createInterface)({ input: process.stdin });
283
+ // resolve before close(): rl.close() emits 'close' synchronously,
284
+ // which would otherwise reject the still-pending promise first.
285
+ rl.once('line', (line) => {
286
+ resolve(line);
287
+ rl.close();
288
+ });
289
+ rl.once('close', () => reject(new Error('stdin closed before a QR payload was provided')));
290
+ });
244
291
  }
245
292
  main().catch((error) => {
246
- process.stderr.write(`fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
293
+ const normalized = (0, util_1.toError)(error);
294
+ process.stderr.write(`fatal: ${normalized.stack ?? normalized.message}\n`);
247
295
  process.exit(1);
248
296
  });
@@ -547,7 +547,13 @@ export class FakePeer {
547
547
  }
548
548
  nextId() {
549
549
  this.nextMessageCounter += 1;
550
- return `${this.jid}-${Date.now()}-${this.nextMessageCounter}`;
550
+ // WA-style uppercase hex, no '@': strict binary decoders tokenize an
551
+ // id containing '@' as a JID and reject the stanza. The registration
552
+ // id keeps ids from colliding across peers created in the same ms.
553
+ const peerPart = this.keyBundle.registrationId.toString(16).padStart(4, '0');
554
+ const timePart = Date.now().toString(16);
555
+ const counterPart = this.nextMessageCounter.toString(16).padStart(4, '0');
556
+ return `3EB0${peerPart}${timePart}${counterPart}`.toUpperCase();
551
557
  }
552
558
  }
553
559
  function findEncForPeer(stanza, peerJid) {
@@ -0,0 +1,287 @@
1
+ /** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
2
+ import { WaFakeIqRouter } from '../protocol/iq/router.js';
3
+ import { AppStateSyncManager } from './AppStateSyncManager.js';
4
+ import { registerDefaultIqHandlers } from './iq-handlers.js';
5
+ import { PreKeyDispenser } from './PreKeyDispenser.js';
6
+ import { ServerRegistries } from './ServerRegistries.js';
7
+ /**
8
+ * One isolated logical device's worth of server state. Every `WaClient`
9
+ * connected to a `FakeWaServer` is bound to exactly one session; peers,
10
+ * groups, privacy, prekeys, app-state, and captured stanzas never leak across
11
+ * sessions. A single-client server uses one default session, which is why the
12
+ * legacy `FakeWaServer` surface delegates straight through to it.
13
+ */
14
+ export class FakeServerSession {
15
+ constructor(id, host, options) {
16
+ this.registries = new ServerRegistries();
17
+ this.preKeyDispenser = new PreKeyDispenser();
18
+ this.appStateSync = new AppStateSyncManager();
19
+ this.iqRouter = new WaFakeIqRouter();
20
+ this.capturedStanzas = [];
21
+ this.pendingIqExpectations = new Set();
22
+ this.pendingStanzaExpectations = new Set();
23
+ this.capturedStanzaListeners = new Set();
24
+ this.inboundStanzaListeners = new Set();
25
+ this.id = id;
26
+ if (options.defaultIqHandlers) {
27
+ registerDefaultIqHandlers(this.iqRouter, this.buildIqHandlerDeps(host));
28
+ }
29
+ }
30
+ registerIqHandler(matcher, respond, label) {
31
+ const handler = { matcher, respond, label };
32
+ return this.iqRouter.register(handler, { priority: 'high' });
33
+ }
34
+ routeIq(iq) {
35
+ return this.iqRouter.route(iq);
36
+ }
37
+ handleCapturedStanza(node) {
38
+ this.capturedStanzas.push(node);
39
+ for (const listener of this.capturedStanzaListeners) {
40
+ try {
41
+ listener(node);
42
+ }
43
+ catch (error) {
44
+ void error;
45
+ }
46
+ }
47
+ for (const listener of this.inboundStanzaListeners) {
48
+ try {
49
+ listener(node);
50
+ }
51
+ catch (error) {
52
+ void error;
53
+ }
54
+ }
55
+ for (const expectation of this.pendingStanzaExpectations) {
56
+ if (matchesStanza(node, expectation.matcher)) {
57
+ expectation.resolve(node);
58
+ break;
59
+ }
60
+ }
61
+ if (node.tag !== 'iq') {
62
+ return;
63
+ }
64
+ for (const expectation of this.pendingIqExpectations) {
65
+ if (matchesIq(node, expectation.matcher)) {
66
+ expectation.resolve(node);
67
+ return;
68
+ }
69
+ }
70
+ }
71
+ subscribeInboundMessages(listener) {
72
+ const wrapped = (node) => {
73
+ if (node.tag !== 'message')
74
+ return;
75
+ listener(node);
76
+ };
77
+ this.inboundStanzaListeners.add(wrapped);
78
+ return () => {
79
+ this.inboundStanzaListeners.delete(wrapped);
80
+ };
81
+ }
82
+ onCapturedStanza(listener) {
83
+ this.capturedStanzaListeners.add(listener);
84
+ return () => {
85
+ this.capturedStanzaListeners.delete(listener);
86
+ };
87
+ }
88
+ capturedStanzaSnapshot() {
89
+ return this.capturedStanzas.slice();
90
+ }
91
+ expectIq(matcher, options = {}) {
92
+ const timeoutMs = options.timeoutMs ?? 2000;
93
+ for (const captured of this.capturedStanzas) {
94
+ if (matchesIq(captured, matcher)) {
95
+ return Promise.resolve(captured);
96
+ }
97
+ }
98
+ return new Promise((resolve, reject) => {
99
+ const expectation = {
100
+ matcher,
101
+ resolve: (iq) => {
102
+ this.pendingIqExpectations.delete(expectation);
103
+ clearTimeout(expectation.timer);
104
+ resolve(iq);
105
+ },
106
+ reject: (error) => {
107
+ this.pendingIqExpectations.delete(expectation);
108
+ clearTimeout(expectation.timer);
109
+ reject(error);
110
+ },
111
+ timer: setTimeout(() => {
112
+ this.pendingIqExpectations.delete(expectation);
113
+ reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
114
+ }, timeoutMs)
115
+ };
116
+ this.pendingIqExpectations.add(expectation);
117
+ });
118
+ }
119
+ expectStanza(matcher, options = {}) {
120
+ const timeoutMs = options.timeoutMs ?? 2000;
121
+ for (const captured of this.capturedStanzas) {
122
+ if (matchesStanza(captured, matcher)) {
123
+ return Promise.resolve(captured);
124
+ }
125
+ }
126
+ return new Promise((resolve, reject) => {
127
+ const expectation = {
128
+ matcher,
129
+ resolve: (node) => {
130
+ this.pendingStanzaExpectations.delete(expectation);
131
+ clearTimeout(expectation.timer);
132
+ resolve(node);
133
+ },
134
+ reject: (error) => {
135
+ this.pendingStanzaExpectations.delete(expectation);
136
+ clearTimeout(expectation.timer);
137
+ reject(error);
138
+ },
139
+ timer: setTimeout(() => {
140
+ this.pendingStanzaExpectations.delete(expectation);
141
+ reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
142
+ }, timeoutMs)
143
+ };
144
+ this.pendingStanzaExpectations.add(expectation);
145
+ });
146
+ }
147
+ buildIqHandlerDeps(host) {
148
+ const reg = this.registries;
149
+ const preKey = this.preKeyDispenser;
150
+ const appState = this.appStateSync;
151
+ return {
152
+ get peerRegistry() {
153
+ return reg.peerRegistry;
154
+ },
155
+ get groupRegistry() {
156
+ return reg.groupRegistry;
157
+ },
158
+ get privacySettings() {
159
+ return reg.privacySettings;
160
+ },
161
+ get blocklistJids() {
162
+ return reg.blocklistJids;
163
+ },
164
+ get profilePicturesByJid() {
165
+ return reg.profilePicturesByJid;
166
+ },
167
+ get businessProfilesByJid() {
168
+ return reg.businessProfilesByJid;
169
+ },
170
+ get abPropsInput() {
171
+ return reg.abPropsInput;
172
+ },
173
+ get issuedPrivacyTokens() {
174
+ return reg.issuedPrivacyTokens;
175
+ },
176
+ get latestStatusText() {
177
+ return reg.latestStatusText;
178
+ },
179
+ setLatestStatusText: (text) => {
180
+ reg.latestStatusText = text;
181
+ },
182
+ lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
183
+ notifyGroupOp: (op) => reg.notifyGroupOp(op),
184
+ mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
185
+ mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
186
+ notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
187
+ handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
188
+ notifyStatusSet: (text) => reg.notifyStatusSet(text),
189
+ notifyLogout: () => reg.notifyLogout(),
190
+ notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
191
+ notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
192
+ notifyPrivacySet: (change) => {
193
+ for (const listener of reg.privacySetListeners) {
194
+ try {
195
+ listener(change);
196
+ }
197
+ catch (error) {
198
+ void error;
199
+ }
200
+ }
201
+ },
202
+ notifyBlocklistChange: (change) => {
203
+ for (const listener of reg.blocklistChangeListeners) {
204
+ try {
205
+ listener(change);
206
+ }
207
+ catch (error) {
208
+ void error;
209
+ }
210
+ }
211
+ },
212
+ capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
213
+ countServerPreKeys: () => preKey.preKeysAvailable(),
214
+ consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
215
+ get appStateCollectionProviders() {
216
+ return appState.appStateCollectionProviders;
217
+ },
218
+ requireMediaHttpsInfo: () => host.requireMediaHttpsInfo()
219
+ };
220
+ }
221
+ }
222
+ export function matchesIq(iq, matcher) {
223
+ if (iq.tag !== 'iq')
224
+ return false;
225
+ if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
226
+ return false;
227
+ if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
228
+ return false;
229
+ if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
230
+ return false;
231
+ if (matcher.childTag !== undefined) {
232
+ const children = Array.isArray(iq.content) ? iq.content : null;
233
+ if (!children || children.length === 0)
234
+ return false;
235
+ if (children[0].tag !== matcher.childTag)
236
+ return false;
237
+ }
238
+ return true;
239
+ }
240
+ function describeMatcher(matcher) {
241
+ const parts = [];
242
+ if (matcher.id !== undefined)
243
+ parts.push(`id=${matcher.id}`);
244
+ if (matcher.type !== undefined)
245
+ parts.push(`type=${matcher.type}`);
246
+ if (matcher.xmlns !== undefined)
247
+ parts.push(`xmlns=${matcher.xmlns}`);
248
+ if (matcher.childTag !== undefined)
249
+ parts.push(`childTag=${matcher.childTag}`);
250
+ return parts.length > 0 ? parts.join(', ') : 'any iq';
251
+ }
252
+ export function matchesStanza(node, matcher) {
253
+ if (matcher.tag !== undefined && node.tag !== matcher.tag)
254
+ return false;
255
+ if (matcher.type !== undefined && node.attrs.type !== matcher.type)
256
+ return false;
257
+ if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
258
+ return false;
259
+ if (matcher.from !== undefined && node.attrs.from !== matcher.from)
260
+ return false;
261
+ if (matcher.to !== undefined && node.attrs.to !== matcher.to)
262
+ return false;
263
+ if (matcher.childTag !== undefined) {
264
+ const children = Array.isArray(node.content) ? node.content : null;
265
+ if (!children || children.length === 0)
266
+ return false;
267
+ if (children[0].tag !== matcher.childTag)
268
+ return false;
269
+ }
270
+ return true;
271
+ }
272
+ function describeStanzaMatcher(matcher) {
273
+ const parts = [];
274
+ if (matcher.tag !== undefined)
275
+ parts.push(`tag=${matcher.tag}`);
276
+ if (matcher.type !== undefined)
277
+ parts.push(`type=${matcher.type}`);
278
+ if (matcher.xmlns !== undefined)
279
+ parts.push(`xmlns=${matcher.xmlns}`);
280
+ if (matcher.from !== undefined)
281
+ parts.push(`from=${matcher.from}`);
282
+ if (matcher.to !== undefined)
283
+ parts.push(`to=${matcher.to}`);
284
+ if (matcher.childTag !== undefined)
285
+ parts.push(`childTag=${matcher.childTag}`);
286
+ return parts.length > 0 ? parts.join(', ') : 'any stanza';
287
+ }