signalk-edge-link 2.6.2 → 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/index.js CHANGED
@@ -209,6 +209,23 @@ module.exports = function createPlugin(app) {
209
209
  setStatus(`Startup failed: ${startError instanceof Error ? startError.message : String(startError)}`);
210
210
  return;
211
211
  }
212
+ // Wire up FULL_STATUS_REQUEST cascade: when a client-mode instance receives
213
+ // a FULL_STATUS_REQUEST from its upstream server, it should also forward the
214
+ // request to all downstream clients connected to any co-located server-mode
215
+ // instances. This propagates the request down the chain (Cloud → Proxy → Boat)
216
+ // so one-shot startup values from the furthest-downstream node are re-sent.
217
+ const allStarted = [...instances.values()];
218
+ const serverInsts = allStarted.filter((inst) => inst.isServerMode());
219
+ const clientInsts = allStarted.filter((inst) => !inst.isServerMode());
220
+ if (serverInsts.length > 0 && clientInsts.length > 0) {
221
+ for (const clientInst of clientInsts) {
222
+ clientInst.setFullStatusCascadeHandler(() => {
223
+ for (const serverInst of serverInsts) {
224
+ serverInst.requestFullStatusFromAllClients();
225
+ }
226
+ });
227
+ }
228
+ }
212
229
  // Initial status aggregation after all instances report their status
213
230
  updateAggregatedStatus();
214
231
  };
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) {
@@ -379,6 +394,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
379
394
  /** Minimum gap between server-initiated full-status replays. Prevents a
380
395
  * restarting or misconfigured server from flooding the link. */
381
396
  const FULL_STATUS_REQUEST_RATE_LIMIT_MS = 10000;
397
+ /**
398
+ * Optional callback invoked after this (client-mode) instance handles a
399
+ * FULL_STATUS_REQUEST. Used in multi-hop chains to cascade the request to
400
+ * any downstream clients connected to a co-located server-mode instance.
401
+ */
402
+ let fullStatusCascadeHandler = null;
382
403
  /** Server asked for a full values snapshot (FULL_STATUS_REQUEST control
383
404
  * packet). Replays the entire current Signal K tree to the server.
384
405
  * Rate-limited to prevent replay floods across rapid server restarts. */
@@ -391,6 +412,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
391
412
  state.lastFullStatusRequestAt = now;
392
413
  app.debug(`[${instanceId}] FULL_STATUS_REQUEST received — replaying values snapshot`);
393
414
  replayValuesSnapshot("full-status-request");
415
+ if (fullStatusCascadeHandler) {
416
+ app.debug(`[${instanceId}] FULL_STATUS_REQUEST cascading to downstream clients`);
417
+ fullStatusCascadeHandler();
418
+ }
394
419
  }
