@basmilius/apple-common 0.9.19 → 0.10.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.
Files changed (3) hide show
  1. package/dist/index.d.mts +767 -14
  2. package/dist/index.mjs +1051 -106
  3. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -61,6 +61,11 @@ function v4(options, buf, offset) {
61
61
 
62
62
  //#endregion
63
63
  //#region src/airplayFeatures.ts
64
+ /**
65
+ * AirPlay feature flags as a bitmask of bigint values. Each flag corresponds to a specific
66
+ * capability advertised by an AirPlay device in its mDNS TXT record "features" field.
67
+ * The bit positions match Apple's internal AirPlayFeatureFlags enum.
68
+ */
64
69
  const AirPlayFeatureFlags = {
65
70
  SupportsAirPlayVideoV1: 1n << 0n,
66
71
  SupportsAirPlayPhoto: 1n << 1n,
@@ -107,9 +112,21 @@ const AirPlayFeatureFlags = {
107
112
  SupportsAudioMetadataControl: 1n << 60n,
108
113
  SupportsRFC2198Redundancy: 1n << 61n
109
114
  };
115
+ /** Bitmask in the "sf" (status flags) TXT field indicating password protection. */
110
116
  const PASSWORD_BIT = 128n;
117
+ /** Bitmask in the "sf" TXT field indicating legacy pairing (PIN required). */
111
118
  const LEGACY_PAIRING_BIT = 512n;
119
+ /** Bitmask in the "sf" TXT field indicating a PIN is required. */
112
120
  const PIN_REQUIRED_BIT = 8n;
121
+ /**
122
+ * Parses an AirPlay features string into a single bigint bitmask.
123
+ * Features are advertised as either a single hex value or two comma-separated
124
+ * 32-bit hex values (low,high) which are combined into a 64-bit bitmask.
125
+ *
126
+ * @param features - The features string from the mDNS TXT record.
127
+ * @returns The combined feature flags as a bigint.
128
+ * @throws If the features string has an unexpected format.
129
+ */
113
130
  function parseFeatures(features) {
114
131
  const parts = features.split(",").map((part) => part.trim());
115
132
  if (parts.length === 1) return BigInt(parts[0]);
@@ -119,14 +136,36 @@ function parseFeatures(features) {
119
136
  }
120
137
  throw new Error(`Invalid features format: ${features}`);
121
138
  }
139
+ /**
140
+ * Checks whether a specific feature flag is set in a features bitmask.
141
+ *
142
+ * @param features - The combined feature flags bitmask.
143
+ * @param flag - The specific flag to check for.
144
+ * @returns True if the flag is set.
145
+ */
122
146
  function hasFeatureFlag(features, flag) {
123
147
  return (features & flag) !== 0n;
124
148
  }
149
+ /**
150
+ * Returns the names of all feature flags that are set in the given bitmask.
151
+ * Useful for debugging and diagnostics output.
152
+ *
153
+ * @param features - The combined feature flags bitmask.
154
+ * @returns An array of feature flag names that are active.
155
+ */
125
156
  function describeFlags(features) {
126
157
  const result = [];
127
158
  for (const [name, flag] of Object.entries(AirPlayFeatureFlags)) if (hasFeatureFlag(features, flag)) result.push(name);
128
159
  return result;
129
160
  }
161
+ /**
162
+ * Determines the AirPlay protocol version supported by a device based on its
163
+ * mDNS TXT record properties. AirPlay 2 is indicated by the presence of
164
+ * SupportsUnifiedMediaControl or SupportsCoreUtilsPairingAndEncryption flags.
165
+ *
166
+ * @param txt - The key-value properties from the device's mDNS TXT record.
167
+ * @returns 1 for legacy AirPlay, 2 for AirPlay 2.
168
+ */
130
169
  function getProtocolVersion(txt) {
131
170
  const featuresStr = txt.features ?? txt.ft;
132
171
  if (!featuresStr) return 1;
@@ -135,6 +174,14 @@ function getProtocolVersion(txt) {
135
174
  if (hasFeatureFlag(features, AirPlayFeatureFlags.SupportsCoreUtilsPairingAndEncryption)) return 2;
136
175
  return 1;
137
176
  }
177
+ /**
178
+ * Determines the pairing requirement for an AirPlay device based on its
179
+ * feature flags and status flags. The hierarchy is:
180
+ * HomeKit pairing > PIN required > Transient (system) pairing > Legacy PIN > None.
181
+ *
182
+ * @param txt - The key-value properties from the device's mDNS TXT record.
183
+ * @returns The pairing requirement type.
184
+ */
138
185
  function getPairingRequirement(txt) {
139
186
  const featuresStr = txt.features ?? txt.ft;
140
187
  if (!featuresStr) return "none";
@@ -146,10 +193,24 @@ function getPairingRequirement(txt) {
146
193
  if ((sf & LEGACY_PAIRING_BIT) !== 0n) return "pin";
147
194
  return "none";
148
195
  }
196
+ /**
197
+ * Checks whether the AirPlay device requires a password to connect.
198
+ * Determined by the "pw" TXT field or the password bit in the "sf" status flags.
199
+ *
200
+ * @param txt - The key-value properties from the device's mDNS TXT record.
201
+ * @returns True if a password is required.
202
+ */
149
203
  function isPasswordRequired(txt) {
150
204
  if (txt.pw === "true") return true;
151
205
  return ((txt.sf ? BigInt(txt.sf) : 0n) & PASSWORD_BIT) !== 0n;
152
206
  }
207
+ /**
208
+ * Checks whether the AirPlay device supports remote control (Hangdog protocol).
209
+ * Only devices with the SupportsHangdogRemoteControl flag can receive HID events.
210
+ *
211
+ * @param txt - The key-value properties from the device's mDNS TXT record.
212
+ * @returns True if remote control is supported (typically Apple TV only).
213
+ */
153
214
  function isRemoteControlSupported(txt) {
154
215
  const featuresStr = txt.features ?? txt.ft;
155
216
  if (!featuresStr) return false;
@@ -158,7 +219,20 @@ function isRemoteControlSupported(txt) {
158
219
 
159
220
  //#endregion
160
221
  //#region src/storage.ts
222
+ /**
223
+ * Builds the composite key for credential storage lookup.
224
+ *
225
+ * @param deviceId - The device identifier.
226
+ * @param protocol - The protocol type.
227
+ * @returns A composite key in the format "deviceId:protocol".
228
+ */
161
229
  const credentialKey = (deviceId, protocol) => `${deviceId}:${protocol}`;
230
+ /**
231
+ * Converts credential buffers to base64 strings for JSON serialization.
232
+ *
233
+ * @param credentials - The credentials to serialize.
234
+ * @returns A JSON-safe serialized form.
235
+ */
162
236
  const serializeCredentials = (credentials) => ({
163
237
  accessoryIdentifier: credentials.accessoryIdentifier,
164
238
  accessoryLongTermPublicKey: credentials.accessoryLongTermPublicKey.toString("base64"),
@@ -166,6 +240,12 @@ const serializeCredentials = (credentials) => ({
166
240
  publicKey: credentials.publicKey.toString("base64"),
167
241
  secretKey: credentials.secretKey.toString("base64")
168
242
  });
243
+ /**
244
+ * Restores credential buffers from base64-encoded strings.
245
+ *
246
+ * @param stored - The serialized credentials from storage.
247
+ * @returns Fully hydrated credentials with Buffer fields.
248
+ */
169
249
  const deserializeCredentials = (stored) => ({
170
250
  accessoryIdentifier: stored.accessoryIdentifier,
171
251
  accessoryLongTermPublicKey: Buffer.from(stored.accessoryLongTermPublicKey, "base64"),
@@ -173,68 +253,150 @@ const deserializeCredentials = (stored) => ({
173
253
  publicKey: Buffer.from(stored.publicKey, "base64"),
174
254
  secretKey: Buffer.from(stored.secretKey, "base64")
175
255
  });
256
+ /**
257
+ * Creates an empty storage data structure with version 1 schema.
258
+ *
259
+ * @returns A fresh empty storage data object.
260
+ */
176
261
  const createEmptyData = () => ({
177
262
  version: 1,
178
263
  devices: {},
179
264
  credentials: {}
180
265
  });
266
+ /**
267
+ * Abstract base class for persistent storage of device registrations and
268
+ * pairing credentials. Subclasses implement the actual load/save mechanism.
269
+ *
270
+ * Credentials are stored keyed by "deviceId:protocol" to support per-protocol
271
+ * pairing (a device can have separate AirPlay and Companion Link credentials).
272
+ */
181
273
  var Storage = class {
182
274
  #data = createEmptyData();
275
+ /** The current storage data. */
183
276
  get data() {
184
277
  return this.#data;
185
278
  }
279
+ /**
280
+ * Replaces the internal data with the given storage data.
281
+ * Used by subclasses during load.
282
+ *
283
+ * @param data - The loaded storage data to set.
284
+ */
186
285
  setData(data) {
187
286
  this.#data = data;
188
287
  }
288
+ /**
289
+ * Retrieves a stored device by its identifier.
290
+ *
291
+ * @param identifier - The device identifier.
292
+ * @returns The stored device, or undefined if not found.
293
+ */
189
294
  getDevice(identifier) {
190
295
  return this.#data.devices[identifier];
191
296
  }
297
+ /**
298
+ * Stores or updates a device registration.
299
+ *
300
+ * @param identifier - The device identifier.
301
+ * @param device - The device data to store.
302
+ */
192
303
  setDevice(identifier, device) {
193
304
  this.#data.devices[identifier] = device;
194
305
  }
306
+ /**
307
+ * Removes a device and all its associated credentials from storage.
308
+ *
309
+ * @param identifier - The device identifier to remove.
310
+ */
195
311
  removeDevice(identifier) {
196
312
  delete this.#data.devices[identifier];
197
313
  for (const key of Object.keys(this.#data.credentials)) if (key.startsWith(`${identifier}:`)) delete this.#data.credentials[key];
198
314
  }
315
+ /**
316
+ * Returns all stored devices.
317
+ *
318
+ * @returns An array of all stored device records.
319
+ */
199
320
  listDevices() {
200
321
  return Object.values(this.#data.devices);
201
322
  }
323
+ /**
324
+ * Retrieves pairing credentials for a device and protocol combination.
325
+ *
326
+ * @param deviceId - The device identifier.
327
+ * @param protocol - The protocol type.
328
+ * @returns The deserialized credentials, or undefined if not found.
329
+ */
202
330
  getCredentials(deviceId, protocol) {
203
331
  const stored = this.#data.credentials[credentialKey(deviceId, protocol)];
204
332
  if (!stored) return;
205
333
  return deserializeCredentials(stored);
206
334
  }
335
+ /**
336
+ * Stores pairing credentials for a device and protocol combination.
337
+ *
338
+ * @param deviceId - The device identifier.
339
+ * @param protocol - The protocol type.
340
+ * @param credentials - The credentials to store.
341
+ */
207
342
  setCredentials(deviceId, protocol, credentials) {
208
343
  this.#data.credentials[credentialKey(deviceId, protocol)] = serializeCredentials(credentials);
209
344
  }
345
+ /**
346
+ * Removes pairing credentials for a device and protocol combination.
347
+ *
348
+ * @param deviceId - The device identifier.
349
+ * @param protocol - The protocol type.
350
+ */
210
351
  removeCredentials(deviceId, protocol) {
211
352
  delete this.#data.credentials[credentialKey(deviceId, protocol)];
212
353
  }
213
354
  };
355
+ /**
356
+ * JSON file-based storage implementation. Persists data to a JSON file on disk,
357
+ * defaulting to `~/.config/apple-protocols/storage.json`.
358
+ */
214
359
  var JsonStorage = class extends Storage {
215
360
  #path;
361
+ /**
362
+ * @param path - Optional custom file path. Defaults to `~/.config/apple-protocols/storage.json`.
363
+ */
216
364
  constructor(path) {
217
365
  super();
218
366
  this.#path = path ?? join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".config", "apple-protocols", "storage.json");
219
367
  }
368
+ /** Loads storage data from the JSON file, if it exists. */
220
369
  async load() {
221
370
  if (!existsSync(this.#path)) return;
222
371
  const raw = readFileSync(this.#path, "utf-8");
223
372
  const json = JSON.parse(raw);
224
373
  if (json.version === 1) this.setData(json);
225
374
  }
375
+ /** Saves the current storage data to the JSON file, creating directories as needed. */
226
376
  async save() {
227
377
  mkdirSync(dirname(this.#path), { recursive: true });
228
378
  writeFileSync(this.#path, JSON.stringify(this.data, null, 2), "utf-8");
229
379
  }
230
380
  };
381
+ /**
382
+ * In-memory storage implementation. Data is not persisted between sessions.
383
+ * Useful for testing or environments without filesystem access.
384
+ */
231
385
  var MemoryStorage = class extends Storage {
386
+ /** No-op: memory storage has nothing to load. */
232
387
  async load() {}
388
+ /** No-op: memory storage has nothing to persist. */
233
389
  async save() {}
234
390
  };
235
391
 
236
392
  //#endregion
237
393
  //#region src/cli.ts
394
+ /**
395
+ * Prompts the user for input via stdin and returns their response.
396
+ *
397
+ * @param message - The message to display before the input cursor.
398
+ * @returns The user's input string.
399
+ */
238
400
  async function prompt(message) {
239
401
  const cli = createInterface({
240
402
  input: process.stdin,
@@ -244,100 +406,156 @@ async function prompt(message) {
244
406
  cli.close();
245
407
  return answer;
246
408
  }
409
+ /**
410
+ * Returns a promise that resolves after the specified delay.
411
+ * Commonly used for timing gaps in HID press/release sequences.
412
+ *
413
+ * @param ms - The delay in milliseconds.
414
+ */
247
415
  async function waitFor(ms) {
248
416
  return new Promise((resolve) => setTimeout(resolve, ms));
249
417
  }
250
418
 
251
419
  //#endregion
252
420
  //#region src/const.ts
421
+ /** Default PIN used for transient (non-persistent) AirPlay pairing sessions. */
253
422
  const AIRPLAY_TRANSIENT_PIN = "3939";
423
+ /** Timeout in milliseconds for HTTP requests during setup and control. */
254
424
  const HTTP_TIMEOUT = 6e3;
425
+ /** Timeout in milliseconds for TCP socket connections during initial connect. */
255
426
  const SOCKET_TIMEOUT = 1e4;
427
+ /** mDNS service type for AirPlay device discovery. */
256
428
  const AIRPLAY_SERVICE = "_airplay._tcp.local";
429
+ /** mDNS service type for Companion Link (remote control protocol) device discovery. */
257
430
  const COMPANION_LINK_SERVICE = "_companion-link._tcp.local";
431
+ /** mDNS service type for RAOP (Remote Audio Output Protocol) device discovery. */
258
432
  const RAOP_SERVICE = "_raop._tcp.local";
259
433
 
260
434
  //#endregion
261
435
  //#region src/errors.ts
436
+ /**
437
+ * Base error class for all Apple protocol errors.
438
+ * All domain-specific errors in this library extend from this class,
439
+ * enabling consumers to catch any protocol error with a single handler.
440
+ */
262
441
  var AppleProtocolError = class extends Error {
442
+ /** @param message - Human-readable description of the error. */
263
443
  constructor(message) {
264
444
  super(message);
265
445
  this.name = "AppleProtocolError";
266
446
  }
267
447
  };
448
+ /**
449
+ * Thrown when a TCP connection cannot be established or encounters a fatal error.
450
+ * Parent class for more specific connection errors.
451
+ */
268
452
  var ConnectionError = class extends AppleProtocolError {
453
+ /** @param message - Human-readable description of the connection failure. */
269
454
  constructor(message) {
270
455
  super(message);
271
456
  this.name = "ConnectionError";
272
457
  }
273
458
  };
459
+ /** Thrown when a TCP connection attempt exceeds the configured socket timeout. */
274
460
  var ConnectionTimeoutError = class extends ConnectionError {
461
+ /** @param message - Optional custom message; defaults to a standard timeout message. */
275
462
  constructor(message = "Connection timed out.") {
276
463
  super(message);
277
464
  this.name = "ConnectionTimeoutError";
278
465
  }
279
466
  };
467
+ /** Thrown when a TCP connection is closed unexpectedly by the remote end or the OS. */
280
468
  var ConnectionClosedError = class extends ConnectionError {
469
+ /** @param message - Optional custom message; defaults to a standard closed message. */
281
470
  constructor(message = "Connection closed unexpectedly.") {
282
471
  super(message);
283
472
  this.name = "ConnectionClosedError";
284
473
  }
285
474
  };
475
+ /**
476
+ * Thrown when a pairing operation fails during the HAP pair-setup or pair-verify flow.
477
+ * Parent class for authentication and credentials errors.
478
+ */
286
479
  var PairingError = class extends AppleProtocolError {
480
+ /** @param message - Human-readable description of the pairing failure. */
287
481
  constructor(message) {
288
482
  super(message);
289
483
  this.name = "PairingError";
290
484
  }
291
485
  };
486
+ /**
487
+ * Thrown when the accessory's identity verification fails, such as an invalid
488
+ * Ed25519 signature or mismatched accessory identifier during pair-verify.
489
+ */
292
490
  var AuthenticationError = class extends PairingError {
491
+ /** @param message - Human-readable description of the authentication failure. */
293
492
  constructor(message) {
294
493
  super(message);
295
494
  this.name = "AuthenticationError";
296
495
  }
297
496
  };
497
+ /**
498
+ * Thrown when stored credentials are invalid, missing, or incompatible
499
+ * with the accessory (e.g. after a factory reset).
500
+ */
298
501
  var CredentialsError = class extends PairingError {
502
+ /** @param message - Human-readable description of the credentials issue. */
299
503
  constructor(message) {
300
504
  super(message);
301
505
  this.name = "CredentialsError";
302
506
  }
303
507
  };
508
+ /** Thrown when a protocol command fails to execute on the target device. */
304
509
  var CommandError = class extends AppleProtocolError {
510
+ /** @param message - Human-readable description of the command failure. */
305
511
  constructor(message) {
306
512
  super(message);
307
513
  this.name = "CommandError";
308
514
  }
309
515
  };
516
+ /** Thrown when a protocol setup step fails (e.g. RTSP SETUP, AirPlay stream setup). */
310
517
  var SetupError = class extends AppleProtocolError {
518
+ /** @param message - Human-readable description of the setup failure. */
311
519
  constructor(message) {
312
520
  super(message);
313
521
  this.name = "SetupError";
314
522
  }
315
523
  };
524
+ /** Thrown when mDNS device discovery fails or a device cannot be found after retries. */
316
525
  var DiscoveryError = class extends AppleProtocolError {
526
+ /** @param message - Human-readable description of the discovery failure. */
317
527
  constructor(message) {
318
528
  super(message);
319
529
  this.name = "DiscoveryError";
320
530
  }
321
531
  };
532
+ /** Thrown when an encryption or decryption operation fails (e.g. ChaCha20 auth tag mismatch). */
322
533
  var EncryptionError = class extends AppleProtocolError {
534
+ /** @param message - Human-readable description of the encryption failure. */
323
535
  constructor(message) {
324
536
  super(message);
325
537
  this.name = "EncryptionError";
326
538
  }
327
539
  };
540
+ /** Thrown when a response from the accessory is malformed or has an unexpected format. */
328
541
  var InvalidResponseError = class extends AppleProtocolError {
542
+ /** @param message - Human-readable description of the invalid response. */
329
543
  constructor(message) {
330
544
  super(message);
331
545
  this.name = "InvalidResponseError";
332
546
  }
333
547
  };
548
+ /** Thrown when a protocol operation exceeds its expected time limit. */
334
549
  var TimeoutError = class extends AppleProtocolError {
550
+ /** @param message - Human-readable description of the timeout. */
335
551
  constructor(message) {
336
552
  super(message);
337
553
  this.name = "TimeoutError";
338
554
  }
339
555
  };
556
+ /** Thrown when a media playback operation fails on the target device. */
340
557
  var PlaybackError = class extends AppleProtocolError {
558
+ /** @param message - Human-readable description of the playback failure. */
341
559
  constructor(message) {
342
560
  super(message);
343
561
  this.name = "PlaybackError";
@@ -346,10 +564,15 @@ var PlaybackError = class extends AppleProtocolError {
346
564
 
347
565
  //#endregion
348
566
  //#region src/mdns.ts
567
+ /** IPv4 multicast address for mDNS (RFC 6762). */
349
568
  const MDNS_ADDRESS = "224.0.0.251";
569
+ /** Standard mDNS port. */
350
570
  const MDNS_PORT = 5353;
571
+ /** Query ID used in outgoing mDNS queries. */
351
572
  const QUERY_ID = 13823;
573
+ /** Maximum number of service queries to pack into a single DNS message. */
352
574
  const SERVICES_PER_MSG = 3;
575
+ /** DNS record type codes used in mDNS queries and responses. */
353
576
  const QueryType = {
354
577
  A: 1,
355
578
  PTR: 12,
@@ -358,6 +581,12 @@ const QueryType = {
358
581
  SRV: 33,
359
582
  ANY: 255
360
583
  };
584
+ /**
585
+ * Encodes a domain name into DNS wire format (length-prefixed labels terminated by 0x00).
586
+ *
587
+ * @param name - The domain name to encode (e.g. "_airplay._tcp.local").
588
+ * @returns A buffer containing the encoded QNAME.
589
+ */
361
590
  const encodeQName = (name) => {
362
591
  const parts = [];
363
592
  const labels = splitServiceName(name);
@@ -369,11 +598,25 @@ const encodeQName = (name) => {
369
598
  parts.push(Buffer.from([0]));
370
599
  return Buffer.concat(parts);
371
600
  };
601
+ /**
602
+ * Splits a service name into DNS labels, handling instance names that may contain dots.
603
+ * For example, "Living Room._airplay._tcp.local" splits into
604
+ * ["Living Room", "_airplay", "_tcp", "local"].
605
+ *
606
+ * @param name - The full service name.
607
+ * @returns An array of DNS labels.
608
+ */
372
609
  const splitServiceName = (name) => {
373
610
  const match = name.match(/\._[a-z]+\._(?:tcp|udp)\.local$/);
374
611
  if (match) return [name.substring(0, match.index), ...match[0].substring(1).split(".")];
375
612
  return name.split(".");
376
613
  };
614
+ /**
615
+ * Encodes a DNS header into a 12-byte buffer.
616
+ *
617
+ * @param header - The header fields to encode.
618
+ * @returns A 12-byte buffer containing the encoded DNS header.
619
+ */
377
620
  const encodeDnsHeader = (header) => {
378
621
  const buf = Buffer.allocUnsafe(12);
379
622
  buf.writeUInt16BE(header.id, 0);
@@ -384,6 +627,14 @@ const encodeDnsHeader = (header) => {
384
627
  buf.writeUInt16BE(header.arcount, 10);
385
628
  return buf;
386
629
  };
630
+ /**
631
+ * Encodes a single DNS question section entry.
632
+ *
633
+ * @param name - The domain name to query.
634
+ * @param qtype - The query type (e.g. PTR, SRV).
635
+ * @param unicastResponse - Whether to request a unicast response (QU bit).
636
+ * @returns A buffer containing the encoded question.
637
+ */
387
638
  const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
388
639
  const qname = encodeQName(name);
389
640
  const suffix = Buffer.allocUnsafe(4);
@@ -391,6 +642,15 @@ const encodeDnsQuestion = (name, qtype, unicastResponse = false) => {
391
642
  suffix.writeUInt16BE(unicastResponse ? 32769 : 1, 2);
392
643
  return Buffer.concat([qname, suffix]);
393
644
  };
645
+ /**
646
+ * Creates one or more DNS query packets for the given service types.
647
+ * Services are batched into groups of {@link SERVICES_PER_MSG} per packet.
648
+ *
649
+ * @param services - The mDNS service types to query (e.g. '_airplay._tcp.local').
650
+ * @param qtype - The DNS query type. Defaults to PTR.
651
+ * @param unicastResponse - Whether to set the QU (unicast response) bit.
652
+ * @returns An array of encoded DNS query packets.
653
+ */
394
654
  function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = false) {
395
655
  const packets = [];
396
656
  for (let i = 0; i < services.length; i += SERVICES_PER_MSG) {
@@ -408,7 +668,15 @@ function createQueryPackets(services, qtype = QueryType.PTR, unicastResponse = f
408
668
  }
409
669
  return packets;
410
670
  }
671
+ /** Maximum number of pointer jumps allowed during name decompression to prevent infinite loops. */
411
672
  const MAX_POINTER_JUMPS = 128;
673
+ /**
674
+ * Decodes a DNS QNAME from a buffer, handling name compression pointers (RFC 1035 section 4.1.4).
675
+ *
676
+ * @param buf - The DNS packet buffer.
677
+ * @param offset - The byte offset where the QNAME starts.
678
+ * @returns A tuple of [decoded name string, next offset after the QNAME].
679
+ */
412
680
  const decodeQName = (buf, offset) => {
413
681
  const labels = [];
414
682
  let currentOffset = offset;
@@ -430,12 +698,19 @@ const decodeQName = (buf, offset) => {
430
698
  continue;
431
699
  }
432
700
  currentOffset++;
701
+ if (currentOffset + length > buf.byteLength) break;
433
702
  labels.push(buf.toString("utf-8", currentOffset, currentOffset + length));
434
703
  currentOffset += length;
435
704
  if (!jumped) returnOffset = currentOffset;
436
705
  }
437
706
  return [labels.join("."), returnOffset];
438
707
  };
708
+ /**
709
+ * Decodes a 12-byte DNS header from a buffer.
710
+ *
711
+ * @param buf - The DNS packet buffer (must be at least 12 bytes).
712
+ * @returns The parsed DNS header.
713
+ */
439
714
  const decodeDnsHeader = (buf) => ({
440
715
  id: buf.readUInt16BE(0),
441
716
  flags: buf.readUInt16BE(2),
@@ -444,6 +719,13 @@ const decodeDnsHeader = (buf) => ({
444
719
  nscount: buf.readUInt16BE(8),
445
720
  arcount: buf.readUInt16BE(10)
446
721
  });
722
+ /**
723
+ * Decodes a DNS question section entry.
724
+ *
725
+ * @param buf - The DNS packet buffer.
726
+ * @param offset - The byte offset where the question starts.
727
+ * @returns A tuple of [parsed question, next offset].
728
+ */
447
729
  const decodeQuestion = (buf, offset) => {
448
730
  const [qname, newOffset] = decodeQName(buf, offset);
449
731
  return [{
@@ -452,6 +734,15 @@ const decodeQuestion = (buf, offset) => {
452
734
  qclass: buf.readUInt16BE(newOffset + 2)
453
735
  }, newOffset + 4];
454
736
  };
737
+ /**
738
+ * Decodes a DNS TXT record into key-value pairs. Each entry is a length-prefixed
739
+ * UTF-8 string in "key=value" format.
740
+ *
741
+ * @param buf - The DNS packet buffer.
742
+ * @param offset - The byte offset where the TXT rdata starts.
743
+ * @param length - The total length of the TXT rdata.
744
+ * @returns A record of key-value pairs.
745
+ */
455
746
  const decodeTxtRecord = (buf, offset, length) => {
456
747
  const properties = {};
457
748
  let pos = offset;
@@ -468,6 +759,13 @@ const decodeTxtRecord = (buf, offset, length) => {
468
759
  }
469
760
  return properties;
470
761
  };
762
+ /**
763
+ * Decodes a DNS SRV record containing service priority, weight, port, and target hostname.
764
+ *
765
+ * @param buf - The DNS packet buffer.
766
+ * @param offset - The byte offset where the SRV rdata starts.
767
+ * @returns The parsed SRV record.
768
+ */
471
769
  const decodeSrvRecord = (buf, offset) => {
472
770
  const priority = buf.readUInt16BE(offset);
473
771
  const weight = buf.readUInt16BE(offset + 2);
@@ -480,6 +778,14 @@ const decodeSrvRecord = (buf, offset) => {
480
778
  target
481
779
  };
482
780
  };
781
+ /**
782
+ * Decodes a DNS resource record (answer, authority, or additional section).
783
+ * Dispatches to type-specific decoders for A, AAAA, PTR, SRV, and TXT records.
784
+ *
785
+ * @param buf - The DNS packet buffer.
786
+ * @param offset - The byte offset where the resource record starts.
787
+ * @returns A tuple of [parsed resource record, next offset].
788
+ */
483
789
  const decodeResource = (buf, offset) => {
484
790
  const [qname, nameEnd] = decodeQName(buf, offset);
485
791
  const qtype = buf.readUInt16BE(nameEnd);
@@ -519,6 +825,13 @@ const decodeResource = (buf, offset) => {
519
825
  rdata
520
826
  }, rdOffset + rdLength];
521
827
  };
828
+ /**
829
+ * Decodes a complete DNS response packet into its header, answer records,
830
+ * and additional resource records. Skips question and authority sections.
831
+ *
832
+ * @param buf - The raw DNS response packet.
833
+ * @returns The parsed header, answer records, and additional resource records.
834
+ */
522
835
  const decodeDnsResponse = (buf) => {
523
836
  const header = decodeDnsHeader(buf);
524
837
  let offset = 12;
@@ -548,11 +861,27 @@ const decodeDnsResponse = (buf) => {
548
861
  resources
549
862
  };
550
863
  };
864
+ /**
865
+ * Aggregates DNS records from multiple mDNS responses and resolves them
866
+ * into complete service descriptions. Correlates PTR, SRV, TXT, and A records
867
+ * to produce fully-resolved {@link MdnsService} instances.
868
+ */
551
869
  var ServiceCollector = class {
870
+ /** Maps service types to sets of instance QNAMEs (from PTR records). */
552
871
  #ptrMap = /* @__PURE__ */ new Map();
872
+ /** Maps instance QNAMEs to their SRV records (port and target host). */
553
873
  #srvMap = /* @__PURE__ */ new Map();
874
+ /** Maps instance QNAMEs to their TXT record properties. */
554
875
  #txtMap = /* @__PURE__ */ new Map();
876
+ /** Maps hostnames to IPv4 addresses (from A records). */
555
877
  #addressMap = /* @__PURE__ */ new Map();
878
+ /**
879
+ * Ingests DNS answer and additional resource records, categorizing them
880
+ * by type into the internal maps.
881
+ *
882
+ * @param answers - Answer section records.
883
+ * @param resources - Additional section records.
884
+ */
556
885
  addRecords(answers, resources) {
557
886
  for (const record of [...answers, ...resources]) switch (record.qtype) {
558
887
  case QueryType.PTR: {
@@ -572,6 +901,10 @@ var ServiceCollector = class {
572
901
  break;
573
902
  }
574
903
  }
904
+ /**
905
+ * Resolves all collected records into complete service descriptions.
906
+ * Only returns services where all required data (PTR, SRV with non-zero port, A record) is present.
907
+ */
575
908
  get services() {
576
909
  const results = [];
577
910
  for (const [serviceType, instanceNames] of this.#ptrMap) for (const instanceQName of instanceNames) {
@@ -593,12 +926,20 @@ var ServiceCollector = class {
593
926
  return results;
594
927
  }
595
928
  };
929
+ /** Well-known ports used to wake sleeping Apple devices via TCP SYN. */
596
930
  const WAKE_PORTS$1 = [
597
931
  7e3,
598
932
  3689,
599
933
  49152,
600
934
  32498
601
935
  ];
936
+ /**
937
+ * Sends TCP connection attempts ("knocks") to well-known Apple service ports
938
+ * to wake a sleeping device before querying it.
939
+ *
940
+ * @param address - The IP address of the device to wake.
941
+ * @returns A promise that resolves when all knock attempts complete (success or failure).
942
+ */
602
943
  const knock = (address) => {
603
944
  const promises = WAKE_PORTS$1.map((port) => new Promise((resolve) => {
604
945
  const socket = createConnection({
@@ -621,6 +962,16 @@ const knock = (address) => {
621
962
  }));
622
963
  return Promise.all(promises).then(() => {});
623
964
  };
965
+ /**
966
+ * Performs unicast DNS-SD queries to specific hosts. First wakes the devices
967
+ * via TCP knocking, then repeatedly sends DNS queries via UDP to port 5353 on
968
+ * each host, collecting responses for the specified duration.
969
+ *
970
+ * @param hosts - IP addresses of the specific devices to query.
971
+ * @param services - mDNS service types to discover.
972
+ * @param timeout - Discovery duration in seconds. Defaults to 4.
973
+ * @returns An array of resolved mDNS services.
974
+ */
624
975
  function unicast(hosts, services, timeout = 4) {
625
976
  return new Promise((resolve) => {
626
977
  const queries = createQueryPackets(services);
@@ -658,6 +1009,19 @@ function unicast(hosts, services, timeout = 4) {
658
1009
  });
659
1010
  });
660
1011
  }
1012
+ /**
1013
+ * Performs multicast DNS-SD discovery on the local network. Creates UDP sockets
1014
+ * on all network interfaces, joins the mDNS multicast group (224.0.0.251),
1015
+ * and sends periodic queries for the specified duration.
1016
+ *
1017
+ * Creates two types of sockets:
1018
+ * - One on 0.0.0.0:5353 to receive multicast responses (may fail on some platforms)
1019
+ * - One per local network interface on a random port with multicast membership
1020
+ *
1021
+ * @param services - mDNS service types to discover.
1022
+ * @param timeout - Discovery duration in seconds. Defaults to 4.
1023
+ * @returns An array of resolved mDNS services found on the network.
1024
+ */
661
1025
  function multicast(services, timeout = 4) {
662
1026
  return new Promise((resolve) => {
663
1027
  const collector = new ServiceCollector();
@@ -734,15 +1098,195 @@ function multicast(services, timeout = 4) {
734
1098
  });
735
1099
  }
736
1100
 
1101
+ //#endregion
1102
+ //#region src/reporter.ts
1103
+ /**
1104
+ * Scoped logger instance tagged with a device or component identifier.
1105
+ * All log output is gated by the global {@link reporter} singleton — messages
1106
+ * are only printed when the corresponding debug group is enabled.
1107
+ */
1108
+ var Logger = class {
1109
+ /** The identifier this logger is scoped to. */
1110
+ get id() {
1111
+ return this.#id;
1112
+ }
1113
+ /** ANSI-colored label prefix used in log output. */
1114
+ get label() {
1115
+ return this.#label;
1116
+ }
1117
+ #id;
1118
+ #label;
1119
+ /**
1120
+ * @param id - Identifier used as a prefix in log output (typically a device ID or component name).
1121
+ */
1122
+ constructor(id) {
1123
+ this.#id = id;
1124
+ this.#label = `\u001b[36m[${id}]\u001b[39m`;
1125
+ }
1126
+ /**
1127
+ * Logs a debug-level message (cyan). Only printed when the 'debug' group is enabled.
1128
+ *
1129
+ * @param data - Values to log.
1130
+ */
1131
+ debug(...data) {
1132
+ debug(this.#label, ...data);
1133
+ }
1134
+ /**
1135
+ * Logs an error-level message (red). Only printed when the 'error' group is enabled.
1136
+ *
1137
+ * @param data - Values to log.
1138
+ */
1139
+ error(...data) {
1140
+ error(this.#label, ...data);
1141
+ }
1142
+ /**
1143
+ * Logs an info-level message (green). Only printed when the 'info' group is enabled.
1144
+ *
1145
+ * @param data - Values to log.
1146
+ */
1147
+ info(...data) {
1148
+ info(this.#label, ...data);
1149
+ }
1150
+ /**
1151
+ * Logs a network-level message (yellow). Only printed when the 'net' group is enabled.
1152
+ *
1153
+ * @param data - Values to log.
1154
+ */
1155
+ net(...data) {
1156
+ net(this.#label, ...data);
1157
+ }
1158
+ /**
1159
+ * Logs a raw data message (blue). Only printed when the 'raw' group is enabled.
1160
+ * Typically used for hex dumps and binary protocol data.
1161
+ *
1162
+ * @param data - Values to log.
1163
+ */
1164
+ raw(...data) {
1165
+ raw(this.#label, ...data);
1166
+ }
1167
+ /**
1168
+ * Logs a warning-level message (yellow). Only printed when the 'warn' group is enabled.
1169
+ *
1170
+ * @param data - Values to log.
1171
+ */
1172
+ warn(...data) {
1173
+ warn(this.#label, ...data);
1174
+ }
1175
+ };
1176
+ /**
1177
+ * Global log output controller that manages which debug groups are active.
1178
+ * All {@link Logger} instances check the singleton {@link reporter} before printing.
1179
+ */
1180
+ var Reporter = class {
1181
+ #enabled = [];
1182
+ /** Enables all debug groups (except 'raw' which is very verbose). */
1183
+ all() {
1184
+ this.#enabled = [
1185
+ "debug",
1186
+ "error",
1187
+ "info",
1188
+ "net",
1189
+ "warn"
1190
+ ];
1191
+ }
1192
+ /** Disables all debug groups, silencing all log output. */
1193
+ none() {
1194
+ this.#enabled = [];
1195
+ }
1196
+ /**
1197
+ * Disables a specific debug group.
1198
+ *
1199
+ * @param group - The debug group to disable.
1200
+ */
1201
+ disable(group) {
1202
+ if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
1203
+ }
1204
+ /**
1205
+ * Enables a specific debug group.
1206
+ *
1207
+ * @param group - The debug group to enable.
1208
+ */
1209
+ enable(group) {
1210
+ if (!this.#enabled.includes(group)) this.#enabled.push(group);
1211
+ }
1212
+ /**
1213
+ * Checks whether a specific debug group is currently enabled.
1214
+ *
1215
+ * @param group - The debug group to check.
1216
+ * @returns True if the group is enabled.
1217
+ */
1218
+ isEnabled(group) {
1219
+ return this.#enabled.includes(group);
1220
+ }
1221
+ };
1222
+ /**
1223
+ * Logs a debug-level message if the 'debug' group is enabled.
1224
+ *
1225
+ * @param data - Values to log.
1226
+ */
1227
+ function debug(...data) {
1228
+ reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
1229
+ }
1230
+ /**
1231
+ * Logs an error-level message if the 'error' group is enabled.
1232
+ *
1233
+ * @param data - Values to log.
1234
+ */
1235
+ function error(...data) {
1236
+ reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
1237
+ }
1238
+ /**
1239
+ * Logs an info-level message if the 'info' group is enabled.
1240
+ *
1241
+ * @param data - Values to log.
1242
+ */
1243
+ function info(...data) {
1244
+ reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
1245
+ }
1246
+ /**
1247
+ * Logs a network-level message if the 'net' group is enabled.
1248
+ *
1249
+ * @param data - Values to log.
1250
+ */
1251
+ function net(...data) {
1252
+ reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
1253
+ }
1254
+ /**
1255
+ * Logs a raw data message if the 'raw' group is enabled.
1256
+ *
1257
+ * @param data - Values to log.
1258
+ */
1259
+ function raw(...data) {
1260
+ reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
1261
+ }
1262
+ /**
1263
+ * Logs a warning-level message if the 'warn' group is enabled.
1264
+ *
1265
+ * @param data - Values to log.
1266
+ */
1267
+ function warn(...data) {
1268
+ reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
1269
+ }
1270
+ /** Global reporter singleton controlling which debug groups produce output. */
1271
+ const reporter = new Reporter();
1272
+
737
1273
  //#endregion
738
1274
  //#region src/discovery.ts
1275
+ /** Cache time-to-live in milliseconds. */
739
1276
  const CACHE_TTL = 3e4;
1277
+ /** Ports used for wake-on-network "knocking" to wake sleeping Apple devices. */
740
1278
  const WAKE_PORTS = [
741
1279
  7e3,
742
1280
  3689,
743
1281
  49152,
744
1282
  32498
745
1283
  ];
1284
+ /**
1285
+ * Converts a raw mDNS service record into a {@link DiscoveryResult}.
1286
+ *
1287
+ * @param service - The mDNS service record.
1288
+ * @returns A normalized discovery result.
1289
+ */
746
1290
  const toDiscoveryResult = (service) => {
747
1291
  const txt = service.properties;
748
1292
  const featuresStr = txt.features ?? txt.ft;
@@ -765,6 +1309,12 @@ const toDiscoveryResult = (service) => {
765
1309
  packet: null
766
1310
  };
767
1311
  };
1312
+ /**
1313
+ * Safely parses a features string, returning undefined on failure.
1314
+ *
1315
+ * @param features - The features string to parse.
1316
+ * @returns The parsed feature bitmask, or undefined if parsing fails.
1317
+ */
768
1318
  const tryParseFeatures = (features) => {
769
1319
  try {
770
1320
  return parseFeatures(features);
@@ -772,12 +1322,32 @@ const tryParseFeatures = (features) => {
772
1322
  return;
773
1323
  }
774
1324
  };
1325
+ const logger = new Logger("discovery");
1326
+ /**
1327
+ * mDNS service discovery for Apple devices on the local network.
1328
+ *
1329
+ * Supports discovering AirPlay, Companion Link, and RAOP services via multicast DNS.
1330
+ * Results are cached for 30 seconds to avoid excessive network traffic.
1331
+ * Use the static factory methods {@link Discovery.airplay}, {@link Discovery.companionLink},
1332
+ * and {@link Discovery.raop} for convenience.
1333
+ */
775
1334
  var Discovery = class Discovery {
1335
+ /** Shared cache of discovery results, keyed by service type. */
776
1336
  static #cache = /* @__PURE__ */ new Map();
777
1337
  #service;
1338
+ /**
1339
+ * @param service - The mDNS service type to discover (e.g. '_airplay._tcp.local').
1340
+ */
778
1341
  constructor(service) {
779
1342
  this.#service = service;
780
1343
  }
1344
+ /**
1345
+ * Discovers devices advertising this service type via mDNS multicast.
1346
+ * Returns cached results if available and not expired.
1347
+ *
1348
+ * @param useCache - Whether to use cached results. Defaults to true.
1349
+ * @returns An array of discovered devices.
1350
+ */
781
1351
  async find(useCache = true) {
782
1352
  if (useCache) {
783
1353
  const cached = Discovery.#cache.get(this.#service);
@@ -792,22 +1362,37 @@ var Discovery = class Discovery {
792
1362
  });
793
1363
  return mapped;
794
1364
  }
1365
+ /**
1366
+ * Repeatedly searches for a specific device by ID until found or retries are exhausted.
1367
+ * Does not use the cache to ensure fresh results on each attempt.
1368
+ *
1369
+ * @param id - The device ID to search for.
1370
+ * @param tries - Maximum number of discovery attempts. Defaults to 10.
1371
+ * @param timeout - Delay in milliseconds between attempts. Defaults to 1000.
1372
+ * @returns The discovered device.
1373
+ * @throws {DiscoveryError} If the device is not found after all attempts.
1374
+ */
795
1375
  async findUntil(id, tries = 10, timeout = 1e3) {
796
1376
  while (tries > 0) {
797
1377
  const devices = await this.find(false);
798
1378
  const device = devices.find((device) => device.id === id);
799
1379
  if (device) return device;
800
- console.log();
801
- console.log(`Device not found, retrying in ${timeout}ms...`);
802
- console.log(devices.map((d) => ` ● ${d.id} (${d.fqdn})`).join("\n"));
1380
+ logger.debug(`Device '${id}' not found, retrying in ${timeout}ms...`, devices.map((d) => d.id));
803
1381
  tries--;
804
1382
  await waitFor(timeout);
805
1383
  }
806
1384
  throw new DiscoveryError(`Device '${id}' not found after several tries, aborting.`);
807
1385
  }
1386
+ /** Clears all cached discovery results across all service types. */
808
1387
  static clearCache() {
809
1388
  Discovery.#cache.clear();
810
1389
  }
1390
+ /**
1391
+ * Attempts to wake a sleeping Apple device by knocking on well-known ports.
1392
+ * Sends TCP connection attempts to ports 7000, 3689, 49152, and 32498.
1393
+ *
1394
+ * @param address - The IP address of the device to wake.
1395
+ */
811
1396
  static async wake(address) {
812
1397
  const promises = WAKE_PORTS.map((port) => new Promise((resolve) => {
813
1398
  const socket = createConnection({
@@ -830,6 +1415,13 @@ var Discovery = class Discovery {
830
1415
  }));
831
1416
  await Promise.all(promises);
832
1417
  }
1418
+ /**
1419
+ * Discovers all Apple devices on the network across all supported service types
1420
+ * (AirPlay, Companion Link, RAOP) and merges them by device ID.
1421
+ *
1422
+ * @returns An array of combined results, each representing a single physical device
1423
+ * with its available service endpoints.
1424
+ */
833
1425
  static async discoverAll() {
834
1426
  const allServices = await multicast([
835
1427
  AIRPLAY_SERVICE,
@@ -855,12 +1447,27 @@ var Discovery = class Discovery {
855
1447
  }
856
1448
  return [...devices.values()];
857
1449
  }
1450
+ /**
1451
+ * Creates a Discovery instance for AirPlay services.
1452
+ *
1453
+ * @returns A new Discovery instance targeting `_airplay._tcp.local`.
1454
+ */
858
1455
  static airplay() {
859
1456
  return new Discovery(AIRPLAY_SERVICE);
860
1457
  }
1458
+ /**
1459
+ * Creates a Discovery instance for Companion Link services.
1460
+ *
1461
+ * @returns A new Discovery instance targeting `_companion-link._tcp.local`.
1462
+ */
861
1463
  static companionLink() {
862
1464
  return new Discovery(COMPANION_LINK_SERVICE);
863
1465
  }
1466
+ /**
1467
+ * Creates a Discovery instance for RAOP services.
1468
+ *
1469
+ * @returns A new Discovery instance targeting `_raop._tcp.local`.
1470
+ */
864
1471
  static raop() {
865
1472
  return new Discovery(RAOP_SERVICE);
866
1473
  }
@@ -868,23 +1475,42 @@ var Discovery = class Discovery {
868
1475
 
869
1476
  //#endregion
870
1477
  //#region src/connection.ts
1478
+ /** No-op promise handler used as a fallback when no connect promise is active. */
871
1479
  const NOOP_PROMISE_HANDLER = {
872
1480
  resolve: () => {},
873
1481
  reject: (_) => {}
874
1482
  };
1483
+ /**
1484
+ * TCP socket connection wrapper with built-in retry logic and typed events.
1485
+ *
1486
+ * Manages a single TCP socket to an Apple device, providing automatic reconnection
1487
+ * on failure (configurable attempts and interval), keep-alive, and no-delay settings.
1488
+ * Subclasses can extend the event map with protocol-specific events.
1489
+ *
1490
+ * Default retry behavior: 3 attempts with 3-second intervals between retries.
1491
+ */
875
1492
  var Connection = class extends EventEmitter {
1493
+ /** The remote IP address this connection targets. */
876
1494
  get address() {
877
1495
  return this.#address;
878
1496
  }
1497
+ /** The shared context carrying device identity and logger. */
879
1498
  get context() {
880
1499
  return this.#context;
881
1500
  }
1501
+ /** The remote port this connection targets. */
882
1502
  get port() {
883
1503
  return this.#port;
884
1504
  }
1505
+ /** Whether the connection is currently established and open. */
885
1506
  get isConnected() {
886
1507
  return this.#state === "connected";
887
1508
  }
1509
+ /** The local IP address of the socket, or '0.0.0.0' if not connected. */
1510
+ get localAddress() {
1511
+ return this.#socket?.localAddress ?? "0.0.0.0";
1512
+ }
1513
+ /** The current connection state, derived from both internal state and socket readyState. */
888
1514
  get state() {
889
1515
  if (this.#state === "closing" || this.#state === "failed") return this.#state;
890
1516
  if (!this.#socket) return "disconnected";
@@ -897,7 +1523,6 @@ var Connection = class extends EventEmitter {
897
1523
  #address;
898
1524
  #port;
899
1525
  #context;
900
- #emitInternal = (event, ...args) => this.emit(event, ...args);
901
1526
  #debug = false;
902
1527
  #retryAttempt = 0;
903
1528
  #retryAttempts = 3;
@@ -907,13 +1532,30 @@ var Connection = class extends EventEmitter {
907
1532
  #socket;
908
1533
  #state;
909
1534
  #connectPromise;
1535
+ /**
1536
+ * @param context - Shared context with device identity and logger.
1537
+ * @param address - The remote IP address to connect to.
1538
+ * @param port - The remote port to connect to.
1539
+ */
910
1540
  constructor(context, address, port) {
911
1541
  super();
912
1542
  this.#address = address;
913
1543
  this.#port = port;
914
1544
  this.#context = context;
915
1545
  this.#state = "disconnected";
1546
+ this.onClose = this.onClose.bind(this);
1547
+ this.onConnect = this.onConnect.bind(this);
1548
+ this.onData = this.onData.bind(this);
1549
+ this.onEnd = this.onEnd.bind(this);
1550
+ this.onError = this.onError.bind(this);
1551
+ this.onTimeout = this.onTimeout.bind(this);
916
1552
  }
1553
+ /**
1554
+ * Establishes a TCP connection to the remote address. If already connected,
1555
+ * returns immediately. Enables retry logic for the duration of this connection.
1556
+ *
1557
+ * @throws {ConnectionError} If already connecting or all retry attempts are exhausted.
1558
+ */
917
1559
  async connect() {
918
1560
  if (this.#state === "connected") return;
919
1561
  if (this.#state === "connecting") throw new ConnectionError("A connection is already being established.");
@@ -921,9 +1563,14 @@ var Connection = class extends EventEmitter {
921
1563
  this.#retryAttempt = 0;
922
1564
  return this.#attemptConnect();
923
1565
  }
1566
+ /** Immediately destroys the underlying socket without graceful shutdown. */
924
1567
  destroy() {
925
1568
  this.#socket?.destroy();
926
1569
  }
1570
+ /**
1571
+ * Gracefully disconnects by ending the socket and waiting for the 'close' event.
1572
+ * Disables retry logic so the connection does not automatically reconnect.
1573
+ */
927
1574
  async disconnect() {
928
1575
  if (this.#retryTimeout) {
929
1576
  clearTimeout(this.#retryTimeout);
@@ -931,6 +1578,10 @@ var Connection = class extends EventEmitter {
931
1578
  }
932
1579
  this.#retryEnabled = false;
933
1580
  if (!this.#socket || this.#state === "disconnected") return;
1581
+ if (this.#socket.destroyed || this.#socket.readyState === "closed") {
1582
+ this.#cleanup();
1583
+ return;
1584
+ }
934
1585
  return new Promise((resolve) => {
935
1586
  this.#state = "closing";
936
1587
  this.#socket.once("close", () => {
@@ -940,15 +1591,34 @@ var Connection = class extends EventEmitter {
940
1591
  this.#socket.end();
941
1592
  });
942
1593
  }
1594
+ /**
1595
+ * Enables or disables debug logging for incoming data (hex + ASCII dumps).
1596
+ *
1597
+ * @param enabled - Whether to enable debug output.
1598
+ * @returns This connection instance for chaining.
1599
+ */
943
1600
  debug(enabled) {
944
1601
  this.#debug = enabled;
945
1602
  return this;
946
1603
  }
1604
+ /**
1605
+ * Configures the retry behavior for connection attempts.
1606
+ *
1607
+ * @param attempts - Maximum number of retry attempts.
1608
+ * @param interval - Delay in milliseconds between retry attempts.
1609
+ * @returns This connection instance for chaining.
1610
+ */
947
1611
  retry(attempts, interval = 3e3) {
948
1612
  this.#retryAttempts = attempts;
949
1613
  this.#retryInterval = interval;
950
1614
  return this;
951
1615
  }
1616
+ /**
1617
+ * Writes data to the underlying TCP socket.
1618
+ * Emits an error event if the socket is not writable.
1619
+ *
1620
+ * @param data - The data to send.
1621
+ */
952
1622
  write(data) {
953
1623
  if (!this.#socket || this.state !== "connected" || !this.#socket.writable) {
954
1624
  this.#emitInternal("error", new ConnectionClosedError("Cannot write to a disconnected connection."));
@@ -960,6 +1630,10 @@ var Connection = class extends EventEmitter {
960
1630
  this.#emitInternal("error", err);
961
1631
  });
962
1632
  }
1633
+ /**
1634
+ * Creates a new socket and attempts to connect. The returned promise resolves
1635
+ * on successful connect or rejects after all retries are exhausted.
1636
+ */
963
1637
  async #attemptConnect() {
964
1638
  return new Promise((resolve, reject) => {
965
1639
  this.#state = "connecting";
@@ -968,16 +1642,17 @@ var Connection = class extends EventEmitter {
968
1642
  reject
969
1643
  };
970
1644
  this.#socket?.removeAllListeners();
1645
+ this.#socket?.destroy();
971
1646
  this.#socket = void 0;
972
1647
  this.#socket = new Socket();
973
1648
  this.#socket.setNoDelay(true);
974
1649
  this.#socket.setTimeout(SOCKET_TIMEOUT);
975
- this.#socket.on("close", this.#onClose.bind(this));
976
- this.#socket.on("connect", this.#onConnect.bind(this));
977
- this.#socket.on("data", this.#onData.bind(this));
978
- this.#socket.on("end", this.#onEnd.bind(this));
979
- this.#socket.on("error", this.#onError.bind(this));
980
- this.#socket.on("timeout", this.#onTimeout.bind(this));
1650
+ this.#socket.on("close", this.onClose);
1651
+ this.#socket.on("connect", this.onConnect);
1652
+ this.#socket.on("data", this.onData);
1653
+ this.#socket.on("end", this.onEnd);
1654
+ this.#socket.on("error", this.onError);
1655
+ this.#socket.on("timeout", this.onTimeout);
981
1656
  this.#context.logger.net(`Connecting to ${this.#address}:${this.#port}...`);
982
1657
  this.#socket.connect({
983
1658
  host: this.#address,
@@ -985,6 +1660,7 @@ var Connection = class extends EventEmitter {
985
1660
  });
986
1661
  });
987
1662
  }
1663
+ /** Removes all socket listeners, destroys the socket, and resets connection state. */
988
1664
  #cleanup() {
989
1665
  if (this.#retryTimeout) {
990
1666
  clearTimeout(this.#retryTimeout);
@@ -998,6 +1674,12 @@ var Connection = class extends EventEmitter {
998
1674
  this.#state = "disconnected";
999
1675
  this.#connectPromise = void 0;
1000
1676
  }
1677
+ /**
1678
+ * Schedules a retry attempt after a failed connection. If all attempts are exhausted,
1679
+ * transitions to 'failed' state and rejects the original connect promise.
1680
+ *
1681
+ * @param err - The error that triggered the retry.
1682
+ */
1001
1683
  #scheduleRetry(err) {
1002
1684
  if (!this.#retryEnabled || this.#retryAttempt >= this.#retryAttempts) {
1003
1685
  this.#state = "failed";
@@ -1022,10 +1704,18 @@ var Connection = class extends EventEmitter {
1022
1704
  };
1023
1705
  await this.#attemptConnect();
1024
1706
  resolve();
1025
- } catch (retryErr) {}
1707
+ } catch (retryErr) {
1708
+ reject(retryErr instanceof Error ? retryErr : new ConnectionError(String(retryErr)));
1709
+ }
1026
1710
  }, this.#retryInterval);
1027
1711
  }
1028
- #onClose(hadError) {
1712
+ /**
1713
+ * Handles the socket 'close' event. If the connection was active and closed
1714
+ * unexpectedly with an error, triggers a retry.
1715
+ *
1716
+ * @param hadError - Whether the close was caused by an error.
1717
+ */
1718
+ onClose(hadError) {
1029
1719
  const wasConnected = this.#state === "connected";
1030
1720
  if (this.#state !== "closing") {
1031
1721
  this.#state = "disconnected";
@@ -1034,7 +1724,11 @@ var Connection = class extends EventEmitter {
1034
1724
  this.#emitInternal("close", hadError);
1035
1725
  if (wasConnected && this.#retryEnabled && hadError) this.#scheduleRetry(new ConnectionClosedError());
1036
1726
  }
1037
- #onConnect() {
1727
+ /**
1728
+ * Handles successful TCP connection. Enables keep-alive (10s interval),
1729
+ * disables the connection timeout, resets retry counter, and resolves the connect promise.
1730
+ */
1731
+ onConnect() {
1038
1732
  this.#state = "connected";
1039
1733
  this.#retryAttempt = 0;
1040
1734
  this.#socket.setKeepAlive(true, 1e4);
@@ -1043,7 +1737,13 @@ var Connection = class extends EventEmitter {
1043
1737
  this.#connectPromise?.resolve();
1044
1738
  this.#connectPromise = void 0;
1045
1739
  }
1046
- #onData(data) {
1740
+ /**
1741
+ * Handles incoming data from the socket. When debug mode is enabled,
1742
+ * logs a hex and ASCII dump of the first 64 bytes.
1743
+ *
1744
+ * @param data - The received data buffer.
1745
+ */
1746
+ onData(data) {
1047
1747
  if (this.#debug) {
1048
1748
  const cutoff = Math.min(data.byteLength, 64);
1049
1749
  this.#context.logger.debug(`Received ${data.byteLength} bytes of data.`);
@@ -1052,17 +1752,27 @@ var Connection = class extends EventEmitter {
1052
1752
  }
1053
1753
  this.#emitInternal("data", data);
1054
1754
  }
1055
- #onEnd() {
1755
+ /** Handles the socket 'end' event (remote end sent FIN). */
1756
+ onEnd() {
1056
1757
  this.#emitInternal("end");
1057
1758
  }
1058
- #onError(err) {
1759
+ /**
1760
+ * Handles socket errors. If connecting, schedules a retry; otherwise marks
1761
+ * the connection as failed. Warns if no error listener is registered.
1762
+ *
1763
+ * @param err - The socket error.
1764
+ */
1765
+ onError(err) {
1059
1766
  this.#context.logger.error(`Connection error: ${err.message}`);
1060
1767
  if (this.listenerCount("error") > 0) this.#emitInternal("error", err);
1061
- else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "#onError");
1768
+ else this.#context.logger.warn("No error handler registered. This is likely a bug.", this.constructor.name, "onError");
1062
1769
  if (this.#state === "connecting") this.#scheduleRetry(err);
1063
- else this.#state = "failed";
1064
1770
  }
1065
- #onTimeout() {
1771
+ /**
1772
+ * Handles socket timeout. If connecting, schedules a retry;
1773
+ * otherwise destroys the socket and marks the connection as failed.
1774
+ */
1775
+ onTimeout() {
1066
1776
  this.#context.logger.error("Connection timed out.");
1067
1777
  const err = new ConnectionTimeoutError();
1068
1778
  this.#emitInternal("timeout");
@@ -1072,21 +1782,60 @@ var Connection = class extends EventEmitter {
1072
1782
  this.#socket?.destroy();
1073
1783
  }
1074
1784
  }
1785
+ /**
1786
+ * Type-safe internal emit that narrows the event to ConnectionEventMap keys.
1787
+ * Needed because the merged TEventMap makes the standard emit signature too broad.
1788
+ *
1789
+ * @param event - The event name to emit.
1790
+ * @param args - The event arguments.
1791
+ * @returns Whether any listeners were called.
1792
+ */
1793
+ #emitInternal(event, ...args) {
1794
+ return this.emit(event, ...args);
1795
+ }
1075
1796
  };
1797
+ /**
1798
+ * Connection subclass that adds optional ChaCha20-Poly1305 encryption.
1799
+ * Once encryption is enabled, subclasses use the {@link EncryptionState}
1800
+ * to encrypt outgoing data and decrypt incoming data with per-message nonce counters.
1801
+ */
1076
1802
  var EncryptionAwareConnection = class extends Connection {
1803
+ /** Whether encryption has been enabled on this connection. */
1077
1804
  get isEncrypted() {
1078
1805
  return !!this._encryption;
1079
1806
  }
1807
+ /** The current encryption state, or undefined if encryption is not enabled. */
1080
1808
  _encryption;
1809
+ /**
1810
+ * Enables ChaCha20-Poly1305 encryption for this connection.
1811
+ * After calling this, all subsequent data must be encrypted/decrypted
1812
+ * using the provided keys.
1813
+ *
1814
+ * @param readKey - The 32-byte key for decrypting incoming data.
1815
+ * @param writeKey - The 32-byte key for encrypting outgoing data.
1816
+ */
1081
1817
  enableEncryption(readKey, writeKey) {
1082
1818
  this._encryption = new EncryptionState(readKey, writeKey);
1083
1819
  }
1084
1820
  };
1821
+ /**
1822
+ * Holds the symmetric encryption keys and nonce counters for a single
1823
+ * encrypted connection. Each message increments the corresponding counter
1824
+ * to ensure unique nonces for ChaCha20-Poly1305.
1825
+ */
1085
1826
  var EncryptionState = class {
1827
+ /** The 32-byte key used to decrypt incoming data. */
1086
1828
  readKey;
1829
+ /** Monotonically increasing counter used as part of the read nonce. */
1087
1830
  readCount;
1831
+ /** The 32-byte key used to encrypt outgoing data. */
1088
1832
  writeKey;
1833
+ /** Monotonically increasing counter used as part of the write nonce. */
1089
1834
  writeCount;
1835
+ /**
1836
+ * @param readKey - The 32-byte decryption key.
1837
+ * @param writeKey - The 32-byte encryption key.
1838
+ */
1090
1839
  constructor(readKey, writeKey) {
1091
1840
  this.readCount = 0;
1092
1841
  this.readKey = readKey;
@@ -1095,98 +1844,50 @@ var EncryptionState = class {
1095
1844
  }
1096
1845
  };
1097
1846
 
1098
- //#endregion
1099
- //#region src/reporter.ts
1100
- var Logger = class {
1101
- get id() {
1102
- return this.#id;
1103
- }
1104
- get label() {
1105
- return this.#label;
1106
- }
1107
- #id;
1108
- #label;
1109
- constructor(id) {
1110
- this.#id = id;
1111
- this.#label = `\u001b[36m[${id}]\u001b[39m`;
1112
- }
1113
- debug(...data) {
1114
- debug(this.#label, ...data);
1115
- }
1116
- error(...data) {
1117
- error(this.#label, ...data);
1118
- }
1119
- info(...data) {
1120
- info(this.#label, ...data);
1121
- }
1122
- net(...data) {
1123
- net(this.#label, ...data);
1124
- }
1125
- raw(...data) {
1126
- raw(this.#label, ...data);
1127
- }
1128
- warn(...data) {
1129
- warn(this.#label, ...data);
1130
- }
1131
- };
1132
- var Reporter = class {
1133
- #enabled = [];
1134
- all() {
1135
- this.#enabled = [
1136
- "debug",
1137
- "error",
1138
- "info",
1139
- "net",
1140
- "raw",
1141
- "warn"
1142
- ];
1143
- }
1144
- none() {
1145
- this.#enabled = [];
1146
- }
1147
- disable(group) {
1148
- if (this.#enabled.includes(group)) this.#enabled.splice(this.#enabled.indexOf(group), 1);
1149
- }
1150
- enable(group) {
1151
- if (!this.#enabled.includes(group)) this.#enabled.push(group);
1152
- }
1153
- isEnabled(group) {
1154
- return this.#enabled.includes(group);
1155
- }
1156
- };
1157
- function debug(...data) {
1158
- reporter.isEnabled("debug") && console.debug(`\u001b[36m[debug]\u001b[39m`, ...data);
1159
- }
1160
- function error(...data) {
1161
- reporter.isEnabled("error") && console.error(`\u001b[31m[error]\u001b[39m`, ...data);
1162
- }
1163
- function info(...data) {
1164
- reporter.isEnabled("info") && console.info(`\u001b[32m[info]\u001b[39m`, ...data);
1165
- }
1166
- function net(...data) {
1167
- reporter.isEnabled("net") && console.info(`\u001b[33m[net]\u001b[39m`, ...data);
1168
- }
1169
- function raw(...data) {
1170
- reporter.isEnabled("raw") && console.log(`\u001b[34m[raw]\u001b[39m`, ...data);
1171
- }
1172
- function warn(...data) {
1173
- reporter.isEnabled("warn") && console.warn(`\u001b[33m[warn]\u001b[39m`, ...data);
1174
- }
1175
- const reporter = new Reporter();
1176
-
1177
1847
  //#endregion
1178
1848
  //#region src/context.ts
1849
+ /** Default identity mimicking an iPhone running the Apple TV Remote app. */
1850
+ const DEFAULT_IDENTITY = {
1851
+ name: "apple-protocols",
1852
+ model: "iPhone17,3",
1853
+ osName: "iPhone OS",
1854
+ osVersion: "26.3",
1855
+ osBuildVersion: "25D63",
1856
+ sourceVersion: "935.7.1",
1857
+ applicationBundleIdentifier: "com.apple.TVRemote",
1858
+ applicationBundleVersion: "700"
1859
+ };
1860
+ /**
1861
+ * Shared context for a device connection, carrying the device identifier,
1862
+ * the client identity presented to the accessory, and a scoped logger.
1863
+ * Passed through the entire protocol stack for consistent identification and logging.
1864
+ */
1179
1865
  var Context = class {
1866
+ /** Unique identifier of the target device (typically its mDNS hostname). */
1180
1867
  get deviceId() {
1181
1868
  return this.#deviceId;
1182
1869
  }
1870
+ /** The identity this client presents to the Apple device. */
1871
+ get identity() {
1872
+ return this.#identity;
1873
+ }
1874
+ /** Scoped logger instance tagged with the device identifier. */
1183
1875
  get logger() {
1184
1876
  return this.#logger;
1185
1877
  }
1186
1878
  #deviceId;
1879
+ #identity;
1187
1880
  #logger;
1188
- constructor(deviceId) {
1881
+ /**
1882
+ * @param deviceId - Unique identifier of the target device.
1883
+ * @param identity - Optional partial override of the default device identity.
1884
+ */
1885
+ constructor(deviceId, identity) {
1189
1886
  this.#deviceId = deviceId;
1887
+ this.#identity = {
1888
+ ...DEFAULT_IDENTITY,
1889
+ ...identity
1890
+ };
1190
1891
  this.#logger = new Logger(deviceId);
1191
1892
  }
1192
1893
  };
@@ -3215,14 +3916,30 @@ var require_srp = /* @__PURE__ */ __commonJSMin(((exports) => {
3215
3916
  //#endregion
3216
3917
  //#region src/pairing.ts
3217
3918
  var import_srp = require_srp();
3919
+ /**
3920
+ * Abstract base class for HAP pairing operations. Provides shared TLV8
3921
+ * decoding with automatic error checking.
3922
+ */
3218
3923
  var BasePairing = class {
3924
+ /** The shared context carrying device identity and logger. */
3219
3925
  get context() {
3220
3926
  return this.#context;
3221
3927
  }
3222
3928
  #context;
3929
+ /**
3930
+ * @param context - Shared context for logging and device identity.
3931
+ */
3223
3932
  constructor(context) {
3224
3933
  this.#context = context;
3225
3934
  }
3935
+ /**
3936
+ * Decodes a TLV8 buffer and checks for error codes. If the TLV contains
3937
+ * an error value, it throws via TLV8.bail.
3938
+ *
3939
+ * @param buffer - The raw TLV8-encoded response.
3940
+ * @returns A map of TLV type numbers to their buffer values.
3941
+ * @throws If the TLV contains an error value.
3942
+ */
3226
3943
  tlv(buffer) {
3227
3944
  const data = TLV8.decode(buffer);
3228
3945
  if (data.has(TLV8.Value.Error)) TLV8.bail(data);
@@ -3242,23 +3959,43 @@ var BasePairing = class {
3242
3959
  * For transient pairing (HomePod), only M1-M4 are needed — no long-term credentials are stored.
3243
3960
  */
3244
3961
  var AccessoryPair = class extends BasePairing {
3962
+ /** Human-readable name sent to the accessory during pairing. */
3245
3963
  #name;
3964
+ /** Unique pairing identifier (UUID) for this controller. */
3246
3965
  #pairingId;
3966
+ /** Protocol-specific callback for sending TLV8-encoded pair-setup requests. */
3247
3967
  #requestHandler;
3968
+ /** Ed25519 public key for long-term identity. */
3248
3969
  #publicKey;
3970
+ /** Ed25519 secret key for signing identity proofs. */
3249
3971
  #secretKey;
3972
+ /** SRP client instance used for PIN-based authentication. */
3250
3973
  #srp;
3974
+ /**
3975
+ * @param context - Shared context for logging and device identity.
3976
+ * @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
3977
+ */
3251
3978
  constructor(context, requestHandler) {
3252
3979
  super(context);
3253
3980
  this.#name = "basmilius/apple-protocols";
3254
3981
  this.#pairingId = Buffer.from(v4().toUpperCase());
3255
3982
  this.#requestHandler = requestHandler;
3256
3983
  }
3984
+ /** Generates a new Ed25519 key pair for long-term identity. Must be called before pin() or transient(). */
3257
3985
  async start() {
3258
3986
  const keyPair = Ed25519.generateKeyPair();
3259
3987
  this.#publicKey = Buffer.from(keyPair.publicKey);
3260
3988
  this.#secretKey = Buffer.from(keyPair.secretKey);
3261
3989
  }
3990
+ /**
3991
+ * Performs the full PIN-based pair-setup flow (M1 through M6).
3992
+ * Prompts for a PIN via the provided callback, then completes SRP authentication
3993
+ * and Ed25519 credential exchange with the accessory.
3994
+ *
3995
+ * @param askPin - Async callback that returns the user-entered PIN.
3996
+ * @returns Long-term credentials for future pair-verify sessions.
3997
+ * @throws {PairingError} If any step fails.
3998
+ */
3262
3999
  async pin(askPin) {
3263
4000
  const m1 = await this.m1();
3264
4001
  const m2 = await this.m2(m1, await askPin());
@@ -3269,6 +4006,15 @@ var AccessoryPair = class extends BasePairing {
3269
4006
  if (!m6) throw new PairingError("Pairing failed, could not get accessory keys.");
3270
4007
  return m6;
3271
4008
  }
4009
+ /**
4010
+ * Performs the transient (non-persistent) pair-setup flow (M1 through M4 only).
4011
+ * Uses the default transient PIN ('3939'). No long-term credentials are stored;
4012
+ * session keys are derived directly from the SRP shared secret.
4013
+ *
4014
+ * Primarily used for HomePod connections where persistent pairing is not required.
4015
+ *
4016
+ * @returns Session keys for the current connection (not reusable across sessions).
4017
+ */
3272
4018
  async transient() {
3273
4019
  const m1 = await this.m1([[TLV8.Value.Flags, TLV8.Flags.TransientPairing]]);
3274
4020
  const m2 = await this.m2(m1);
@@ -3447,13 +4193,28 @@ var AccessoryPair = class extends BasePairing {
3447
4193
  * → M3 (controller proof) → M4 (session key derivation)
3448
4194
  */
3449
4195
  var AccessoryVerify = class extends BasePairing {
4196
+ /** Ephemeral Curve25519 key pair for forward-secrecy ECDH exchange. */
3450
4197
  #ephemeralKeyPair;
4198
+ /** Protocol-specific callback for sending TLV8-encoded pair-verify requests. */
3451
4199
  #requestHandler;
4200
+ /**
4201
+ * @param context - Shared context for logging and device identity.
4202
+ * @param requestHandler - Callback that sends TLV8-encoded data and returns the response.
4203
+ */
3452
4204
  constructor(context, requestHandler) {
3453
4205
  super(context);
3454
4206
  this.#ephemeralKeyPair = Curve25519.generateKeyPair();
3455
4207
  this.#requestHandler = requestHandler;
3456
4208
  }
4209
+ /**
4210
+ * Performs the full pair-verify flow (M1 through M4) using stored credentials
4211
+ * from a previous pair-setup. Establishes a new session with forward secrecy
4212
+ * via ephemeral Curve25519 key exchange.
4213
+ *
4214
+ * @param credentials - Long-term credentials from a previous pair-setup.
4215
+ * @returns Session keys derived from the ECDH shared secret.
4216
+ * @throws {AuthenticationError} If the accessory's identity cannot be verified.
4217
+ */
3457
4218
  async start(credentials) {
3458
4219
  const m1 = await this.#m1();
3459
4220
  const m2 = await this.#m2(credentials.accessoryIdentifier, credentials.accessoryLongTermPublicKey, m1);
@@ -3551,10 +4312,25 @@ var AccessoryVerify = class extends BasePairing {
3551
4312
 
3552
4313
  //#endregion
3553
4314
  //#region src/symbols.ts
4315
+ /**
4316
+ * Unique symbol used as a key for accessing encryption state on connection instances.
4317
+ * Provides type-safe access to internal encryption fields without exposing them publicly.
4318
+ */
3554
4319
  const ENCRYPTION = Symbol();
3555
4320
 
3556
4321
  //#endregion
3557
4322
  //#region src/recovery.ts
4323
+ /**
4324
+ * Manages automatic connection recovery with exponential backoff.
4325
+ *
4326
+ * When an unexpected disconnect occurs, this class schedules retry attempts
4327
+ * with configurable delay (exponential backoff by default: base=1s, max=30s).
4328
+ * Emits 'recovering', 'recovered', and 'failed' events for monitoring.
4329
+ *
4330
+ * Optionally supports a periodic reconnect interval for proactive
4331
+ * connection health checks (failures are silent; unexpected disconnects
4332
+ * trigger the full recovery flow).
4333
+ */
3558
4334
  var ConnectionRecovery = class extends EventEmitter {
3559
4335
  #options;
3560
4336
  #attempt = 0;
@@ -3564,6 +4340,9 @@ var ConnectionRecovery = class extends EventEmitter {
3564
4340
  #retryTimeout;
3565
4341
  #reconnectInterval;
3566
4342
  #disposed = false;
4343
+ /**
4344
+ * @param options - Recovery configuration including the reconnect callback.
4345
+ */
3567
4346
  constructor(options) {
3568
4347
  super();
3569
4348
  this.#options = {
@@ -3576,17 +4355,26 @@ var ConnectionRecovery = class extends EventEmitter {
3576
4355
  };
3577
4356
  if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
3578
4357
  }
4358
+ /** Whether a recovery attempt is currently in progress. */
3579
4359
  get isRecovering() {
3580
4360
  return this.#isRecovering;
3581
4361
  }
4362
+ /** The current retry attempt number (0 when not recovering). */
3582
4363
  get attempt() {
3583
4364
  return this.#attempt;
3584
4365
  }
4366
+ /**
4367
+ * Called when a disconnect is detected. Only triggers recovery for
4368
+ * unexpected disconnects; intentional disconnects are ignored.
4369
+ *
4370
+ * @param unexpected - Whether the disconnect was unexpected (e.g. socket error).
4371
+ */
3585
4372
  handleDisconnect(unexpected) {
3586
4373
  if (this.#disposed || !unexpected) return;
3587
4374
  this.#stopReconnectInterval();
3588
4375
  this.#recover();
3589
4376
  }
4377
+ /** Resets the recovery state and restarts the periodic reconnect interval if configured. */
3590
4378
  reset() {
3591
4379
  this.#attempt = 0;
3592
4380
  this.#errors = [];
@@ -3597,6 +4385,7 @@ var ConnectionRecovery = class extends EventEmitter {
3597
4385
  }
3598
4386
  if (this.#options.reconnectInterval > 0) this.#startReconnectInterval();
3599
4387
  }
4388
+ /** Permanently disposes this recovery instance, cancelling all timers and removing listeners. */
3600
4389
  dispose() {
3601
4390
  this.#disposed = true;
3602
4391
  this.#isRecovering = false;
@@ -3608,6 +4397,10 @@ var ConnectionRecovery = class extends EventEmitter {
3608
4397
  this.#stopReconnectInterval();
3609
4398
  this.removeAllListeners();
3610
4399
  }
4400
+ /**
4401
+ * Initiates a recovery attempt. If max attempts are reached, emits 'failed'
4402
+ * with all collected errors. Otherwise, schedules a delayed reconnect.
4403
+ */
3611
4404
  #recover() {
3612
4405
  if (this.#isRecovering || this.#disposed) return;
3613
4406
  if (this.#attempt >= this.#options.maxAttempts) {
@@ -3634,11 +4427,22 @@ var ConnectionRecovery = class extends EventEmitter {
3634
4427
  }
3635
4428
  }, delay);
3636
4429
  }
4430
+ /**
4431
+ * Calculates the delay before the next retry attempt.
4432
+ * Uses exponential backoff (base * 2^(attempt-1)) capped at maxDelay,
4433
+ * or a flat baseDelay if exponential backoff is disabled.
4434
+ *
4435
+ * @returns The delay in milliseconds.
4436
+ */
3637
4437
  #calculateDelay() {
3638
4438
  if (!this.#options.useExponentialBackoff) return this.#options.baseDelay;
3639
4439
  const delay = this.#options.baseDelay * Math.pow(2, this.#attempt - 1);
3640
4440
  return Math.min(delay, this.#options.maxDelay);
3641
4441
  }
4442
+ /**
4443
+ * Starts the periodic reconnect interval. Silently calls onReconnect at the
4444
+ * configured interval; failures do not trigger recovery (that happens via handleDisconnect).
4445
+ */
3642
4446
  #startReconnectInterval() {
3643
4447
  this.#stopReconnectInterval();
3644
4448
  this.#reconnectInterval = setInterval(async () => {
@@ -3651,6 +4455,7 @@ var ConnectionRecovery = class extends EventEmitter {
3651
4455
  }
3652
4456
  }, this.#options.reconnectInterval);
3653
4457
  }
4458
+ /** Stops the periodic reconnect interval timer. */
3654
4459
  #stopReconnectInterval() {
3655
4460
  if (this.#reconnectInterval) {
3656
4461
  clearInterval(this.#reconnectInterval);
@@ -3661,7 +4466,19 @@ var ConnectionRecovery = class extends EventEmitter {
3661
4466
 
3662
4467
  //#endregion
3663
4468
  //#region src/timing.ts
4469
+ /**
4470
+ * NTP timing server for AirPlay audio synchronization.
4471
+ *
4472
+ * Apple devices send NTP timing requests to synchronize their clocks with the
4473
+ * controller during audio streaming. This server responds with the current
4474
+ * wall-clock time using NTP packet format, enabling the device to calculate
4475
+ * the correct playback offset for synchronized multi-room audio.
4476
+ *
4477
+ * Must use wall-clock time (Date.now), not monotonic time (process.hrtime),
4478
+ * because NTP timestamps are anchored to the Unix epoch.
4479
+ */
3664
4480
  var TimingServer = class {
4481
+ /** The UDP port the timing server is listening on, or 0 if not yet bound. */
3665
4482
  get port() {
3666
4483
  return this.#port;
3667
4484
  }
@@ -3671,14 +4488,24 @@ var TimingServer = class {
3671
4488
  constructor() {
3672
4489
  this.#logger = new Logger("timing-server");
3673
4490
  this.#socket = createSocket("udp4");
3674
- this.#socket.on("connect", this.#onConnect.bind(this));
3675
- this.#socket.on("error", this.#onError.bind(this));
3676
- this.#socket.on("message", this.#onMessage.bind(this));
3677
- }
4491
+ this.onConnect = this.onConnect.bind(this);
4492
+ this.onError = this.onError.bind(this);
4493
+ this.onMessage = this.onMessage.bind(this);
4494
+ this.#socket.on("connect", this.onConnect);
4495
+ this.#socket.on("error", this.onError);
4496
+ this.#socket.on("message", this.onMessage);
4497
+ }
4498
+ /** Closes the UDP socket and resets the port. */
3678
4499
  close() {
3679
4500
  this.#socket.close();
3680
4501
  this.#port = 0;
3681
4502
  }
4503
+ /**
4504
+ * Binds the UDP socket to a random available port and starts listening
4505
+ * for NTP timing requests.
4506
+ *
4507
+ * @throws If the socket fails to bind.
4508
+ */
3682
4509
  listen() {
3683
4510
  return new Promise((resolve, reject) => {
3684
4511
  const onError = (err) => {
@@ -3695,18 +4522,37 @@ var TimingServer = class {
3695
4522
  this.#socket.bind(0);
3696
4523
  });
3697
4524
  }
3698
- #onConnect() {
4525
+ /**
4526
+ * Handles the socket 'connect' event by configuring buffer sizes.
4527
+ */
4528
+ onConnect() {
3699
4529
  this.#socket.setRecvBufferSize(16384);
3700
4530
  this.#socket.setSendBufferSize(16384);
3701
4531
  }
3702
- #onError(err) {
4532
+ /**
4533
+ * Handles socket errors by logging them.
4534
+ *
4535
+ * @param err - The error that occurred.
4536
+ */
4537
+ onError(err) {
3703
4538
  this.#logger.error("Timing server error", err);
3704
4539
  }
4540
+ /**
4541
+ * Records the bound port after the socket starts listening.
4542
+ */
3705
4543
  #onListening() {
3706
4544
  const { port } = this.#socket.address();
3707
4545
  this.#port = port;
3708
4546
  }
3709
- #onMessage(data, info) {
4547
+ /**
4548
+ * Handles incoming NTP timing requests from Apple devices.
4549
+ * Decodes the request, captures the current NTP timestamp, and sends back
4550
+ * a response with reference, receive, and send timestamps populated.
4551
+ *
4552
+ * @param data - The raw UDP packet data.
4553
+ * @param info - Remote address information of the sender.
4554
+ */
4555
+ onMessage(data, info) {
3710
4556
  try {
3711
4557
  const request = NTP.decode(data);
3712
4558
  const ntp = NTP.now();
@@ -3736,15 +4582,36 @@ var TimingServer = class {
3736
4582
 
3737
4583
  //#endregion
3738
4584
  //#region src/utils.ts
4585
+ /**
4586
+ * Generates a random Active-Remote identifier for DACP (Digital Audio Control Protocol).
4587
+ * Used in RTSP headers to identify the remote controller.
4588
+ *
4589
+ * @returns A random 32-bit unsigned integer as a decimal string.
4590
+ */
3739
4591
  function generateActiveRemoteId() {
3740
4592
  return Math.floor(Math.random() * 2 ** 32).toString(10);
3741
4593
  }
4594
+ /**
4595
+ * Generates a random DACP-ID for identifying this controller in DACP sessions.
4596
+ *
4597
+ * @returns A random 64-bit integer as an uppercase hexadecimal string.
4598
+ */
3742
4599
  function generateDacpId() {
3743
4600
  return Math.floor(Math.random() * 2 ** 64).toString(16).toUpperCase();
3744
4601
  }
4602
+ /**
4603
+ * Generates a random session identifier for RTSP and AirPlay sessions.
4604
+ *
4605
+ * @returns A random 32-bit unsigned integer as a decimal string.
4606
+ */
3745
4607
  function generateSessionId() {
3746
4608
  return Math.floor(Math.random() * 2 ** 32).toString(10);
3747
4609
  }
4610
+ /**
4611
+ * Finds the first non-internal IPv4 address on this machine.
4612
+ *
4613
+ * @returns The local IPv4 address, or null if none is found.
4614
+ */
3748
4615
  function getLocalIP() {
3749
4616
  const interfaces = networkInterfaces();
3750
4617
  for (const iface of Object.values(interfaces)) {
@@ -3756,6 +4623,11 @@ function getLocalIP() {
3756
4623
  }
3757
4624
  return null;
3758
4625
  }
4626
+ /**
4627
+ * Finds the first non-internal MAC address on this machine.
4628
+ *
4629
+ * @returns The MAC address in uppercase colon-separated format, or '00:00:00:00:00:00' if none is found.
4630
+ */
3759
4631
  function getMacAddress() {
3760
4632
  const interfaces = networkInterfaces();
3761
4633
  for (const iface of Object.values(interfaces)) {
@@ -3767,17 +4639,41 @@ function getMacAddress() {
3767
4639
  }
3768
4640
  return "00:00:00:00:00:00";
3769
4641
  }
4642
+ /**
4643
+ * Generates a cryptographically random 32-bit unsigned integer.
4644
+ *
4645
+ * @returns A random unsigned 32-bit integer.
4646
+ */
3770
4647
  function randomInt32() {
3771
4648
  return randomBytes(4).readUInt32BE(0);
3772
4649
  }
4650
+ /**
4651
+ * Generates a cryptographically random 64-bit unsigned integer.
4652
+ *
4653
+ * @returns A random unsigned 64-bit bigint.
4654
+ */
3773
4655
  function randomInt64() {
3774
4656
  return randomBytes(8).readBigUint64LE(0);
3775
4657
  }
4658
+ /**
4659
+ * Encodes a 16-bit unsigned integer into a big-endian buffer.
4660
+ *
4661
+ * @param value - The 16-bit unsigned integer to encode.
4662
+ * @returns A 2-byte buffer containing the value in big-endian byte order.
4663
+ */
3776
4664
  function uint16ToBE(value) {
3777
4665
  const buffer = Buffer.allocUnsafe(2);
3778
4666
  buffer.writeUInt16BE(value, 0);
3779
4667
  return buffer;
3780
4668
  }
4669
+ /**
4670
+ * Encodes a 53-bit unsigned integer into an 8-byte little-endian buffer.
4671
+ * Useful for encoding JavaScript-safe integers into 64-bit wire formats.
4672
+ *
4673
+ * @param value - The unsigned integer to encode (must be in range [0, 2^53-1]).
4674
+ * @returns An 8-byte buffer containing the value in little-endian byte order.
4675
+ * @throws If the value is out of range or not an integer.
4676
+ */
3781
4677
  function uint53ToLE(value) {
3782
4678
  const [upper, lower] = splitUInt53(value);
3783
4679
  const buffer = Buffer.allocUnsafe(8);
@@ -3785,6 +4681,13 @@ function uint53ToLE(value) {
3785
4681
  buffer.writeUInt32LE(upper, 4);
3786
4682
  return buffer;
3787
4683
  }
4684
+ /**
4685
+ * Splits a 53-bit unsigned integer into upper and lower 32-bit halves.
4686
+ *
4687
+ * @param number - The integer to split (must be in range [0, 2^53-1]).
4688
+ * @returns A tuple of [upper32, lower32].
4689
+ * @throws If the number is out of range or not an integer.
4690
+ */
3788
4691
  function splitUInt53(number) {
3789
4692
  const MAX_UINT32 = 4294967295;
3790
4693
  if (number <= -1 || number > 9007199254740991) throw new Error("Number out of range.");
@@ -3798,6 +4701,7 @@ function splitUInt53(number) {
3798
4701
 
3799
4702
  //#endregion
3800
4703
  //#region src/deviceModel.ts
4704
+ /** Known Apple device models that can be discovered and controlled via AirPlay/Companion Link. */
3801
4705
  let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
3802
4706
  DeviceModel[DeviceModel["Unknown"] = 0] = "Unknown";
3803
4707
  DeviceModel[DeviceModel["AppleTVGen2"] = 1] = "AppleTVGen2";
@@ -3813,6 +4717,7 @@ let DeviceModel = /* @__PURE__ */ function(DeviceModel) {
3813
4717
  DeviceModel[DeviceModel["AirPortExpressGen2"] = 21] = "AirPortExpressGen2";
3814
4718
  return DeviceModel;
3815
4719
  }({});
4720
+ /** High-level device categories derived from the specific device model. */
3816
4721
  let DeviceType = /* @__PURE__ */ function(DeviceType) {
3817
4722
  DeviceType[DeviceType["Unknown"] = 0] = "Unknown";
3818
4723
  DeviceType[DeviceType["AppleTV"] = 1] = "AppleTV";
@@ -3820,6 +4725,7 @@ let DeviceType = /* @__PURE__ */ function(DeviceType) {
3820
4725
  DeviceType[DeviceType["AirPort"] = 3] = "AirPort";
3821
4726
  return DeviceType;
3822
4727
  }({});
4728
+ /** Maps Apple model identifiers (e.g. "AppleTV6,2") to known device models. */
3823
4729
  const MODEL_IDENTIFIERS = {
3824
4730
  "AppleTV2,1": DeviceModel.AppleTVGen2,
3825
4731
  "AppleTV3,1": DeviceModel.AppleTVGen3,
@@ -3836,6 +4742,7 @@ const MODEL_IDENTIFIERS = {
3836
4742
  "AirPort4,107": DeviceModel.AirPortExpress,
3837
4743
  "AirPort10,115": DeviceModel.AirPortExpressGen2
3838
4744
  };
4745
+ /** Maps Apple internal code names (e.g. "J305AP") to known device models. */
3839
4746
  const INTERNAL_NAMES = {
3840
4747
  "K66AP": DeviceModel.AppleTVGen2,
3841
4748
  "J33AP": DeviceModel.AppleTVGen3,
@@ -3846,6 +4753,7 @@ const INTERNAL_NAMES = {
3846
4753
  "J255AP": DeviceModel.AppleTV4KGen3,
3847
4754
  "B520AP": DeviceModel.HomePodMini
3848
4755
  };
4756
+ /** Human-readable display names for each device model. */
3849
4757
  const MODEL_NAMES = {
3850
4758
  [DeviceModel.Unknown]: "Unknown",
3851
4759
  [DeviceModel.AppleTVGen2]: "Apple TV (2nd generation)",
@@ -3860,6 +4768,7 @@ const MODEL_NAMES = {
3860
4768
  [DeviceModel.AirPortExpress]: "AirPort Express",
3861
4769
  [DeviceModel.AirPortExpressGen2]: "AirPort Express (2nd generation)"
3862
4770
  };
4771
+ /** Maps each device model to its high-level device type category. */
3863
4772
  const MODEL_TYPES = {
3864
4773
  [DeviceModel.Unknown]: DeviceType.Unknown,
3865
4774
  [DeviceModel.AppleTVGen2]: DeviceType.AppleTV,
@@ -3874,11 +4783,47 @@ const MODEL_TYPES = {
3874
4783
  [DeviceModel.AirPortExpress]: DeviceType.AirPort,
3875
4784
  [DeviceModel.AirPortExpressGen2]: DeviceType.AirPort
3876
4785
  };
4786
+ /**
4787
+ * Resolves an Apple model identifier or internal code name to a known device model.
4788
+ *
4789
+ * @param identifier - Model identifier (e.g. "AppleTV6,2") or internal name (e.g. "J305AP").
4790
+ * @returns The matching device model, or {@link DeviceModel.Unknown} if unrecognized.
4791
+ */
3877
4792
  const lookupDeviceModel = (identifier) => MODEL_IDENTIFIERS[identifier] ?? INTERNAL_NAMES[identifier] ?? DeviceModel.Unknown;
4793
+ /**
4794
+ * Returns the human-readable display name for a device model.
4795
+ *
4796
+ * @param model - The device model to look up.
4797
+ * @returns A display name like "Apple TV 4K (2nd generation)", or "Unknown".
4798
+ */
3878
4799
  const getDeviceModelName = (model) => MODEL_NAMES[model] ?? "Unknown";
4800
+ /**
4801
+ * Returns the high-level device type category for a device model.
4802
+ *
4803
+ * @param model - The device model to categorize.
4804
+ * @returns The device type (AppleTV, HomePod, AirPort, or Unknown).
4805
+ */
3879
4806
  const getDeviceType = (model) => MODEL_TYPES[model] ?? DeviceType.Unknown;
4807
+ /**
4808
+ * Checks whether the given model is an Apple TV.
4809
+ *
4810
+ * @param model - The device model to check.
4811
+ * @returns True if the model is any Apple TV generation.
4812
+ */
3880
4813
  const isAppleTV = (model) => getDeviceType(model) === DeviceType.AppleTV;
4814
+ /**
4815
+ * Checks whether the given model is a HomePod.
4816
+ *
4817
+ * @param model - The device model to check.
4818
+ * @returns True if the model is any HomePod variant.
4819
+ */
3881
4820
  const isHomePod = (model) => getDeviceType(model) === DeviceType.HomePod;
4821
+ /**
4822
+ * Checks whether the given model is an AirPort Express.
4823
+ *
4824
+ * @param model - The device model to check.
4825
+ * @returns True if the model is any AirPort Express generation.
4826
+ */
3882
4827
  const isAirPort = (model) => getDeviceType(model) === DeviceType.AirPort;
3883
4828
 
3884
4829
  //#endregion