signalk-edge-link 2.2.1 → 2.3.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.
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeDeltaForSignalK = sanitizeDeltaForSignalK;
4
+ exports.sanitizeDeltaPayloadForSignalK = sanitizeDeltaPayloadForSignalK;
5
+ function isObject(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function isValidValuePath(path) {
9
+ return typeof path === "string" && path.trim().length > 0;
10
+ }
11
+ /**
12
+ * Remove Signal K value entries that the server will reject before calling
13
+ * app.handleMessage or forwarding over the link. Metadata-only updates are
14
+ * preserved, but updates with neither valid values nor meta are dropped.
15
+ */
16
+ function sanitizeDeltaForSignalK(delta) {
17
+ if (!delta || !Array.isArray(delta.updates)) {
18
+ return null;
19
+ }
20
+ let changed = false;
21
+ const sanitizedUpdates = [];
22
+ for (const rawUpdate of delta.updates) {
23
+ if (!isObject(rawUpdate)) {
24
+ changed = true;
25
+ continue;
26
+ }
27
+ const update = rawUpdate;
28
+ let updateChanged = false;
29
+ const rawValues = Array.isArray(update.values) ? update.values : [];
30
+ if (!Array.isArray(update.values)) {
31
+ updateChanged = true;
32
+ }
33
+ const values = [];
34
+ for (const rawValue of rawValues) {
35
+ if (!isObject(rawValue) || !isValidValuePath(rawValue.path)) {
36
+ updateChanged = true;
37
+ continue;
38
+ }
39
+ values.push(rawValue);
40
+ }
41
+ if (values.length !== rawValues.length) {
42
+ updateChanged = true;
43
+ }
44
+ const hasMeta = Array.isArray(update.meta) && update.meta.length > 0;
45
+ if (values.length === 0 && !hasMeta) {
46
+ changed = true;
47
+ continue;
48
+ }
49
+ if (updateChanged) {
50
+ changed = true;
51
+ }
52
+ sanitizedUpdates.push(updateChanged ? { ...update, values } : update);
53
+ }
54
+ if (sanitizedUpdates.length === 0) {
55
+ return null;
56
+ }
57
+ if (!changed && sanitizedUpdates.length === delta.updates.length) {
58
+ return delta;
59
+ }
60
+ return {
61
+ ...delta,
62
+ updates: sanitizedUpdates
63
+ };
64
+ }
65
+ function isDeltaLike(value) {
66
+ return isObject(value) && Array.isArray(value.updates);
67
+ }
68
+ function sanitizeDeltaPayloadForSignalK(delta) {
69
+ if (Array.isArray(delta)) {
70
+ const sanitized = delta
71
+ .map((item) => sanitizeDeltaForSignalK(item))
72
+ .filter((item) => item !== null);
73
+ return sanitized.length > 0 ? sanitized : null;
74
+ }
75
+ if (isDeltaLike(delta)) {
76
+ return sanitizeDeltaForSignalK(delta);
77
+ }
78
+ const sanitizedEntries = [];
79
+ for (const [key, value] of Object.entries(delta)) {
80
+ const sanitized = sanitizeDeltaForSignalK(value);
81
+ if (sanitized !== null) {
82
+ sanitizedEntries.push([key, sanitized]);
83
+ }
84
+ }
85
+ return sanitizedEntries.length > 0 ? Object.fromEntries(sanitizedEntries) : null;
86
+ }
package/lib/instance.js CHANGED
@@ -30,6 +30,8 @@ const packet_capture_1 = require("./packet-capture");
30
30
  const constants_1 = require("./constants");
31
31
  const config_io_1 = require("./config-io");
32
32
  const config_watcher_1 = require("./config-watcher");
33
+ const metadata_1 = require("./metadata");
34
+ const delta_sanitizer_1 = require("./delta-sanitizer");
33
35
  const DELTA_SEND_MAX_RETRIES = 1;
34
36
  const DELTA_SEND_RETRY_BACKOFF_MS = 100;
35
37
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -63,6 +65,7 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
63
65
  isHealthy: false,
64
66
  options,
65
67
  socketUdp: null,
68
+ metaSocketUdp: null,
66
69
  readyToSend: false,
67
70
  stopped: false,
68
71
  isServerMode: false,
@@ -96,11 +99,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
96
99
  configDebounceTimers: {},
97
100
  configContentHashes: {},
98
101
  configWatcherObjects: [],
99
- processDelta: null
102
+ processDelta: null,
103
+ metaConfig: null,
104
+ metaTimer: null,
105
+ metaDiffBuffer: [],
106
+ metaDiffFlushTimer: null,
107
+ metaSnapshotTimers: [],
108
+ lastMetaRequestAt: 0
100
109
  };
