@apocaliss92/nodelink-js 0.6.1 → 0.6.3

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.
@@ -30,7 +30,7 @@ import {
30
30
  runAllDiagnosticsConsecutively,
31
31
  runMultifocalDiagnosticsConsecutively,
32
32
  xmlEscape
33
- } from "./chunk-XDVBNZGR.js";
33
+ } from "./chunk-IQVVVSXO.js";
34
34
  import {
35
35
  BC_CLASS_FILE_DOWNLOAD,
36
36
  BC_CLASS_LEGACY,
@@ -232,35 +232,88 @@ function decodeHeader(buf) {
232
232
  return { header, headerLen, messageKey };
233
233
  }
234
234
  var BaichuanFrameParser = class {
235
+ /** Retained-but-unconsumed contiguous bytes from previous push() calls. */
235
236
  buffer = Buffer.alloc(0);
237
+ /** Chunks received since the last materialization, not yet concatenated. */
238
+ pending = [];
239
+ /** Total bytes held in `pending` (kept in sync to avoid re-summing). */
240
+ pendingLen = 0;
241
+ /**
242
+ * Total contiguous bytes (`buffer` + `pending`) required before the next
243
+ * parse attempt can make progress. While buffered bytes stay below this,
244
+ * incoming chunks are merely stashed in `pending` with no copy. This is
245
+ * the mechanism that turns the worst case (a large frame fragmented over
246
+ * many small TCP chunks) from O(n²) into O(n): we concatenate once, when
247
+ * enough bytes have arrived, instead of on every chunk.
248
+ *
249
+ * Starts at 4 — the minimum needed to inspect the magic header.
250
+ */
251
+ needed = 4;
252
+ /**
253
+ * Collapse `this.buffer` + all `pending` chunks into a single contiguous
254
+ * buffer. The retained leftover is copied at most once per materialize(),
255
+ * and materialize() only runs when `needed` bytes are available — so a
256
+ * fragmented frame is assembled with a single concat, not one per chunk.
257
+ */
258
+ materialize() {
259
+ if (this.pendingLen === 0) return;
260
+ if (this.buffer.length === 0 && this.pending.length === 1) {
261
+ this.buffer = this.pending[0];
262
+ } else {
263
+ const parts = this.buffer.length === 0 ? this.pending : [this.buffer, ...this.pending];
264
+ this.buffer = Buffer.concat(parts);
265
+ }
266
+ this.pending = [];
267
+ this.pendingLen = 0;
268
+ }
269
+ /** Total buffered bytes, whether materialized or still pending. */
270
+ get available() {
271
+ return this.buffer.length + this.pendingLen;
272
+ }
236
273
  push(chunk) {
237
274
  if (chunk.length === 0) return [];
238
- const c = chunk;
239
- this.buffer = this.buffer.length === 0 ? c : Buffer.concat([this.buffer, c]);
275
+ this.pending.push(chunk);
276
+ this.pendingLen += chunk.length;
277
+ if (this.available < this.needed) return [];
278
+ this.materialize();
240
279
  const out = [];
241
280
  while (true) {
242
- if (this.buffer.length < 4) break;
281
+ if (this.buffer.length < 4) {
282
+ this.needed = 4;
283
+ break;
284
+ }
243
285
  if (!this.buffer.subarray(0, 4).equals(BC_MAGIC) && !this.buffer.subarray(0, 4).equals(BC_MAGIC_REV)) {
244
286
  const idx = this.buffer.indexOf(BC_MAGIC);
245
287
  const idxRev = this.buffer.indexOf(BC_MAGIC_REV);
246
288
  const next = idx === -1 ? idxRev : idxRev === -1 ? idx : Math.min(idx, idxRev);
247
289
  if (next === -1) {
248
290
  this.buffer = this.buffer.subarray(Math.max(0, this.buffer.length - 3));
291
+ this.needed = 4;
249
292
  break;
250
293
  }
251
294
  this.buffer = this.buffer.subarray(next);
252
- if (this.buffer.length < 20) break;
295
+ if (this.buffer.length < 20) {
296
+ this.needed = 20;
297
+ break;
298
+ }
299
+ }
300
+ if (this.buffer.length < 20) {
301
+ this.needed = 20;
302
+ break;
253
303
  }
254
- if (this.buffer.length < 20) break;
255
304
  let headerInfo;
256
305
  try {
257
306
  headerInfo = decodeHeader(this.buffer);
258
307
  } catch {
308
+ this.needed = 24;
259
309
  break;
260
310
  }
261
311
  const { header, headerLen, messageKey } = headerInfo;
262
312
  const frameLen = headerLen + header.bodyLen;
263
- if (this.buffer.length < frameLen) break;
313
+ if (this.buffer.length < frameLen) {
314
+ this.needed = frameLen;
315
+ break;
316
+ }
264
317
  const raw = this.buffer.subarray(0, frameLen);
265
318
  const body = raw.subarray(headerLen);
266
319
  let extLen = 0;
@@ -272,6 +325,7 @@ var BaichuanFrameParser = class {
272
325
  const payload = body.subarray(extLen);
273
326
  out.push({ header, body, extension, payload, messageKey, raw });
274
327
  this.buffer = this.buffer.subarray(frameLen);
328
+ this.needed = 4;
275
329
  }
276
330
  return out;
277
331
  }
@@ -677,8 +731,14 @@ async function getServerBinding(uid, options = {}) {
677
731
  headers: { Accept: "application/json" }
678
732
  });
679
733
  if (!res.ok) {
734
+ let bodyPreview;
735
+ try {
736
+ const text = await res.text();
737
+ bodyPreview = text.slice(0, 512).replace(/\s+/g, " ").trim();
738
+ } catch {
739
+ }
680
740
  logger?.log?.(
681
- `[server-binding] ${uid}: HTTP ${res.status} ${res.statusText} from ${url}`
741
+ `[server-binding] ${uid}: HTTP ${res.status} ${res.statusText} from ${url}` + (bodyPreview ? ` \u2014 body=${bodyPreview}` : "")
682
742
  );
683
743
  cache.set(uid, { kind: "err", expires: now + NEGATIVE_TTL_MS });
684
744
  return void 0;
@@ -794,6 +854,68 @@ function parseServerBindingResponse(raw) {
794
854
  }
795
855
 
796
856
  // src/bcudp/BcUdpStream.ts
857
+ async function probeEgressForHost(destHost, destPort) {
858
+ return await new Promise((resolve, reject) => {
859
+ const probe = dgram.createSocket("udp4");
860
+ let settled = false;
861
+ const finish = (err, out) => {
862
+ if (settled) return;
863
+ settled = true;
864
+ try {
865
+ probe.close();
866
+ } catch {
867
+ }
868
+ if (err || !out) reject(err ?? new Error("egress probe failed"));
869
+ else resolve(out);
870
+ };
871
+ probe.on("error", (e) => finish(e));
872
+ try {
873
+ probe.connect(destPort, destHost, () => {
874
+ try {
875
+ const a = probe.address();
876
+ if (typeof a === "string") return finish(new Error("probe address is string"));
877
+ finish(void 0, {
878
+ localAddress: a.address,
879
+ localPort: a.port
880
+ });
881
+ } catch (e) {
882
+ finish(e);
883
+ }
884
+ });
885
+ } catch (e) {
886
+ finish(e);
887
+ }
888
+ });
889
+ }
890
+ function isSameSubnetAsAnyLocalIface(destHost, srcInfo) {
891
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(destHost)) return "unknown";
892
+ const dest = destHost.split(".").map((s) => Number(s));
893
+ if (dest.some((n) => !Number.isFinite(n) || n < 0 || n > 255))
894
+ return "unknown";
895
+ const ifaces = networkInterfaces();
896
+ let ownerSubnet;
897
+ for (const name of Object.keys(ifaces)) {
898
+ const entries = ifaces[name];
899
+ if (!entries) continue;
900
+ for (const e of entries) {
901
+ if (e.family !== "IPv4" || e.internal) continue;
902
+ if (e.address !== srcInfo.localAddress) continue;
903
+ const addr = e.address.split(".").map((s) => Number(s));
904
+ const mask = e.netmask.split(".").map((s) => Number(s));
905
+ if (addr.length !== 4 || mask.length !== 4 || addr.some((n) => !Number.isFinite(n)) || mask.some((n) => !Number.isFinite(n)))
906
+ continue;
907
+ ownerSubnet = { addr, mask };
908
+ break;
909
+ }
910
+ if (ownerSubnet) break;
911
+ }
912
+ if (!ownerSubnet) return "unknown";
913
+ for (let i = 0; i < 4; i++) {
914
+ if ((ownerSubnet.addr[i] & ownerSubnet.mask[i]) !== (dest[i] & ownerSubnet.mask[i]))
915
+ return "mismatch";
916
+ }
917
+ return "same";
918
+ }
797
919
  var AckLatency = class {
798
920
  currentValues = [];
799
921
  lastReceiveTime = null;
@@ -872,6 +994,16 @@ function isUnroutableForP2P(ip) {
872
994
  var P2P_LOOKUP_PORT = 9999;
873
995
  var P2P_MAX_WAIT_MS = 15e3;
874
996
  var P2P_RESEND_WAIT_MS = 500;
997
+ var inflightP2pLookups = /* @__PURE__ */ new Map();
998
+ var cachedP2pLookups = /* @__PURE__ */ new Map();
999
+ var negCachedP2pLookups = /* @__PURE__ */ new Map();
1000
+ var P2P_LOOKUP_CACHE_TTL_MS = 3e4;
1001
+ var P2P_LOOKUP_NEG_CACHE_TTL_MS = 15e3;
1002
+ function _clearP2pLookupDedupForTests() {
1003
+ inflightP2pLookups.clear();
1004
+ cachedP2pLookups.clear();
1005
+ negCachedP2pLookups.clear();
1006
+ }
875
1007
  var BcUdpStream = class extends EventEmitter {
876
1008
  opts;
877
1009
  /**
@@ -960,31 +1092,14 @@ var BcUdpStream = class extends EventEmitter {
960
1092
  });
961
1093
  sock.on("error", (e) => this.emit("error", e));
962
1094
  sock.on("close", () => this.emit("close"));
963
- const portRange = Array.from({ length: 500 }, (_, i) => 53500 + i);
964
- for (let i = portRange.length - 1; i > 0; i--) {
965
- const j = Math.floor(Math.random() * (i + 1));
966
- [portRange[i], portRange[j]] = [portRange[j], portRange[i]];
967
- }
968
- let bound = false;
969
- for (const port of portRange) {
970
- try {
971
- await new Promise((resolve, reject) => {
972
- sock.once("error", reject);
973
- sock.bind(port, "0.0.0.0", () => {
974
- sock.removeListener("error", reject);
975
- resolve();
976
- });
977
- });
978
- bound = true;
979
- break;
980
- } catch {
981
- }
982
- }
983
- if (!bound) {
984
- await new Promise(
985
- (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
986
- );
987
- }
1095
+ await new Promise((resolve, reject) => {
1096
+ const onErr = (e) => reject(e);
1097
+ sock.once("error", onErr);
1098
+ sock.bind(0, "0.0.0.0", () => {
1099
+ sock.removeListener("error", onErr);
1100
+ resolve();
1101
+ });
1102
+ });
988
1103
  if (this.opts.mode === "direct") {
989
1104
  this.remote = { host: this.opts.host, port: this.opts.port };
990
1105
  this.clientId = this.opts.clientId;
@@ -1055,6 +1170,49 @@ var BcUdpStream = class extends EventEmitter {
1055
1170
  this.remote = { host: connected.rhost, port: connected.rport };
1056
1171
  }
1057
1172
  async p2pUidLookup(sock, uid) {
1173
+ const log = (msg) => this.discoveryLogger?.log?.(`[P2P] ${msg}`);
1174
+ const shortUid = uid.length > 7 ? `${uid.slice(0, 5)}\u2026${uid.slice(-2)}` : uid;
1175
+ const cached = cachedP2pLookups.get(uid);
1176
+ if (cached && cached.expires > Date.now()) {
1177
+ log(
1178
+ `UID=${shortUid} cached lookup hit (relay=${cached.result.relay.ip}:${cached.result.relay.port})`
1179
+ );
1180
+ return cached.result;
1181
+ }
1182
+ const negCached = negCachedP2pLookups.get(uid);
1183
+ if (negCached && negCached.expires > Date.now()) {
1184
+ const remaining = negCached.expires - Date.now();
1185
+ log(
1186
+ `UID=${shortUid} negative-cache hit (fail-fast, retry in ${Math.ceil(remaining / 1e3)}s)`
1187
+ );
1188
+ throw negCached.error;
1189
+ }
1190
+ const inflight = inflightP2pLookups.get(uid);
1191
+ if (inflight) {
1192
+ log(`UID=${shortUid} sharing in-flight lookup with concurrent race lane`);
1193
+ return await inflight;
1194
+ }
1195
+ const work = this._doP2pUidLookupWork(sock, uid);
1196
+ inflightP2pLookups.set(uid, work);
1197
+ try {
1198
+ const result = await work;
1199
+ cachedP2pLookups.set(uid, {
1200
+ result,
1201
+ expires: Date.now() + P2P_LOOKUP_CACHE_TTL_MS
1202
+ });
1203
+ return result;
1204
+ } catch (e) {
1205
+ const err = e instanceof Error ? e : new Error(String(e));
1206
+ negCachedP2pLookups.set(uid, {
1207
+ error: err,
1208
+ expires: Date.now() + P2P_LOOKUP_NEG_CACHE_TTL_MS
1209
+ });
1210
+ throw err;
1211
+ } finally {
1212
+ inflightP2pLookups.delete(uid);
1213
+ }
1214
+ }
1215
+ async _doP2pUidLookupWork(sock, uid) {
1058
1216
  const log = (msg) => this.discoveryLogger?.log?.(`[P2P] ${msg}`);
1059
1217
  const shortUid = uid.length > 7 ? `${uid.slice(0, 5)}\u2026${uid.slice(-2)}` : uid;
1060
1218
  const t0 = Date.now();
@@ -1522,6 +1680,23 @@ var BcUdpStream = class extends EventEmitter {
1522
1680
  log(
1523
1681
  `local discovery: mode=${localMode} uid=${shortUid} ports=[${ports.join(", ")}] broadcasts=[${broadcastHosts.join(", ")}]${directHost ? ` direct=${directHost}` : ""} localBindPort=${localPort} timeout=${discoveryTimeout}ms`
1524
1682
  );
1683
+ if (directHost && localMode === "local-direct") {
1684
+ try {
1685
+ const egress = await probeEgressForHost(directHost, ports[0] ?? 2015);
1686
+ const sameSubnet = isSameSubnetAsAnyLocalIface(directHost, egress);
1687
+ if (sameSubnet === "mismatch") {
1688
+ log(
1689
+ `WARN: kernel-chosen source IP ${egress.localAddress} is NOT in the same subnet as ${directHost}. Some Reolink battery cams silently drop discovery packets with off-subnet source IPs. If discovery fails, check your routing table \u2014 likely a Tailscale / VPN / secondary NIC stealing the default route.`
1690
+ );
1691
+ } else {
1692
+ log(
1693
+ `egress for ${directHost} \u2192 src=${egress.localAddress}` + (sameSubnet === "same" ? ` (same subnet \u2713)` : ` (subnet relationship unknown)`)
1694
+ );
1695
+ }
1696
+ } catch (e) {
1697
+ this.emit("debug", "egress_probe_failed", e);
1698
+ }
1699
+ }
1525
1700
  let bytesSent = 0;
1526
1701
  let pktsRecv = 0;
1527
1702
  sock.on("message", () => {
@@ -20517,7 +20692,7 @@ ${xml}`
20517
20692
  * @returns Test results for all stream types and profiles
20518
20693
  */
20519
20694
  async testChannelStreams(channel, logger) {
20520
- const { testChannelStreams } = await import("./DiagnosticsTools-K4MF2VXZ.js");
20695
+ const { testChannelStreams } = await import("./DiagnosticsTools-QJ3CRYGA.js");
20521
20696
  return await testChannelStreams({
20522
20697
  api: this,
20523
20698
  channel: this.normalizeChannel(channel),
@@ -20533,7 +20708,7 @@ ${xml}`
20533
20708
  * @returns Complete diagnostics for all channels and streams
20534
20709
  */
20535
20710
  async collectMultifocalDiagnostics(logger) {
20536
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-K4MF2VXZ.js");
20711
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-QJ3CRYGA.js");
20537
20712
  return await collectMultifocalDiagnostics({
20538
20713
  api: this,
20539
20714
  logger
@@ -25250,7 +25425,10 @@ export {
25250
25425
  encodeHeader,
25251
25426
  decodeHeader,
25252
25427
  BaichuanFrameParser,
25428
+ probeEgressForHost,
25429
+ isSameSubnetAsAnyLocalIface,
25253
25430
  isUnroutableForP2P,
25431
+ _clearP2pLookupDedupForTests,
25254
25432
  BcUdpStream,
25255
25433
  asLogger,
25256
25434
  createNullLogger,
@@ -25318,4 +25496,4 @@ export {
25318
25496
  tcpReachabilityProbe,
25319
25497
  autoDetectDeviceType
25320
25498
  };
25321
- //# sourceMappingURL=chunk-EAHRVNEX.js.map
25499
+ //# sourceMappingURL=chunk-Q4AXRV2G.js.map