signalk-edge-link 2.6.3 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,10 +72,48 @@ function createDebouncedConfigHandler(opts) {
72
72
  app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
73
73
  return;
74
74
  }
75
- const parsed = content ? JSON.parse(content) : readFallback;
76
- await processConfig(parsed);
77
- if (!state.stopped) {
78
- state.configContentHashes[name] = contentHash;
75
+ // Claim the hash BEFORE awaiting processConfig so a concurrent runLoad
76
+ // (e.g. the initial flush() racing an fs.watch event triggered during
77
+ // startup) sees the hash and skips instead of running processConfig
78
+ // twice. Without this, two processConfigs can both observe the same
79
+ // pre-claim hash and both call subscribe(), leaving leaked listeners
80
+ // for any path whose bus is created between the new subscribe() and
81
+ // the old previousUnsubscribes.forEach() in the second call.
82
+ const previousHash = state.configContentHashes[name];
83
+ function revertHashClaim() {
84
+ if (state.configContentHashes[name] !== contentHash)
85
+ return;
86
+ if (previousHash === undefined) {
87
+ delete state.configContentHashes[name];
88
+ }
89
+ else {
90
+ state.configContentHashes[name] = previousHash;
91
+ }
92
+ }
93
+ state.configContentHashes[name] = contentHash;
94
+ let parsed;
95
+ try {
96
+ parsed = content ? JSON.parse(content) : readFallback;
97
+ }
98
+ catch (parseErr) {
99
+ // Parse failure leaves nothing valid to process. Revert the claim so
100
+ // a subsequent file event (presumably with corrected content) is not
101
+ // silently skipped on the unchanged-hash check.
102
+ revertHashClaim();
103
+ throw parseErr;
104
+ }
105
+ try {
106
+ await processConfig(parsed);
107
+ }
108
+ catch (err) {
109
+ revertHashClaim();
110
+ throw err;
111
+ }
112
+ // If stop() ran between claim and the processConfig microtask, treat
113
+ // the hash as unclaimed so a fresh start() will reload from disk
114
+ // instead of inheriting our claim (the in-memory state is gone).
115
+ if (state.stopped) {
116
+ revertHashClaim();
79
117
  }
80
118
  }