101
110
  const metricsApi = (0, metrics_1.default)();
102
111
  const { metrics, recordError, resetMetrics } = metricsApi;
103
- // v1 pipeline is created lazily on first use (only needed in client v1 mode)
104
112
  let v1Pipeline = null;
105
113
  function getV1Pipeline() {
106
114
  if (!v1Pipeline) {
@@ -196,14 +204,170 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
196
204
  app
197
205
  });
198
206
  /**
199
- * Outbound filtering is intentionally disabled:
200
- * forward all subscribed deltas as-is.
207
+ * Forward subscribed deltas as-is except for malformed value entries that
208
+ * Signal K would reject on the receiver side.
201
209
  */
202
210
  function filterOutboundDelta(delta) {
203
- if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
204
- return null;
211
+ return (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
212
+ }
213
+ // ── Metadata streaming ────────────────────────────────────────────────────
214
+ /** In-memory cache of last-sent meta (hashed) per context+path. Used to
215
+ * compute diffs and to skip no-op periodic resends. */
216
+ const metaCache = new metadata_1.MetaCache();
217
+ /** Debounce window for coalescing live meta entries observed in the delta
218
+ * stream before they are transmitted as a single `diff` packet. */
219
+ const META_DIFF_DEBOUNCE_MS = 500;
220
+ /** Minimum gap between receiver-initiated snapshot sends. Prevents a noisy
221
+ * or malicious receiver from forcing snapshots on every delta. */
222
+ const META_REQUEST_RATE_LIMIT_MS = 5000;
223
+ /** Dispatches `entries` through the active pipeline. Returns true on a
224
+ * successful send so callers (e.g. `enqueueMetaDiff`) can decide whether
225
+ * to commit the MetaCache. Any failure is logged and returns false. */
226
+ async function sendMetaEntries(entries, kind) {
227
+ if (!options.udpAddress || !options.secretKey) {
228
+ return false;
229
+ }
230
+ if (entries.length === 0) {
231
+ return false;
232
+ }
233
+ const protoVer = options.protocolVersion ?? 2;
234
+ try {
235
+ if (protoVer === 1) {
236
+ if (!options.udpMetaPort || options.udpMetaPort <= 0) {
237
+ app.debug(`[${instanceId}] Meta skipped: v1 pipeline requires 'udpMetaPort' in connection config`);
238
+ return false;
239
+ }
240
+ await getV1Pipeline().packCryptMeta(entries, kind, options.secretKey, options.udpAddress, options.udpMetaPort);
241
+ }
242
+ else if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
243
+ await state.pipeline.sendMetadata(entries, kind, options.secretKey, options.udpAddress, options.udpPort);
244
+ }
245
+ else {
246
+ app.debug(`[${instanceId}] Meta skipped: pipeline not ready or does not support sendMetadata`);
247
+ return false;
248
+ }
249
+ return true;
250
+ }
251
+ catch (err) {
252
+ const msg = err instanceof Error ? err.message : String(err);
253
+ app.error(`[${instanceId}] sendMetaEntries failed: ${msg}`);
254
+ recordError("general", `sendMetaEntries failed: ${msg}`);
255
+ return false;
256
+ }
257
+ }
258
+ /**
259
+ * Build and transmit a full metadata snapshot from the current Signal K
260
+ * state tree. Resets the internal diff cache afterwards so the next diff is
261
+ * measured against what was just sent.
262
+ */
263
+ async function sendMetadataSnapshot() {
264
+ if (!state.metaConfig?.enabled || state.stopped || !state.readyToSend) {
265
+ return;
266
+ }
267
+ const entries = (0, metadata_1.collectSnapshot)(appProxy, state.metaConfig);
268
+ const sent = await sendMetaEntries(entries, "snapshot");
269
+ // Only prime the diff cache on a successful send; on failure the next
270
+ // snapshot (periodic or META_REQUEST-triggered) will still cover every
271
+ // path rather than the cache showing stale "already sent" state.
272
+ if (sent) {
273
+ metaCache.replaceAll(entries);
274
+ }
275
+ }
276
+ /** Coalesces live meta diffs extracted from deltas; flushes after a short
277
+ * debounce window so a burst of meta changes becomes one packet. */
278
+ function enqueueMetaDiff(entries) {
279
+ // Buffer raw entries; the actual change-detection (and cache commit)
280
+ // happens in the flush handler so a failed send doesn't leave the
281
+ // MetaCache thinking it transmitted something it never did.
282
+ if (entries.length === 0) {
283
+ return;
284
+ }
285
+ state.metaDiffBuffer.push(...entries);
286
+ if (state.metaDiffFlushTimer) {
287
+ return;
288
+ }
289
+ state.metaDiffFlushTimer = setTimeout(() => {
290
+ state.metaDiffFlushTimer = null;
291
+ const pending = state.metaDiffBuffer;
292
+ state.metaDiffBuffer = [];
293
+ const changed = metaCache.computeDiff(pending);
294
+ if (changed.length === 0) {
295
+ return;
296
+ }
297
+ sendMetaEntries(changed, "diff")
298
+ .then((sent) => {
299
+ if (sent) {
300
+ metaCache.commit(changed);
301
+ }
302
+ })
303
+ .catch((err) => {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ app.debug(`[${instanceId}] meta diff flush failed: ${msg}`);
306
+ });
307
+ }, META_DIFF_DEBOUNCE_MS);
308
+ }
309
+ function restartMetadataTimer() {
310
+ if (state.metaTimer) {
311
+ clearInterval(state.metaTimer);
312
+ state.metaTimer = null;
313
+ }
314
+ if (!state.metaConfig?.enabled) {
315
+ return;
316
+ }
317
+ const intervalMs = Math.max(30, state.metaConfig.intervalSec) * 1000;
318
+ state.metaTimer = setInterval(() => {
319
+ sendMetadataSnapshot().catch((err) => {
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ app.debug(`[${instanceId}] periodic snapshot failed: ${msg}`);
322
+ });
323
+ }, intervalMs);
324
+ }
325
+ /** Schedules a meta snapshot send after `delayMs`. Cancels any prior
326
+ * pending snapshot timer first — back-to-back (re)subscribes or socket
327
+ * recoveries should coalesce into a single pending snapshot rather than
328
+ * queue up multiple sends. The returned timer is tracked on
329
+ * state.metaSnapshotTimers so stop() can cancel it. */
330
+ function scheduleMetadataSnapshot(delayMs) {
331
+ for (const existing of state.metaSnapshotTimers) {
332
+ clearTimeout(existing);
333
+ }
334
+ state.metaSnapshotTimers.length = 0;
335
+ const handle = setTimeout(() => {
336
+ const idx = state.metaSnapshotTimers.indexOf(handle);
337
+ if (idx !== -1) {
338
+ state.metaSnapshotTimers.splice(idx, 1);
339
+ }
340
+ if (state.stopped) {
341
+ return;
342
+ }
343
+ sendMetadataSnapshot().catch(() => {
344
+ /* errors already logged inside sendMetadataSnapshot */
345
+ });
346
+ }, delayMs);
347
+ state.metaSnapshotTimers.push(handle);
348
+ }
349
+ /** Receiver asked for a fresh meta snapshot (META_REQUEST control packet).
350
+ * Rate-limited so a malformed or buggy receiver cannot force continuous
351
+ * snapshot work on the edge-link. */
352
+ function handleMetaRequest() {
353
+ if (!state.metaConfig?.enabled) {
354
+ return;
355
+ }
356
+ const now = Date.now();
357
+ if (now - state.lastMetaRequestAt < META_REQUEST_RATE_LIMIT_MS) {
358
+ return;
205
359
  }
206
- return delta;
360
+ state.lastMetaRequestAt = now;
361
+ sendMetadataSnapshot().catch((err) => {
362
+ const msg = err instanceof Error ? err.message : String(err);
363
+ app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
364
+ });
365
+ }
366
+ /** Thin wrapper around the parser in `metadata.ts` so the instance log
367
+ * line is tagged with this connection's instanceId. Errors from the
368
+ * shared parser already have the `[meta-config]` prefix. */
369
+ function parseMetaConfig(raw) {
370
+ return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
207
371
  }
