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.
- package/lib/config-watcher.js +42 -4
- package/lib/index.js +17 -0
- package/lib/instance.js +245 -39
- package/lib/metrics.js +5 -1
- package/lib/pipeline-v2-server.js +16 -1
- package/lib/prometheus.js +13 -0
- package/lib/routes.js +1 -0
- package/lib/source-dispatch.js +130 -65
- package/lib/values-snapshot.js +40 -24
- package/package.json +165 -165
package/lib/config-watcher.js
CHANGED
|
@@ -72,10 +72,48 @@ function createDebouncedConfigHandler(opts) {
|
|
|
72
72
|
app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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(
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
715
|
-
//
|
|
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
|
|
718
|
-
|
|
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()
|
|
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: {
|
package/lib/source-dispatch.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
delete cloned.$source;
|
|
29
|
-
return cloned;
|
|
68
|
+
return derived;
|
|
30
69
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
if (
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
73
|
-
|
|
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
|
}
|
package/lib/values-snapshot.js
CHANGED
|
@@ -33,11 +33,28 @@ function walkValues(node, pathParts, onLeaf) {
|
|
|
33
33
|
if (!isRecord(node)) {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// `
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|