81
119
  const handleChange = function () {
package/lib/instance.js CHANGED
@@ -38,6 +38,11 @@ const values_snapshot_1 = require("./values-snapshot");
38
38
  const DELTA_SEND_MAX_RETRIES = 1;
39
39
  const DELTA_SEND_RETRY_BACKOFF_MS = 100;
40
40
  const SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
41
+ // Signal K's subscription manager can deliver the same cached/live delta pair
42
+ // about one fixed-policy window apart. Exact JSON equality keeps this narrow:
43
+ // a fresh timestamp or changed value still forwards normally.
44
+ const OUTBOUND_DUPLICATE_SUPPRESS_MS = 1500;
45
+ const SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = 50;
41
46
  // ── Helpers ──────────────────────────────────────────────────────────────────
42
47
  /**
43
48
  * Derive a URL-safe identifier from a human-readable name.
@@ -71,7 +76,13 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
71
76
  socketUdp: null,
72
77
  readyToSend: false,
73
78
  stopped: false,
74
- isServerMode: false,
79
+ // Initialise from options so isServerMode() returns the correct value
80
+ // BEFORE start() runs. index.ts filters instances into server and client
81
+ // groups (servers start first) using inst.isServerMode(); if that read
82
+ // happens before start() has had a chance to set state.isServerMode, the
83
+ // server group ends up empty and every instance starts concurrently in
84
+ // the client group, defeating the intended sequencing.
85
+ isServerMode: options.serverType === true || options.serverType === "server",
75
86
  deltas: [],
76
87
  timer: false,
77
88
  batchSendInFlight: false,
@@ -95,6 +106,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
95
106
  pingMonitor: null,
96
107
  deltaTimer: null,
97
108
  subscriptionRetryTimer: null,
109
+ subscribing: false,
98
110
  pipeline: null,
99
111
  pipelineServer: null,
100
112
  heartbeatHandle: null,
@@ -115,6 +127,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
115
127
  };
116
128
  const metricsApi = (0, metrics_1.default)();
117
129
  const { metrics, recordError, resetMetrics } = metricsApi;
130
+ const recentOutboundDeltas = new Map();
131
+ let lastOutboundDuplicateLogAt = 0;
132
+ let activeSubscriptionGeneration = 0;
118
133
  let v1Pipeline = null;
119
134
  function getV1Pipeline() {
120
135
  if (!v1Pipeline) {
@@ -484,6 +499,51 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
484
499
  function parseMetaConfig(raw) {
485
500
  return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
486
501
  }
502
+ function normalizeSubscriptionConfig(config) {
503
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
504
+ return config;
505
+ }
506
+ const record = config;
507
+ if (!Array.isArray(record.subscribe)) {
508
+ return config;
509
+ }
510
+ const rows = record.subscribe;
511
+ const wildcardRow = rows.find((row) => row &&
512
+ typeof row === "object" &&
513
+ !Array.isArray(row) &&
514
+ row.path === "*");
515
+ if (wildcardRow) {
516
+ if (rows.length > 1) {
517
+ app.debug(`[${instanceId}] Subscription contains path="*"; ignoring ${rows.length - 1} overlapping row(s)`);
518
+ }
519
+ return { ...record, subscribe: [wildcardRow] };
520
+ }
521
+ const seenPaths = new Set();
522
+ const deduped = [];
523
+ let dropped = 0;
524
+ for (const row of rows) {
525
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
526
+ deduped.push(row);
527
+ continue;
528
+ }
529
+ const path = row.path;
530
+ if (typeof path !== "string") {
531
+ deduped.push(row);
532
+ continue;
533
+ }
534
+ if (seenPaths.has(path)) {
535
+ dropped++;
536
+ continue;
537
+ }
538
+ seenPaths.add(path);
539
+ deduped.push(row);
540
+ }
541
+ if (dropped > 0) {
542
+ app.debug(`[${instanceId}] Removed ${dropped} duplicate subscription row(s)`);
543
+ return { ...record, subscribe: deduped };
544
+ }
545
+ return config;
546
+ }
487
547
  /**
488
548
  * Processes an incoming delta from the subscription manager.
489
549
  * Buffers and dispatches deltas to the send pipeline.
@@ -559,6 +619,17 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
559
619
  if (!state.readyToSend) {
560
620
  return;
561
621
  }
622
+ // Drop signalk-server's synchronous cache replay. handleSubscribeRow
623
+ // calls `latest.forEach(callback)` after registering the live listener,
624
+ // bypassing the bufferWithTime+uniqBy dedupe — so each path with a
625
+ // cached value at subscribe time is delivered to us twice (once direct,
626
+ // once through the live pipeline). `replayValuesSnapshot("initial
627
+ // subscribe")` runs immediately after subscribe() returns and walks
628
+ // the SK tree explicitly to ship initial state downstream, so dropping
629
+ // the direct replay does not cost us any data.
630
+ if (state.subscribing) {
631
+ return;
632
+ }
562
633
  // Capture live meta BEFORE the delta flows into the pipeline encoder,
563
634
  // because pathDictionary.transformDelta will strip `updates[].meta[]` when
564
635
  // rebuilding the update objects. `extractLiveMeta` returns [] when meta
@@ -573,6 +644,24 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
573
644
  if (!outboundDelta) {
574
645
  return;
575
646
  }
647
+ const now = Date.now();
648
+ const dedupeKey = JSON.stringify(outboundDelta);
649
+ const lastSeenAt = recentOutboundDeltas.get(dedupeKey);
650
+ if (lastSeenAt !== undefined && now - lastSeenAt <= OUTBOUND_DUPLICATE_SUPPRESS_MS) {
651
+ metrics.suppressedOutboundDuplicates = (metrics.suppressedOutboundDuplicates || 0) + 1;
652
+ recordSuppressedDuplicateStats(outboundDelta, now);
653
+ if (now - lastOutboundDuplicateLogAt >= 1000) {
654
+ lastOutboundDuplicateLogAt = now;
655
+ app.debug(`[${instanceId}] Suppressed duplicate outbound delta (${summarizeDeltaForLog(outboundDelta)})`);
656
+ }
657
+ return;
658
+ }
659
+ recentOutboundDeltas.set(dedupeKey, now);
660
+ for (const [key, seenAt] of recentOutboundDeltas) {
661
+ if (now - seenAt > OUTBOUND_DUPLICATE_SUPPRESS_MS) {
662
+ recentOutboundDeltas.delete(key);
663
+ }
664
+ }
576
665
  if (state.deltas.length >= constants_1.MAX_DELTAS_BUFFER_SIZE) {
577
666
  const dropCount = Math.floor(constants_1.MAX_DELTAS_BUFFER_SIZE * constants_1.DELTA_BUFFER_DROP_RATIO);
578
667
  state.deltas.splice(0, dropCount);
@@ -602,6 +691,14 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
602
691
  }
603
692
  }
604
693
  state.processDelta = processDelta;
694
+ function createSubscriptionDeltaHandler(subscriptionGeneration) {
695
+ return (delta) => {
696
+ if (subscriptionGeneration !== activeSubscriptionGeneration) {
697
+ return;
698
+ }
699
+ processDelta(delta);
700
+ };
701
+ }
605
702
  const SUBSCRIPTION_RETRY_BASE_DELAY = 5000;
606
703
  const SUBSCRIPTION_RETRY_MAX_DELAY = 300000;
607
704
  const SUBSCRIPTION_RETRY_MAX_ATTEMPTS = 10;
@@ -637,13 +734,27 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
637
734
  return;
638
735
  }
639
736
  app.debug(`[${instanceId}] Retrying subscription (attempt ${attempt})...`);
737
+ // Tear down any partial listeners left behind by a previous failed
738
+ // subscribe attempt before adding new ones — same reason as the main
739
+ // handleSubscriptionChange path: keeping stale partial listeners in
740
+ // state.unsubscribes alongside a fresh subscribe() causes them to fire
741
+ // for every push, doubling processDelta delivery for affected paths.
742
+ const partialUnsubscribes = state.unsubscribes.splice(0);
743
+ partialUnsubscribes.forEach((f) => f());
640
744
  try {
641
- app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (retrySubError) => {
642
- app.error(`[${instanceId}] Subscription error (attempt ${attempt}): ${retrySubError}`);
643
- state.readyToSend = false;
644
- _setStatus("Subscription error - data transmission paused", false);
645
- recordError("subscription", `Subscription error: ${retrySubError}`);
646
- }, processDelta);
745
+ const subscriptionGeneration = ++activeSubscriptionGeneration;
746
+ state.subscribing = true;
747
+ try {
748
+ app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (retrySubError) => {
749
+ app.error(`[${instanceId}] Subscription error (attempt ${attempt}): ${retrySubError}`);
750
+ state.readyToSend = false;
751
+ _setStatus("Subscription error - data transmission paused", false);
752
+ recordError("subscription", `Subscription error: ${retrySubError}`);
753
+ }, createSubscriptionDeltaHandler(subscriptionGeneration));
754
+ }
755
+ finally {
756
+ state.subscribing = false;
757
+ }
647
758
  // Retry succeeded — perform the staged commit that the original
648
759
  // processConfig catch block skipped. Without this, the operator's
649
760
  // new meta block (stashed on state.pendingMetaConfig) would remain
@@ -676,30 +787,46 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
676
787
  name: "Subscription",
677
788
  getFilePath: () => state.subscriptionFile,
678
789
  processConfig: (config) => {
679
- state.localSubscription = config;
790
+ state.localSubscription = normalizeSubscriptionConfig(config);
680
791
  app.debug(`[${instanceId}] Subscription configuration updated`);
681
792
  // Stage the new metadata config — do NOT yet touch state.metaConfig,
682
793
  // the periodic timer, or metaCache. If subscribe() throws, the old
683
794
  // subscription remains active until the retry succeeds, so its
684
795
  // previous metadata behaviour must remain intact.
685
796
  const previousMetaConfig = state.metaConfig;
686
- const pendingMetaConfig = parseMetaConfig(config);
687
- // Capture the old cleanup handlers but do NOT call them yet.
688
- // We establish the new subscription first so data keeps flowing during
689
- // the handover; only after success do we release the old subscription.
690
- // If the new subscribe() throws, we restore the old handlers so that
691
- // stop() can still clean up and the old subscription remains active
692
- // until the scheduled retry succeeds.
797
+ const pendingMetaConfig = parseMetaConfig(state.localSubscription);
798
+ // Tear down the old subscription FIRST, then establish the new one.
799
+ // The previous "subscribe-then-unsubscribe" ordering tried to avoid
800
+ // dropping any delta during the handover, but it leaves a window
801
+ // where BOTH the old and new subscriptions are simultaneously
802
+ // attached to every per-path bus in signalk-server's
803
+ // `streambundle.buses`. Any push that lands in that window — or any
804
+ // listener that the new subscribe() registers asynchronously via
805
+ // streambundle.keys.onValue for a path whose bus is created during
806
+ // the window — fires both callbacks, doubling processDelta delivery
807
+ // for the rest of the process lifetime.
808
+ //
809
+ // Replaying via `replayValuesSnapshot("initial subscribe")` below
810
+ // recovers any value that was already in the SK tree, and any
811
+ // genuinely live delta that lands in the brief teardown→subscribe
812
+ // gap will be re-emitted by its publisher within the subscription's
813
+ // throttle period.
693
814
  const previousUnsubscribes = state.unsubscribes.splice(0);
815
+ previousUnsubscribes.forEach((f) => f());
694
816
  try {
695
- app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (subscriptionError) => {
696
- app.error(`[${instanceId}] Subscription error: ${subscriptionError}`);
697
- state.readyToSend = false;
698
- _setStatus("Subscription error - data transmission paused", false);
699
- recordError("subscription", `Subscription error: ${subscriptionError}`);
700
- }, processDelta);
701
- // New subscription established release old cleanup handlers.
702
- previousUnsubscribes.forEach((f) => f());
817
+ const subscriptionGeneration = ++activeSubscriptionGeneration;
818
+ state.subscribing = true;
819
+ try {
820
+ app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (subscriptionError) => {
821
+ app.error(`[${instanceId}] Subscription error: ${subscriptionError}`);
822
+ state.readyToSend = false;
823
+ _setStatus("Subscription error - data transmission paused", false);
824
+ recordError("subscription", `Subscription error: ${subscriptionError}`);
825
+ }, createSubscriptionDeltaHandler(subscriptionGeneration));
826
+ }
827
+ finally {
828
+ state.subscribing = false;
829
+ }
703
830
  // Commit the new metadata config AFTER a successful subscribe: swap
704
831
  // state.metaConfig, (re)start the periodic timer, and reset the diff
705
832
  // cache so the next snapshot represents the live state in full. We
@@ -721,12 +848,19 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
721
848
  replayValuesSnapshot("initial subscribe");
722
849
  }
723
850
  catch (subscribeError) {
724
- // Re-subscribe failed restore old handlers so stop() can still
725
- // clean up and the previous subscription remains active until retry.
851
+ // Re-subscribe failed. The old subscription was already torn down
852
+ // before we attempted the new subscribe(), so we cannot restore it —
853
+ // any partial subscriptions registered by the failed subscribe() are
854
+ // already in state.unsubscribes and stop() can clean them up.
855
+ // The retry path (scheduleSubscriptionRetry) will attempt a fresh
856
+ // subscribe() against state.unsubscribes; if any partial listeners
857
+ // exist they get added to alongside, but that's no worse than the
858
+ // pre-fix behaviour and avoids the more serious 2× delivery race.
726
859
  // Leave state.metaConfig / metaCache / metaTimer untouched so the
727
- // previous subscription's metadata stream keeps running unchanged.
728
- state.unsubscribes = previousUnsubscribes;
860
+ // previous subscription's metadata behaviour rules are preserved
861
+ // pending retry.
729
862
  void previousMetaConfig; // explicit: intentionally unchanged
863
+ void previousUnsubscribes; // intentionally not restored — see above
730
864
  // Stash the new meta config on state so the scheduled retry can
731
865
  // promote it when subscribe() finally succeeds. Otherwise the
732
866
  // operator's new meta settings would silently sit unused until the
@@ -767,7 +901,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
767
901
  app
768
902
  });
769
903
  // ── File-system watchers (delegated to config-watcher module) ────────────
770
- function setupConfigWatchers() {
904
+ async function setupConfigWatchers() {
771
905
  try {
772
906
  const watcherConfigs = [
773
907
  { filePath: state.deltaTimerFile, onChange: handleDeltaTimerChange, name: "Delta timer" },
@@ -789,10 +923,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
789
923
  // produced by co-located plugins are emitted before our subscription
790
924
  // is registered with the subscriptionmanager — those deltas would be
791
925
  // silently dropped since the manager only delivers future events.
792
- handleSubscriptionChange.flush().catch((err) => {
793
- const msg = err instanceof Error ? err.message : String(err);
794
- app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
795
- });
926
+ await handleSubscriptionChange.flush();
796
927
  app.debug(`[${instanceId}] Configuration file watchers initialized`);
797
928
  }
798
929
  catch (err) {
@@ -1067,7 +1198,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1067
1198
  }
1068
1199
  state.socketUdp.on("error", handleClientSocketError);
1069
1200
  scheduleDeltaTimer();
1070
- setupConfigWatchers();
1071
1201
  // Ping / connectivity monitor (v1 only, RTT measurement)
1072
1202
  if ((options.protocolVersion ?? 0) < 2) {
1073
1203
  state.pingMonitor = new ping_monitor_1.default({
@@ -1133,11 +1263,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1133
1263
  const msg = err instanceof Error ? err.message : String(err);
1134
1264
  app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
1135
1265
  });
1136
- // The initial-subscribe replayValuesSnapshot fires before readyToSend
1137
- // is true (pipeline not yet created) and silently returns early. Replay
1138
- // now so data already in the SK tree — including values injected by a
1139
- // co-located server-mode instance — is forwarded on first connect.
1140
- replayValuesSnapshot("initial connect");
1141
1266
  state.socketUdp.on("message", (msg, rinfo) => {
1142
1267
  v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
1143
1268
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1181,7 +1306,66 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1181
1306
  }
1182
1307
  app.debug(`[${instanceId}] [v1] Client pipeline initialized`);
1183
1308
  }
1309
+ // Wire the Signal K subscription after the client send pipeline is ready.
1310
+ // The subscription handler performs one explicit values-snapshot replay;
1311
+ // doing it here prevents the v2 startup path from also sending a second
1312
+ // "initial connect" snapshot.
1313
+ await setupConfigWatchers();
1314
+ }
1315
+ }
1316
+ function summarizeDeltaForLog(delta) {
1317
+ const fields = getDeltaSummaryFields(delta);
1318
+ return `context=${fields.context}, path=${fields.path}, source=${fields.source}, timestamp=${fields.timestamp}, updates=${fields.updateCount}, values=${fields.valueCount}, suppressed=${metrics.suppressedOutboundDuplicates || 0}`;
1319
+ }
1320
+ function getDeltaSummaryFields(delta) {
1321
+ const update = Array.isArray(delta.updates) ? delta.updates[0] : null;
1322
+ const value = Array.isArray(update?.values) ? update.values[0] : null;
1323
+ const context = delta.context || "?";
1324
+ const path = value?.path || "?";
1325
+ const source = update?.$source || update?.source?.label || "?";
1326
+ const timestamp = update?.timestamp || "?";
1327
+ const updateCount = Array.isArray(delta.updates) ? delta.updates.length : 0;
1328
+ const valueCount = Array.isArray(delta.updates)
1329
+ ? delta.updates.reduce((sum, item) => sum + (Array.isArray(item.values) ? item.values.length : 0), 0)
1330
+ : 0;
1331
+ return { context, path, source, timestamp, updateCount, valueCount };
1332
+ }
1333
+ function recordSuppressedDuplicateStats(delta, now) {
1334
+ if (!metrics.suppressedOutboundDuplicateStats) {
1335
+ metrics.suppressedOutboundDuplicateStats = new Map();
1336
+ }
1337
+ const fields = getDeltaSummaryFields(delta);
1338
+ const key = JSON.stringify({
1339
+ context: fields.context,
1340
+ path: fields.path,
1341
+ source: fields.source
1342
+ });
1343
+ const existing = metrics.suppressedOutboundDuplicateStats.get(key);
1344
+ if (existing) {
1345
+ existing.count++;
1346
+ existing.lastUpdate = now;
1347
+ return;
1348
+ }
1349
+ if (metrics.suppressedOutboundDuplicateStats.size >= SUPPRESSED_DUPLICATE_STATS_MAX_SIZE) {
1350
+ let stalestKey = null;
1351
+ let stalestTime = Infinity;
1352
+ for (const [candidateKey, item] of metrics.suppressedOutboundDuplicateStats) {
1353
+ if (item.lastUpdate < stalestTime) {
1354
+ stalestKey = candidateKey;
1355
+ stalestTime = item.lastUpdate;
1356
+ }
1357
+ }
1358
+ if (stalestKey) {
1359
+ metrics.suppressedOutboundDuplicateStats.delete(stalestKey);
1360
+ }
1184
1361
  }
1362
+ metrics.suppressedOutboundDuplicateStats.set(key, {
1363
+ context: fields.context,
1364
+ path: fields.path,
1365
+ source: fields.source,
1366
+ count: 1,
1367
+ lastUpdate: now
1368
+ });
1185
1369
  }
1186
1370
  function stop() {
1187
1371
  // If a batch send is in progress, log the warning. We cannot reliably
@@ -1198,8 +1382,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1198
1382
  state.unsubscribes.forEach((f) => f());
1199
1383
  state.unsubscribes = [];
1200
1384
  state.localSubscription = null;
1385
+ activeSubscriptionGeneration++;
1201
1386
  // Reset runtime state
1202
1387
  state.deltas = [];
1388
+ recentOutboundDeltas.clear();
1203
1389
  state.timer = false;
1204
1390
  state.batchSendInFlight = false;
1205
1391
  state.socketRecoveryInProgress = false;
package/lib/metrics.js CHANGED
@@ -36,6 +36,8 @@ function createMetrics() {
36
36
  rateLimitedPackets: 0,
37
37
  droppedDeltaBatches: 0,
38
38
  droppedDeltaCount: 0,
39
+ suppressedOutboundDuplicates: 0,
40
+ suppressedOutboundDuplicateStats: new Map(),
39
41
  remoteNetworkQuality: {
40
42
  rtt: 0,
41
43
  jitter: 0,
@@ -156,7 +158,9 @@ function createMetrics() {
156
158
  dataPacketsReceived: 0,
157
159
  rateLimitedPackets: 0,
158
160
  droppedDeltaBatches: 0,
159
- droppedDeltaCount: 0
161
+ droppedDeltaCount: 0,
162
+ suppressedOutboundDuplicates: 0,
163
+ suppressedOutboundDuplicateStats: new Map()
160
164
  });
161
165
  Object.assign(metrics.bandwidth, {
162
166
  bytesOut: 0,
package/lib/prometheus.js CHANGED
@@ -47,6 +47,19 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
47
47
  counter("rate_limited_packets_total", "Total packets dropped by rate limiting", metrics.rateLimitedPackets || 0);
48
48
  counter("dropped_delta_batches_total", "Total delta batches dropped before send", metrics.droppedDeltaBatches || 0);
49
49
  counter("dropped_deltas_total", "Total deltas dropped before send", metrics.droppedDeltaCount || 0);
50
+ counter("suppressed_outbound_duplicates_total", "Total exact duplicate outbound deltas suppressed before send", metrics.suppressedOutboundDuplicates || 0);
51
+ if (metrics.suppressedOutboundDuplicateStats) {
52
+ const duplicateStats = Array.from(metrics.suppressedOutboundDuplicateStats.values())
53
+ .sort((a, b) => b.count - a.count)
54
+ .slice(0, 20);
55
+ for (const item of duplicateStats) {
56
+ counter("suppressed_outbound_duplicates_by_path_total", "Total exact duplicate outbound deltas suppressed before send, grouped by context/path/source", item.count, {
57
+ context: item.context,
58
+ path: item.path,
59
+ source: item.source
60
+ });
61
+ }
62
+ }
50
63
  // Error counters
51
64
  counter("udp_send_errors_total", "Total UDP send errors", metrics.udpSendErrors);
52
65
  counter("udp_retries_total", "Total UDP send retries", metrics.udpRetries);
package/lib/routes.js CHANGED
@@ -462,6 +462,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
462
462
  rateLimitedPackets: metrics.rateLimitedPackets || 0,
463
463
  droppedDeltaBatches: metrics.droppedDeltaBatches || 0,
464
464
  droppedDeltaCount: metrics.droppedDeltaCount || 0,
465
+ suppressedOutboundDuplicates: metrics.suppressedOutboundDuplicates || 0,
465
466
  errorCounts: { ...(metrics.errorCounts || {}) }
466
467
  },
467
468
  status: {
@@ -5,94 +5,159 @@ exports.handleMessageBySource = handleMessageBySource;
5
5
  function isRecord(value) {
6
6
  return value !== null && typeof value === "object" && !Array.isArray(value);
7
7
  }
8
- function getSourceLabel(update) {
9
- const source = isRecord(update.source) ? update.source : null;
10
- const label = source && typeof source.label === "string" ? source.label.trim() : "";
11
- return label.length > 0 ? label : "";
8
+ function trimmedString(value) {
9
+ return typeof value === "string" ? value.trim() : "";
12
10
  }
13
- function hasStaleEdgeLinkSourceRef(update) {
14
- const sourceLabel = getSourceLabel(update);
15
- const sourceRef = typeof update.$source === "string" ? update.$source.trim() : "";
16
- if (!sourceLabel || !sourceRef || sourceLabel === "signalk-edge-link") {
17
- return false;
18
- }
11
+ function isStaleEdgeLinkRef(sourceRef) {
19
12
  return (sourceRef === "signalk-edge-link" ||
20
13
  sourceRef.startsWith("signalk-edge-link.") ||
21
14
  sourceRef.startsWith("signalk-edge-link:"));
22
15
  }
23
- function normalizeUpdateSourceRef(update) {
24
- if (!hasStaleEdgeLinkSourceRef(update)) {
25
- return update;
16
+ /**
17
+ * Mirror of signalk-schema's `getSourceId` for use when the incoming update
18
+ * lacks a `$source` string and we have to synthesize one from the structured
19
+ * `source` object. Kept locally so we don't introduce a runtime dependency
20
+ * on signalk-schema just for this one function.
21
+ *
22
+ * Notably: the schema's fallback for a labelled-but-otherwise-empty source
23
+ * is `${label}.XX` (literal "XX"). We deliberately do NOT reproduce that
24
+ * fallback here — emitting a bare label keeps the receiver's $source key
25
+ * identical to what a single-source publisher would have stored.
26
+ */
27
+ function deriveSourceRefFromObject(source) {
28
+ const label = trimmedString(source.label);
29
+ if (!label) {
30
+ return "";
31
+ }
32
+ const canName = trimmedString(source.canName);
33
+ if (canName) {
34
+ return `${label}.${canName}`;
35
+ }
36
+ const src = source.src === undefined || source.src === null ? "" : String(source.src).trim();
37
+ if (src) {
38
+ return `${label}.${src}`;
39
+ }
40
+ const talker = trimmedString(source.talker);
41
+ if (talker) {
42
+ return `${label}.${talker}`;
43
+ }
44
+ return label;
45
+ }
46
+ /**
47
+ * Resolve the canonical `$source` string for an update, preferring the
48
+ * incoming `$source` field over a derived value from the structured source
49
+ * object. A stale `signalk-edge-link.*` explicit ref is only replaced when
50
+ * the derived ref is genuinely fresh — otherwise we'd be swapping one stale
51
+ * attribution for another (e.g. when `source.label` is itself
52
+ * `"signalk-edge-link"` on a relayed update), which would collapse keys and
53
+ * misroute downstream subscribers.
54
+ */
55
+ function resolveSourceRef(update) {
56
+ const explicit = trimmedString(update.$source);
57
+ const sourceObj = isRecord(update.source) ? update.source : null;
58
+ const derived = sourceObj ? deriveSourceRefFromObject(sourceObj) : "";
59
+ if (explicit) {
60
+ if (!isStaleEdgeLinkRef(explicit)) {
61
+ return explicit;
62
+ }
63
+ // Explicit is stale; only swap to derived if derived is genuinely fresh.
64
+ if (!derived || isStaleEdgeLinkRef(derived)) {
65
+ return explicit;
66
+ }
26
67
  }
27
- const cloned = { ...update };
28
- delete cloned.$source;
29
- return cloned;
68
+ return derived;
30
69
  }
31
- function cloneUpdate(update) {
32
- const normalized = normalizeUpdateSourceRef(update);
33
- const cloned = {
34
- ...normalized,
35
- source: isRecord(normalized.source)
36
- ? { ...normalized.source }
37
- : normalized.source,
38
- values: Array.isArray(normalized.values)
39
- ? normalized.values.map((value) => ({ ...value }))
40
- : normalized.values,
41
- meta: Array.isArray(normalized.meta)
42
- ? normalized.meta.map((entry) => ({ ...entry }))
43
- : normalized.meta
70
+ /**
71
+ * Build the update object actually handed to `app.handleMessage`.
72
+ *
73
+ * Critically, the structured `source` object is dropped — signalk-server's
74
+ * `FullSignalK.addValue` unconditionally recomputes the leaf's `$source` via
75
+ * `getSourceId(source)` whenever a structured source is present, which in turn
76
+ * uses the providerId-rewritten `source.label` and the hardcoded `.XX` fallback
77
+ * from signalk-schema. Passing the canonical `$source` as a string instead
78
+ * (via the `update.source || update.$source` short-circuit in addUpdate)
79
+ * makes addValue store the leaf under our chosen key verbatim, so a leaf
80
+ * published on the boat as `$source = "bedroom"` lands on every downstream
81
+ * node as `$source = "bedroom"` too.
82
+ *
83
+ * Side effect: per-leaf `pgn` / `sentence` metadata that signalk-schema's
84
+ * `setMessage` would attach from the source object is no longer applied at
85
+ * the receiver — that metadata still rides across the link via the source
86
+ * snapshot envelope (`sendSourceSnapshot` / `mergeSourceSnapshot`) and is
87
+ * available under `/signalk/v1/api/sources`.
88
+ */
89
+ function prepareUpdateForDispatch(update) {
90
+ const sourceRef = resolveSourceRef(update);
91
+ const prepared = {
92
+ values: Array.isArray(update.values)
93
+ ? update.values.map((value) => ({ ...value }))
94
+ : update.values
44
95
  };
45
- return cloned;
96
+ if (update.timestamp !== undefined) {
97
+ prepared.timestamp = update.timestamp;
98
+ }
99
+ if (Array.isArray(update.meta)) {
100
+ prepared.meta = update.meta.map((entry) => ({ ...entry }));
101
+ }
102
+ if (sourceRef) {
103
+ prepared.$source = sourceRef;
104
+ }
105
+ return prepared;
46
106
  }
107
+ /**
108
+ * Strip stale `signalk-edge-link.*` `$source` values from a delta in place
109
+ * (well, by returning a cloned delta when needed). Kept for callers that need
110
+ * to massage a delta without going through full dispatch — currently used by
111
+ * the v2 server pipeline before `_ingestRemoteTelemetry`.
112
+ *
113
+ * After the resolve-source-ref rewrite this is largely a no-op for the
114
+ * dispatch path itself: `resolveSourceRef` already prefers a fresh structured
115
+ * source over a stale `signalk-edge-link.*` `$source`. It remains useful so
116
+ * downstream consumers (e.g. source-replication metrics) see the same
117
+ * normalised `$source` the receiver would store.
118
+ */
47
119
  function normalizeDeltaSourceRefs(delta) {
48
120
  if (!delta || !Array.isArray(delta.updates)) {
49
121
  return delta;
50
122
  }
51
123
  let changed = false;
52
124
  const updates = delta.updates.map((update) => {
53
- const normalized = normalizeUpdateSourceRef(update);
54
- if (normalized !== update) {
55
- changed = true;
125
+ const sourceRef = trimmedString(update.$source);
126
+ if (!sourceRef || !isStaleEdgeLinkRef(sourceRef)) {
127
+ return update;
128
+ }
129
+ const sourceObj = isRecord(update.source) ? update.source : null;
130
+ const sourceLabel = trimmedString(sourceObj?.label);
131
+ // Only strip when we have a real (non-edge-link) structured source the
132
+ // receiver can fall back to. Otherwise keep the stale $source so the
133
+ // value still has *some* attribution downstream. Use prefix-aware
134
+ // staleness detection so a label like `"signalk-edge-link:<instanceId>"`
135
+ // is treated the same as the bare `"signalk-edge-link"`.
136
+ if (!sourceLabel || isStaleEdgeLinkRef(sourceLabel)) {
137
+ return update;
56
138
  }
57
- return normalized;
139
+ changed = true;
140
+ const cloned = { ...update };
141
+ delete cloned.$source;
142
+ return cloned;
58
143
  });
59
144
  return changed ? { ...delta, updates } : delta;
60
145
  }
61
146
  /**
62
- * Signal K's app.handleMessage(providerId, delta) rewrites update.source.label
63
- * to providerId before applying the delta. Remote updates can contain several
64
- * original source labels, so dispatch them under their original label. Stale
65
- * edge-link `$source` values are removed separately before dispatch so Signal K
66
- * can recompute them from the structured source object.
147
+ * Hand a delta to `app.handleMessage` with `$source` preserved end-to-end.
148
+ *
149
+ * signalk-server's plugin handleMessage wrapper substitutes the calling
150
+ * plugin's id for whatever providerId argument we pass, so dispatching one
151
+ * call per source label is pointless we always end up with providerId =
152
+ * `"signalk-edge-link"` anyway. The important work is in
153
+ * `prepareUpdateForDispatch`, which drops the structured `source` object so
154
+ * `FullSignalK.addValue` doesn't recompute the leaf's `$source` from the
155
+ * rewritten label.
67
156
  */
68
157
  function handleMessageBySource(app, delta) {
69
158
  if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
70
159
  return;
71
160
  }
72
- const grouped = new Map();
73
- let hasOriginalSourceLabel = false;
74
- for (const update of delta.updates) {
75
- const sourceLabel = getSourceLabel(update);
76
- if (sourceLabel) {
77
- hasOriginalSourceLabel = true;
78
- }
79
- const providerId = sourceLabel || "";
80
- const updates = grouped.get(providerId);
81
- if (updates) {
82
- updates.push(update);
83
- }
84
- else {
85
- grouped.set(providerId, [update]);
86
- }
87
- }
88
- if (!hasOriginalSourceLabel) {
89
- app.handleMessage("", delta);
90
- return;
91
- }
92
- for (const [providerId, updates] of grouped) {
93
- app.handleMessage(providerId, {
94
- ...delta,
95
- updates: updates.map(cloneUpdate)
96
- });
97
- }
161
+ const prepared = delta.updates.map(prepareUpdateForDispatch);
162
+ app.handleMessage("", { ...delta, updates: prepared });
98
163
  }
@@ -33,11 +33,28 @@ function walkValues(node, pathParts, onLeaf) {
33
33
  if (!isRecord(node)) {
34
34
  return;
35
35
  }
36
- // Multi-source case wins when present: `values` is { sourceLabel:
37
- // { value, timestamp } } and is more authoritative than the top-level
38
- // `value`/`timestamp` (which mirror the latest of the multi-source map).
39
- // Emit one leaf per source so the receiver retains attribution, then stop
40
- // the rest of the node is per-source bookkeeping.
36
+ // Prefer the top-level single-source view: it reflects the *current*
37
+ // writer, which is what live subscription deltas also carry. The
38
+ // multi-source `values: { }` map is signalk-server's append-only
39
+ // history bookkeeping entries created by sources that have since
40
+ // stopped writing (different N2K source address, a previous edge-link
41
+ // version, a one-shot delta from another local provider) stay there
42
+ // for the life of the signalk-server process with no TTL. Walking
43
+ // that map would ship every historical `$source` to the receiver,
44
+ // where they become ghost entries that never refresh because no live
45
+ // writer is producing them anymore.
46
+ const single = readLeafFromNode(node);
47
+ if (single !== null) {
48
+ onLeaf({
49
+ path: pathParts.join("."),
50
+ value: single.value,
51
+ timestamp: single.timestamp,
52
+ source: single.source
53
+ });
54
+ return;
55
+ }
56
+ // No top-level leaf — fall back to the multi-source map for older
57
+ // signalk-server versions or partial trees that only populated `values`.
41
58
  if (isRecord(node.values)) {
42
59
  for (const [sourceLabel, sourceData] of Object.entries(node.values)) {
43
60
  if (isRecord(sourceData) &&
@@ -53,17 +70,6 @@ function walkValues(node, pathParts, onLeaf) {
53
70
  }
54
71
  return;
55
72
  }
56
- // Single-source leaf.
57
- const single = readLeafFromNode(node);
58
- if (single !== null) {
59
- onLeaf({
60
- path: pathParts.join("."),
61
- value: single.value,
62
- timestamp: single.timestamp,
63
- source: single.source
64
- });
65
- return;
66
- }
67
73
  // Container — descend.
68
74
  for (const key of Object.keys(node)) {
69
75
  if (SK_LEAF_KEYS.has(key)) {
package/package.json CHANGED
@@ -1,165 +1,165 @@
1
- {
2
- "name": "signalk-edge-link",
3
- "version": "2.6.3",
4
- "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
- "main": "lib/index.js",
6
- "files": [
7
- "lib/",
8
- "public/"
9
- ],
10
- "keywords": [
11
- "signalk-node-server-plugin",
12
- "signalk-category-network",
13
- "signalk-webapp",
14
- "signalk-category-utility",
15
- "signalk-plugin-configurator"
16
- ],
17
- "signalk": {
18
- "appIcon": "./icons/icon-72x72.png",
19
- "displayName": "Edge Link Configuration"
20
- },
21
- "signalk-plugin-enabled-by-default": false,
22
- "scripts": {
23
- "clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
24
- "clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
25
- "build": "npm run build:ts && npm run build:web",
26
- "build:web": "npm run clean:public && webpack --mode production",
27
- "build:ts": "npm run clean:lib && tsc",
28
- "check:ts": "tsc --noEmit",
29
- "check:release-docs": "node scripts/check-release-truth.js",
30
- "dev": "webpack --mode development --watch",
31
- "test": "jest --runInBand",
32
- "test:v2": "jest __tests__/v2/",
33
- "test:integration": "jest test/integration/",
34
- "test:watch": "jest --watch",
35
- "test:coverage": "jest --coverage",
36
- "lint": "eslint .",
37
- "lint:fix": "eslint . --fix",
38
- "format": "prettier --write \"**/*.{js,ts,json,md}\"",
39
- "migrate:config": "node lib/scripts/migrate-config.js",
40
- "cli": "node lib/bin/edge-link-cli.js",
41
- "prepare": "husky"
42
- },
43
- "dependencies": {
44
- "@msgpack/msgpack": "^3.0.0",
45
- "@rjsf/core": "^5.18.4",
46
- "@rjsf/utils": "^5.18.4",
47
- "@rjsf/validator-ajv8": "^5.18.4",
48
- "ping-monitor": "^0.8.2"
49
- },
50
- "devDependencies": {
51
- "@babel/core": "^7.22.0",
52
- "@babel/preset-env": "^7.22.0",
53
- "@babel/preset-react": "^7.22.0",
54
- "@testing-library/jest-dom": "^5.17.0",
55
- "@testing-library/react": "^12.1.5",
56
- "@types/node": "^25.3.5",
57
- "@types/react": "^16.14.0",
58
- "@types/react-dom": "^16.9.0",
59
- "babel-loader": "^9.1.2",
60
- "copy-webpack-plugin": "^14.0.0",
61
- "css-loader": "^6.8.1",
62
- "eslint": "^8.57.1",
63
- "eslint-plugin-react": "^7.37.5",
64
- "html-webpack-plugin": "^5.5.3",
65
- "husky": "^9.1.7",
66
- "jest": "^29.7.0",
67
- "jest-environment-jsdom": "^30.3.0",
68
- "lint-staged": "^15.4.3",
69
- "mini-css-extract-plugin": "^2.7.6",
70
- "prettier": "^3.6.2",
71
- "react": "^16.13.1",
72
- "react-dom": "^16.13.1",
73
- "react-test-renderer": "^16.14.0",
74
- "style-loader": "^3.3.3",
75
- "ts-jest": "^29.4.6",
76
- "ts-loader": "^9.5.4",
77
- "typescript": "^5.9.3",
78
- "webpack": "^5.102.1",
79
- "webpack-cli": "^5.1.4"
80
- },
81
- "engines": {
82
- "node": ">=16"
83
- },
84
- "author": "Karl-Erik Gustafsson",
85
- "repository": "https://github.com/KEGustafsson/signalk-edge-link",
86
- "homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
87
- "bugs": {
88
- "url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
89
- },
90
- "license": "MIT",
91
- "jest": {
92
- "testEnvironment": "node",
93
- "coverageDirectory": "coverage",
94
- "collectCoverageFrom": [
95
- "lib/**/*.js",
96
- "!lib/webapp/**",
97
- "!lib/components/**",
98
- "!lib/utils/**"
99
- ],
100
- "testMatch": [
101
- "**/__tests__/**/*.js",
102
- "**/*.test.js",
103
- "**/*.spec.js"
104
- ],
105
- "transform": {
106
- "^.+\\.js$": [
107
- "babel-jest",
108
- {
109
- "presets": [
110
- [
111
- "@babel/preset-env",
112
- {
113
- "targets": {
114
- "node": "current"
115
- }
116
- }
117
- ]
118
- ]
119
- }
120
- ],
121
- ".+\\.tsx$": [
122
- "ts-jest",
123
- {
124
- "tsconfig": "tsconfig.webapp.json",
125
- "diagnostics": false
126
- }
127
- ],
128
- "^.+\\.ts$": "ts-jest"
129
- },
130
- "moduleFileExtensions": [
131
- "ts",
132
- "tsx",
133
- "js",
134
- "json",
135
- "node"
136
- ],
137
- "testPathIgnorePatterns": [
138
- "/node_modules/",
139
- "/public/"
140
- ],
141
- "coverageThreshold": {
142
- "global": {
143
- "branches": 60,
144
- "functions": 65,
145
- "lines": 65,
146
- "statements": 65
147
- }
148
- }
149
- },
150
- "bin": {
151
- "edge-link-cli": "lib/bin/edge-link-cli.js"
152
- },
153
- "lint-staged": {
154
- "*.js": [
155
- "prettier --write",
156
- "eslint --fix"
157
- ],
158
- "*.ts": [
159
- "prettier --write"
160
- ],
161
- "*.{json,md}": [
162
- "prettier --write"
163
- ]
164
- }
165
- }
1
+ {
2
+ "name": "signalk-edge-link",
3
+ "version": "2.7.0",
4
+ "description": "SignalK Edge Link. Secure UDP link for data exchange.",
5
+ "main": "lib/index.js",
6
+ "files": [
7
+ "lib/",
8
+ "public/"
9
+ ],
10
+ "keywords": [
11
+ "signalk-node-server-plugin",
12
+ "signalk-category-network",
13
+ "signalk-webapp",
14
+ "signalk-category-utility",
15
+ "signalk-plugin-configurator"
16
+ ],
17
+ "signalk": {
18
+ "appIcon": "./icons/icon-72x72.png",
19
+ "displayName": "Edge Link Configuration"
20
+ },
21
+ "signalk-plugin-enabled-by-default": false,
22
+ "scripts": {
23
+ "clean:lib": "node -e \"const fs=require('fs');if(fs.existsSync('lib'))fs.rmSync('lib',{recursive:true,force:true});\"",
24
+ "clean:public": "node -e \"const fs=require('fs');if(fs.existsSync('public'))fs.rmSync('public',{recursive:true,force:true});\"",
25
+ "build": "npm run build:ts && npm run build:web",
26
+ "build:web": "npm run clean:public && webpack --mode production",
27
+ "build:ts": "npm run clean:lib && tsc",
28
+ "check:ts": "tsc --noEmit",
29
+ "check:release-docs": "node scripts/check-release-truth.js",
30
+ "dev": "webpack --mode development --watch",
31
+ "test": "jest --runInBand",
32
+ "test:v2": "jest __tests__/v2/",
33
+ "test:integration": "jest test/integration/",
34
+ "test:watch": "jest --watch",
35
+ "test:coverage": "jest --coverage",
36
+ "lint": "eslint .",
37
+ "lint:fix": "eslint . --fix",
38
+ "format": "prettier --write \"**/*.{js,ts,json,md}\"",
39
+ "migrate:config": "node lib/scripts/migrate-config.js",
40
+ "cli": "node lib/bin/edge-link-cli.js",
41
+ "prepare": "husky"
42
+ },
43
+ "dependencies": {
44
+ "@msgpack/msgpack": "^3.0.0",
45
+ "@rjsf/core": "^5.18.4",
46
+ "@rjsf/utils": "^5.18.4",
47
+ "@rjsf/validator-ajv8": "^5.18.4",
48
+ "ping-monitor": "^0.8.2"
49
+ },
50
+ "devDependencies": {
51
+ "@babel/core": "^7.22.0",
52
+ "@babel/preset-env": "^7.22.0",
53
+ "@babel/preset-react": "^7.22.0",
54
+ "@testing-library/jest-dom": "^5.17.0",
55
+ "@testing-library/react": "^12.1.5",
56
+ "@types/node": "^25.3.5",
57
+ "@types/react": "^16.14.0",
58
+ "@types/react-dom": "^16.9.0",
59
+ "babel-loader": "^9.1.2",
60
+ "copy-webpack-plugin": "^14.0.0",
61
+ "css-loader": "^6.8.1",
62
+ "eslint": "^8.57.1",
63
+ "eslint-plugin-react": "^7.37.5",
64
+ "html-webpack-plugin": "^5.5.3",
65
+ "husky": "^9.1.7",
66
+ "jest": "^29.7.0",
67
+ "jest-environment-jsdom": "^30.3.0",
68
+ "lint-staged": "^15.4.3",
69
+ "mini-css-extract-plugin": "^2.7.6",
70
+ "prettier": "^3.6.2",
71
+ "react": "^16.13.1",
72
+ "react-dom": "^16.13.1",
73
+ "react-test-renderer": "^16.14.0",
74
+ "style-loader": "^3.3.3",
75
+ "ts-jest": "^29.4.6",
76
+ "ts-loader": "^9.5.4",
77
+ "typescript": "^5.9.3",
78
+ "webpack": "^5.102.1",
79
+ "webpack-cli": "^5.1.4"
80
+ },
81
+ "engines": {
82
+ "node": ">=16"
83
+ },
84
+ "author": "Karl-Erik Gustafsson",
85
+ "repository": "https://github.com/KEGustafsson/signalk-edge-link",
86
+ "homepage": "https://github.com/KEGustafsson/signalk-edge-link#readme",
87
+ "bugs": {
88
+ "url": "https://github.com/KEGustafsson/signalk-edge-link/issues"
89
+ },
90
+ "license": "MIT",
91
+ "jest": {
92
+ "testEnvironment": "node",
93
+ "coverageDirectory": "coverage",
94
+ "collectCoverageFrom": [
95
+ "lib/**/*.js",
96
+ "!lib/webapp/**",
97
+ "!lib/components/**",
98
+ "!lib/utils/**"
99
+ ],
100
+ "testMatch": [
101
+ "**/__tests__/**/*.js",
102
+ "**/*.test.js",
103
+ "**/*.spec.js"
104
+ ],
105
+ "transform": {
106
+ "^.+\\.js$": [
107
+ "babel-jest",
108
+ {
109
+ "presets": [
110
+ [
111
+ "@babel/preset-env",
112
+ {
113
+ "targets": {
114
+ "node": "current"
115
+ }
116
+ }
117
+ ]
118
+ ]
119
+ }
120
+ ],
121
+ ".+\\.tsx$": [
122
+ "ts-jest",
123
+ {
124
+ "tsconfig": "tsconfig.webapp.json",
125
+ "diagnostics": false
126
+ }
127
+ ],
128
+ "^.+\\.ts$": "ts-jest"
129
+ },
130
+ "moduleFileExtensions": [
131
+ "ts",
132
+ "tsx",
133
+ "js",
134
+ "json",
135
+ "node"
136
+ ],
137
+ "testPathIgnorePatterns": [
138
+ "/node_modules/",
139
+ "/public/"
140
+ ],
141
+ "coverageThreshold": {
142
+ "global": {
143
+ "branches": 60,
144
+ "functions": 65,
145
+ "lines": 65,
146
+ "statements": 65
147
+ }
148
+ }
149
+ },
150
+ "bin": {
151
+ "edge-link-cli": "lib/bin/edge-link-cli.js"
152
+ },
153
+ "lint-staged": {
154
+ "*.js": [
155
+ "prettier --write",
156
+ "eslint --fix"
157
+ ],
158
+ "*.ts": [
159
+ "prettier --write"
160
+ ],
161
+ "*.{json,md}": [
162
+ "prettier --write"
163
+ ]
164
+ }
165
+ }