208
372
  /**
209
373
  * Processes an incoming delta from the subscription manager.
@@ -279,6 +443,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
279
443
  if (!state.readyToSend) {
280
444
  return;
281
445
  }
446
+ // Capture live meta BEFORE the delta flows into the pipeline encoder,
447
+ // because pathDictionary.transformDelta will strip `updates[].meta[]` when
448
+ // rebuilding the update objects. `extractLiveMeta` returns [] when meta
449
+ // streaming is disabled, so this is zero-cost in the default off state.
450
+ if (state.metaConfig?.enabled) {
451
+ const liveMeta = (0, metadata_1.extractLiveMeta)(delta, state.metaConfig, (0, metadata_1.resolveSelfContext)(appProxy));
452
+ if (liveMeta.length > 0) {
453
+ enqueueMetaDiff(liveMeta);
454
+ }
455
+ }
282
456
  const outboundDelta = filterOutboundDelta(delta);
283
457
  if (!outboundDelta) {
284
458
  return;
@@ -354,6 +528,21 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
354
528
  _setStatus("Subscription error - data transmission paused", false);
355
529
  recordError("subscription", `Subscription error: ${retrySubError}`);
356
530
  }, processDelta);
531
+ // Retry succeeded — perform the staged commit that the original
532
+ // processConfig catch block skipped. Without this, the operator's
533
+ // new meta block (stashed on state.pendingMetaConfig) would remain
534
+ // inactive even though subscribe() is now working.
535
+ if (state.pendingMetaConfig !== undefined) {
536
+ state.metaConfig = state.pendingMetaConfig;
537
+ state.pendingMetaConfig = undefined;
538
+ restartMetadataTimer();
539
+ metaCache.clear();
540
+ if (state.metaConfig?.enabled) {
541
+ scheduleMetadataSnapshot(2000);
542
+ }
543
+ }
544
+ state.readyToSend = true;
545
+ _setStatus("Subscription restored", true);
357
546
  }
358
547
  catch (retryError) {
359
548
  const msg = retryError instanceof Error ? retryError.message : String(retryError);
@@ -370,6 +559,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
370
559
  processConfig: (config) => {
371
560
  state.localSubscription = config;
372
561
  app.debug(`[${instanceId}] Subscription configuration updated`);
562
+ // Stage the new metadata config — do NOT yet touch state.metaConfig,
563
+ // the periodic timer, or metaCache. If subscribe() throws, the old
564
+ // subscription remains active until the retry succeeds, so its
565
+ // previous metadata behaviour must remain intact.
566
+ const previousMetaConfig = state.metaConfig;
567
+ const pendingMetaConfig = parseMetaConfig(config);
373
568
  // Capture the old cleanup handlers but do NOT call them yet.
374
569
  // We establish the new subscription first so data keeps flowing during
375
570
  // the handover; only after success do we release the old subscription.
@@ -386,11 +581,32 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
386
581
  }, processDelta);
387
582
  // New subscription established — release old cleanup handlers.
388
583
  previousUnsubscribes.forEach((f) => f());
584
+ // Commit the new metadata config AFTER a successful subscribe: swap
585
+ // state.metaConfig, (re)start the periodic timer, and reset the diff
586
+ // cache so the next snapshot represents the live state in full. We
587
+ // reset the cache unconditionally here because even "meta unchanged"
588
+ // still needs an empty cache for the new subscription's path set.
589
+ state.metaConfig = pendingMetaConfig;
590
+ restartMetadataTimer();
591
+ metaCache.clear();
592
+ // Prime the receiver's meta cache with a full snapshot once the
593
+ // Signal K state tree has had a moment to settle after (re)subscribe.
594
+ if (state.metaConfig?.enabled) {
595
+ scheduleMetadataSnapshot(2000);
596
+ }
389
597
  }
390
598
  catch (subscribeError) {
391
599
  // Re-subscribe failed — restore old handlers so stop() can still
392
600
  // clean up and the previous subscription remains active until retry.
601
+ // Leave state.metaConfig / metaCache / metaTimer untouched so the
602
+ // previous subscription's metadata stream keeps running unchanged.
393
603
  state.unsubscribes = previousUnsubscribes;
604
+ void previousMetaConfig; // explicit: intentionally unchanged
605
+ // Stash the new meta config on state so the scheduled retry can
606
+ // promote it when subscribe() finally succeeds. Otherwise the
607
+ // operator's new meta settings would silently sit unused until the
608
+ // user re-saved subscription.json.
609
+ state.pendingMetaConfig = pendingMetaConfig;
394
610
  const subErrMsg = subscribeError instanceof Error ? subscribeError.message : String(subscribeError);
395
611
  app.error(`[${instanceId}] Failed to subscribe: ${subErrMsg}`);
396
612
  state.readyToSend = false;
@@ -526,6 +742,34 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
526
742
  getV1Pipeline().unpackDecrypt(delta, options.secretKey);
527
743
  });
528
744
  app.debug(`[${instanceId}] [v1] Server pipeline initialized`);
745
+ // v1 has no packet-type byte, so meta is streamed on a separate UDP
746
+ // port by the client. Bind that port here when the operator has opted
747
+ // in. If `udpMetaPort` is unset we simply don't listen — keeping the
748
+ // receive side idle is the correct default for existing v1 peers that
749
+ // don't know about meta.
750
+ if (typeof options.udpMetaPort === "number" && options.udpMetaPort > 0) {
751
+ if (options.udpMetaPort === options.udpPort) {
752
+ app.error(`[${instanceId}] [v1] udpMetaPort (${options.udpMetaPort}) must differ from udpPort; meta disabled`);
753
+ }
754
+ else {
755
+ const metaSocket = dgram_1.default.createSocket({ type: "udp4", reuseAddr: true });
756
+ state.metaSocketUdp = metaSocket;
757
+ metaSocket.on("message", (msg) => {
758
+ getV1Pipeline()
759
+ .unpackDecryptMeta(msg, options.secretKey)
760
+ .catch((err) => {
761
+ const m = err instanceof Error ? err.message : String(err);
762
+ app.debug(`[${instanceId}] [v1] meta decrypt failed: ${m}`);
763
+ });
764
+ });
765
+ metaSocket.on("error", (err) => {
766
+ app.error(`[${instanceId}] [v1] meta socket error: ${err.message} (${err.code})`);
767
+ recordError("udpSend", `v1 meta socket error: ${err.message} (${err.code})`);
768
+ });
769
+ metaSocket.bind(options.udpMetaPort);
770
+ app.debug(`[${instanceId}] [v1] Meta listener bound to UDP :${options.udpMetaPort}`);
771
+ }
772
+ }
529
773
  }
530
774
  const startupSocket = state.socketUdp;
531
775
  await new Promise((resolve, reject) => {
@@ -691,6 +935,13 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
691
935
  state.readyToSend = true;
692
936
  _setStatus("UDP socket recovered", true);
693
937
  app.debug(`[${instanceId}] UDP socket recovered`);
938
+ // A socket-level recovery is the strongest local signal that the
939
+ // remote receiver may have restarted. Re-prime its meta cache
940
+ // with a full snapshot so it doesn't have to wait a full
941
+ // `intervalSec` for periodic resend.
942
+ if (state.metaConfig?.enabled) {
943
+ scheduleMetadataSnapshot(1000);
944
+ }
694
945
  }
695
946
  catch (recoveryErr) {
696
947
  state.socketRecoveryInProgress = false;
@@ -751,6 +1002,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
751
1002
  const v2Pipeline = (0, pipeline_v2_client_1.createPipelineV2Client)(appProxy, state, metricsApi);
752
1003
  state.pipeline = v2Pipeline;
753
1004
  v2Pipeline.setMonitoring(state.monitoring);
1005
+ if (typeof v2Pipeline.setMetaRequestHandler === "function") {
1006
+ v2Pipeline.setMetaRequestHandler(handleMetaRequest);
1007
+ }
754
1008
  v2Pipeline.startMetricsPublishing();
755
1009
  if (options.congestionControl && options.congestionControl.enabled) {
756
1010
  v2Pipeline.startCongestionControl();
@@ -833,6 +1087,18 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
833
1087
  // Clear timers
834
1088
  clearInterval(state.helloMessageSender ?? undefined);
835
1089
  state.helloMessageSender = null;
1090
+ clearInterval(state.metaTimer ?? undefined);
1091
+ state.metaTimer = null;
1092
+ clearTimeout(state.metaDiffFlushTimer ?? undefined);
1093
+ state.metaDiffFlushTimer = null;
1094
+ for (const handle of state.metaSnapshotTimers) {
1095
+ clearTimeout(handle);
1096
+ }
1097
+ state.metaSnapshotTimers = [];
1098
+ state.metaDiffBuffer = [];
1099
+ state.metaConfig = null;
1100
+ state.pendingMetaConfig = undefined;
1101
+ metaCache.clear();
836
1102
  clearTimeout(state.deltaTimer ?? undefined);
837
1103
  state.deltaTimer = null;
838
1104
  clearTimeout(state.pendingRetry ?? undefined);
@@ -889,12 +1155,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
889
1155
  state.pingMonitor.stop();
890
1156
  state.pingMonitor = null;
891
1157
  }
892
- // Close UDP socket
1158
+ // Close UDP socket(s)
893
1159
  if (state.socketUdp) {
894
1160
  state.socketUdp.close();
895
1161
  state.socketUdp = null;
896
1162
  app.debug(`[${instanceId}] Stopped`);
897
1163
  }
1164
+ if (state.metaSocketUdp) {
1165
+ try {
1166
+ state.metaSocketUdp.close();
1167
+ }
1168
+ catch (err) {
1169
+ const msg = err instanceof Error ? err.message : String(err);
1170
+ app.debug(`[${instanceId}] Meta socket close failed: ${msg}`);
1171
+ }
1172
+ state.metaSocketUdp = null;
1173
+ }
898
1174
  _setStatus("Stopped", false);
899
1175
  }
900
1176
  // ── Public API ────────────────────────────────────────────────────────────