395
420
  async function sendSourceSnapshot() {
396
421
  if (state.stopped ||
@@ -474,6 +499,51 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
474
499
  function parseMetaConfig(raw) {
475
500
  return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
476
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
+ }
477
547
  /**
478
548
  * Processes an incoming delta from the subscription manager.
479
549
  * Buffers and dispatches deltas to the send pipeline.
@@ -549,6 +619,17 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
549
619
  if (!state.readyToSend) {
550
620
  return;
551
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
+ }
552
633
  // Capture live meta BEFORE the delta flows into the pipeline encoder,
553
634
  // because pathDictionary.transformDelta will strip `updates[].meta[]` when
554
635
  // rebuilding the update objects. `extractLiveMeta` returns [] when meta
@@ -563,6 +644,24 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
563
644
  if (!outboundDelta) {
564
645
  return;
565
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
+ }
566
665
  if (state.deltas.length >= constants_1.MAX_DELTAS_BUFFER_SIZE) {
567
666
  const dropCount = Math.floor(constants_1.MAX_DELTAS_BUFFER_SIZE * constants_1.DELTA_BUFFER_DROP_RATIO);
568
667
  state.deltas.splice(0, dropCount);
@@ -592,6 +691,14 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
592
691
  }
593
692
  }
594
693
  state.processDelta = processDelta;
694
+ function createSubscriptionDeltaHandler(subscriptionGeneration) {
695
+ return (delta) => {
696
+ if (subscriptionGeneration !== activeSubscriptionGeneration) {
697
+ return;
698
+ }
699
+ processDelta(delta);
700
+ };
701
+ }
595
702
  const SUBSCRIPTION_RETRY_BASE_DELAY = 5000;
596
703
  const SUBSCRIPTION_RETRY_MAX_DELAY = 300000;
597
704
  const SUBSCRIPTION_RETRY_MAX_ATTEMPTS = 10;
@@ -627,13 +734,27 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
627
734
  return;
628
735
  }
629
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());
630
744
  try {
631
- app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (retrySubError) => {
632
- app.error(`[${instanceId}] Subscription error (attempt ${attempt}): ${retrySubError}`);
633
- state.readyToSend = false;
634
- _setStatus("Subscription error - data transmission paused", false);
635
- recordError("subscription", `Subscription error: ${retrySubError}`);
636
- }, 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
+ }
637
758
  // Retry succeeded — perform the staged commit that the original
638
759
  // processConfig catch block skipped. Without this, the operator's
639
760
  // new meta block (stashed on state.pendingMetaConfig) would remain
@@ -666,30 +787,46 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
666
787
  name: "Subscription",
667
788
  getFilePath: () => state.subscriptionFile,
668
789
  processConfig: (config) => {
669
- state.localSubscription = config;
790
+ state.localSubscription = normalizeSubscriptionConfig(config);
670
791
  app.debug(`[${instanceId}] Subscription configuration updated`);
671
792
  // Stage the new metadata config — do NOT yet touch state.metaConfig,
672
793
  // the periodic timer, or metaCache. If subscribe() throws, the old
673
794
  // subscription remains active until the retry succeeds, so its
674
795
  // previous metadata behaviour must remain intact.
675
796
  const previousMetaConfig = state.metaConfig;
676
- const pendingMetaConfig = parseMetaConfig(config);
677
- // Capture the old cleanup handlers but do NOT call them yet.
678
- // We establish the new subscription first so data keeps flowing during
679
- // the handover; only after success do we release the old subscription.
680
- // If the new subscribe() throws, we restore the old handlers so that
681
- // stop() can still clean up and the old subscription remains active
682
- // 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.
683
814
  const previousUnsubscribes = state.unsubscribes.splice(0);
815
+ previousUnsubscribes.forEach((f) => f());
684
816
  try {
685
- app.subscriptionmanager.subscribe(state.localSubscription, state.unsubscribes, (subscriptionError) => {
686
- app.error(`[${instanceId}] Subscription error: ${subscriptionError}`);
687
- state.readyToSend = false;
688
- _setStatus("Subscription error - data transmission paused", false);
689
- recordError("subscription", `Subscription error: ${subscriptionError}`);
690
- }, processDelta);
691
- // New subscription established release old cleanup handlers.
692
- 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
+ }
693
830
  // Commit the new metadata config AFTER a successful subscribe: swap
694
831
  // state.metaConfig, (re)start the periodic timer, and reset the diff
695
832
  // cache so the next snapshot represents the live state in full. We
@@ -711,12 +848,19 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
711
848
  replayValuesSnapshot("initial subscribe");
712
849
  }
713
850
  catch (subscribeError) {
714
- // Re-subscribe failed restore old handlers so stop() can still
715
- // 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.
716
859
  // Leave state.metaConfig / metaCache / metaTimer untouched so the
717
- // previous subscription's metadata stream keeps running unchanged.
718
- state.unsubscribes = previousUnsubscribes;
860
+ // previous subscription's metadata behaviour rules are preserved
861
+ // pending retry.
719
862
  void previousMetaConfig; // explicit: intentionally unchanged
863
+ void previousUnsubscribes; // intentionally not restored — see above
720
864
  // Stash the new meta config on state so the scheduled retry can
721
865
  // promote it when subscribe() finally succeeds. Otherwise the
722
866
  // operator's new meta settings would silently sit unused until the
@@ -757,7 +901,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
757
901
  app
758
902
  });
759
903
  // ── File-system watchers (delegated to config-watcher module) ────────────
760
- function setupConfigWatchers() {
904
+ async function setupConfigWatchers() {
761
905
  try {
762
906
  const watcherConfigs = [
763
907
  { filePath: state.deltaTimerFile, onChange: handleDeltaTimerChange, name: "Delta timer" },
@@ -779,10 +923,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
779
923
  // produced by co-located plugins are emitted before our subscription
780
924
  // is registered with the subscriptionmanager — those deltas would be
781
925
  // silently dropped since the manager only delivers future events.
782
- handleSubscriptionChange.flush().catch((err) => {
783
- const msg = err instanceof Error ? err.message : String(err);
784
- app.error(`[${instanceId}] Initial subscription load failed: ${msg}`);
785
- });
926
+ await handleSubscriptionChange.flush();
786
927
  app.debug(`[${instanceId}] Configuration file watchers initialized`);
787
928
  }
788
929
  catch (err) {
@@ -1057,7 +1198,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1057
1198
  }
1058
1199
  state.socketUdp.on("error", handleClientSocketError);
1059
1200
  scheduleDeltaTimer();
1060
- setupConfigWatchers();
1061
1201
  // Ping / connectivity monitor (v1 only, RTT measurement)
1062
1202
  if ((options.protocolVersion ?? 0) < 2) {
1063
1203
  state.pingMonitor = new ping_monitor_1.default({
@@ -1123,11 +1263,6 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1123
1263
  const msg = err instanceof Error ? err.message : String(err);
1124
1264
  app.debug(`[${instanceId}] initial source snapshot failed: ${msg}`);
1125
1265
  });
1126
- // The initial-subscribe replayValuesSnapshot fires before readyToSend
1127
- // is true (pipeline not yet created) and silently returns early. Replay
1128
- // now so data already in the SK tree — including values injected by a
1129
- // co-located server-mode instance — is forwarded on first connect.
1130
- replayValuesSnapshot("initial connect");
1131
1266
  state.socketUdp.on("message", (msg, rinfo) => {
1132
1267
  v2Pipeline.handleControlPacket(msg, rinfo).catch((err) => {
1133
1268
  const errMsg = err instanceof Error ? err.message : String(err);
@@ -1171,8 +1306,67 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1171
1306
  }
1172
1307
  app.debug(`[${instanceId}] [v1] Client pipeline initialized`);
1173
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();
1174
1314
  }
1175
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
+ }
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
+ });
1369
+ }
1176
1370
  function stop() {
1177
1371
  // If a batch send is in progress, log the warning. We cannot reliably
1178
1372
  // await it here because stop() must remain synchronous (called by the
@@ -1188,8 +1382,10 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1188
1382
  state.unsubscribes.forEach((f) => f());
1189
1383
  state.unsubscribes = [];
1190
1384
  state.localSubscription = null;
1385
+ activeSubscriptionGeneration++;
1191
1386
  // Reset runtime state
1192
1387
  state.deltas = [];
1388
+ recentOutboundDeltas.clear();
1193
1389
  state.timer = false;
1194
1390
  state.batchSendInFlight = false;
1195
1391
  state.socketRecoveryInProgress = false;
@@ -1291,6 +1487,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
1291
1487
  getName: () => state.instanceName,
1292
1488
  getStatus: () => ({ text: state.instanceStatus, healthy: state.isHealthy }),
1293
1489
  getState: () => state,
1294
- getMetricsApi: () => metricsApi
1490
+ getMetricsApi: () => metricsApi,
1491
+ /** Register a callback to invoke when this client-mode instance handles
1492
+ * a FULL_STATUS_REQUEST, so the request cascades to downstream clients. */
1493
+ setFullStatusCascadeHandler(handler) {
1494
+ fullStatusCascadeHandler = handler;
1495
+ },
1496
+ /** Forward a FULL_STATUS_REQUEST to all currently-connected clients
1497
+ * (server-mode instances only; no-op on client-mode instances). */
1498
+ requestFullStatusFromAllClients() {
1499
+ state.pipelineServer?.requestFullStatusFromAllClients?.();
1500
+ }
1295
1501
  };
1296
1502
  }
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,
@@ -717,6 +717,20 @@ function createPipelineV2Server(app, state, metricsApi) {
717
717
  throw err;
718
718
  }
719
719
  }
720
+ /**
721
+ * Send FULL_STATUS_REQUEST to every currently-connected client session.
722
+ * Called when this server instance itself receives a FULL_STATUS_REQUEST
723
+ * from an upstream server, so the request cascades down the chain:
724
+ * Cloud → Proxy (triggers this) → Boat.
725
+ */
726
+ function requestFullStatusFromAllClients() {
727
+ const secretKey = state.options?.secretKey ?? "";
728
+ for (const session of clientSessions.values()) {
729
+ _sendFullStatusRequest(session, secretKey).catch((err) => {
730
+ app.debug(`[v2-server] cascade FULL_STATUS_REQUEST to ${session.key} failed: ${err instanceof Error ? err.message : String(err)}`);
731
+ });
732
+ }
733
+ }
720
734
  /**
721
735
  * Build and send a META_REQUEST (0x07) control packet to a client.
722
736
  * Instructs the client to emit a fresh metadata snapshot — used on first
@@ -1210,6 +1224,7 @@ function createPipelineV2Server(app, state, metricsApi) {
1210
1224
  startACKTimer,
1211
1225
  stopACKTimer,
1212
1226
  startMetricsPublishing,
1213
- stopMetricsPublishing
1227
+ stopMetricsPublishing,
1228
+ requestFullStatusFromAllClients
1214
1229
  };
1215
1230
  }
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)) {
@@ -171,18 +177,28 @@ function collectValuesSnapshot(app) {
171
177
  }
172
178
  const context = `${contextGroup}.${contextId}`;
173
179
  walkValues(contextNode, [], (leaf) => {
174
- // Skip values that this plugin injected from remote instances.
175
- // SK stores them under "signalk-edge-link.*" $source keys. Including
176
- // them in the snapshot would loop remote data back to its origin and
177
- // propagate wrong source labels (the fallback label derived from the
178
- // "signalk-edge-link" prefix is never the original sensor label).
179
- // Live streaming handles relay correctly via subscription callbacks,
180
- // which SK populates with the full source object automatically.
180
+ // Values stored under "signalk-edge-link.*" $source keys were injected
181
+ // by this plugin (data received via an upstream edge-link server connection
182
+ // or a downstream edge-link client connection). Skip them only when the SK
183
+ // sources table cannot provide a proper original-sensor label that case
184
+ // would produce wrong attribution on the receiver. When the sources table
185
+ // does resolve to a real label (e.g. "pypilot"), include the value so relay
186
+ // data reaches the upstream server after its restart; the receiver's
187
+ // normalizeDeltaSourceRefs will strip the stale $source and
188
+ // handleMessageBySource will dispatch under the original label.
181
189
  const src = leaf.source ?? "";
182
190
  if (src === "signalk-edge-link" ||
183
191
  src.startsWith("signalk-edge-link.") ||
184
192
  src.startsWith("signalk-edge-link:")) {
185
- return;
193
+ const resolved = sourceLookup.get(src);
194
+ const resolvedLabel = typeof resolved?.label === "string" ? resolved.label.trim() : "";
195
+ if (!resolvedLabel ||
196
+ resolvedLabel === "signalk-edge-link" ||
197
+ resolvedLabel.startsWith("signalk-edge-link.") ||
198
+ resolvedLabel.startsWith("signalk-edge-link:")) {
199
+ return; // No proper label available — skip to avoid wrong attribution
200
+ }
201
+ // Resolved to a real sensor label — fall through and include the value
186
202
  }
187
203
  const key = `${context}|${leaf.source ?? ""}|${leaf.timestamp}`;
188
204
  const existing = grouped.get(key);
package/package.json CHANGED
@@ -1,165 +1,165 @@
1
- {
2
- "name": "signalk-edge-link",
3
- "version": "2.6.2",
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
+ }