signalk-edge-link 2.2.1 → 2.4.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.
@@ -18,6 +18,7 @@ exports.VALID_CONNECTION_KEYS = [
18
18
  "useMsgpack",
19
19
  "usePathDictionary",
20
20
  "enableNotifications",
21
+ "skipOwnData",
21
22
  "protocolVersion",
22
23
  "udpAddress",
23
24
  "helloMessageSender",
@@ -75,6 +76,9 @@ function validateConnectionConfig(connection, prefix = "") {
75
76
  if (conn.alertThresholds !== undefined) {
76
77
  return `${p}alertThresholds is not supported in server mode`;
77
78
  }
79
+ if (conn.skipOwnData !== undefined) {
80
+ return `${p}skipOwnData is not supported in server mode`;
81
+ }
78
82
  }
79
83
  if (!isValidPort(conn.udpPort, 1024)) {
80
84
  return `${p}udpPort must be an integer between 1024 and 65535`;
@@ -103,6 +107,9 @@ function validateConnectionConfig(connection, prefix = "") {
103
107
  if (conn.enableNotifications !== undefined && typeof conn.enableNotifications !== "boolean") {
104
108
  return `${p}enableNotifications must be a boolean`;
105
109
  }
110
+ if (conn.skipOwnData !== undefined && typeof conn.skipOwnData !== "boolean") {
111
+ return `${p}skipOwnData must be a boolean`;
112
+ }
106
113
  if (conn.name !== undefined &&
107
114
  (typeof conn.name !== "string" || conn.name.length > 40)) {
108
115
  return `${p}name must be a string of at most 40 characters`;
@@ -328,6 +335,7 @@ function sanitizeConnectionConfig(connection) {
328
335
  delete out.congestionControl;
329
336
  delete out.bonding;
330
337
  delete out.alertThresholds;
338
+ delete out.skipOwnData;
331
339
  }
332
340
  return out;
333
341
  }
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stripOwnDataFromDelta = stripOwnDataFromDelta;
4
+ exports.sanitizeDeltaForSignalK = sanitizeDeltaForSignalK;
5
+ exports.sanitizeDeltaPayloadForSignalK = sanitizeDeltaPayloadForSignalK;
6
+ /**
7
+ * Path prefixes for data this plugin publishes locally. When the
8
+ * `skipOwnData` option is set on a client connection, value entries with
9
+ * matching paths are stripped before the delta is forwarded over the link so
10
+ * the receiver's Signal K tree is not polluted with the sender's own
11
+ * edge-link metrics. The `networking.edgeLink.*` subtree is owned entirely
12
+ * by this plugin so the whole prefix is matched.
13
+ */
14
+ const OWN_DATA_PATH_PREFIXES = ["networking.edgeLink."];
15
+ /**
16
+ * RTT paths the plugin publishes — kept by `stripOwnDataFromDelta` even when
17
+ * `skipOwnData` is on, because operators rely on RTT for link-health
18
+ * visibility on both sides of the link. Covers v1 modem RTT
19
+ * (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`) and v2
20
+ * edge-link RTT (`networking.edgeLink.rtt`,
21
+ * `networking.edgeLink.<instanceId>.rtt`).
22
+ */
23
+ const RTT_PATH_RE = /^networking\.(?:modem|edgeLink)(?:\.[^.]+)?\.rtt$/;
24
+ function isOwnDataPath(path) {
25
+ if (typeof path !== "string") {
26
+ return false;
27
+ }
28
+ // RTT paths (modem + edgeLink, namespaced or not) are always forwarded so
29
+ // the receiver retains link-health visibility regardless of skipOwnData.
30
+ if (RTT_PATH_RE.test(path)) {
31
+ return false;
32
+ }
33
+ for (const prefix of OWN_DATA_PATH_PREFIXES) {
34
+ // prefix.slice(0, -1) drops the trailing ".", so a published path that
35
+ // matches the prefix root exactly (e.g. just "networking.edgeLink") still
36
+ // counts as own data; startsWith(prefix) covers everything underneath.
37
+ if (path === prefix.slice(0, -1) || path.startsWith(prefix)) {
38
+ return true;
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ /**
44
+ * Drop value/meta entries whose paths are owned by this plugin. Returns null
45
+ * when nothing remains to forward. Updates that become empty are dropped; the
46
+ * delta is dropped entirely when no updates survive.
47
+ */
48
+ function stripOwnDataFromDelta(delta) {
49
+ if (!delta || !Array.isArray(delta.updates)) {
50
+ return null;
51
+ }
52
+ let changed = false;
53
+ const surviving = [];
54
+ for (const update of delta.updates) {
55
+ const rawValues = Array.isArray(update.values) ? update.values : [];
56
+ const values = rawValues.filter((v) => !isOwnDataPath(v?.path));
57
+ const valuesChanged = values.length !== rawValues.length;
58
+ const rawMeta = Array.isArray(update.meta) ? update.meta : null;
59
+ const meta = rawMeta
60
+ ? rawMeta.filter((m) => !isOwnDataPath(m?.path))
61
+ : null;
62
+ const metaChanged = rawMeta !== null && meta !== null && meta.length !== rawMeta.length;
63
+ if (values.length === 0 && (!meta || meta.length === 0)) {
64
+ changed = true;
65
+ continue;
66
+ }
67
+ if (valuesChanged || metaChanged) {
68
+ changed = true;
69
+ const next = { ...update, values };
70
+ if (meta && meta.length > 0) {
71
+ next.meta = meta;
72
+ }
73
+ else if (rawMeta) {
74
+ delete next.meta;
75
+ }
76
+ surviving.push(next);
77
+ }
78
+ else {
79
+ surviving.push(update);
80
+ }
81
+ }
82
+ if (surviving.length === 0) {
83
+ return null;
84
+ }
85
+ if (!changed) {
86
+ return delta;
87
+ }
88
+ return { ...delta, updates: surviving };
89
+ }
90
+ function isObject(value) {
91
+ return value !== null && typeof value === "object" && !Array.isArray(value);
92
+ }
93
+ function isValidValuePath(path) {
94
+ return typeof path === "string" && path.trim().length > 0;
95
+ }
96
+ /**
97
+ * Remove Signal K value entries that the server will reject before calling
98
+ * app.handleMessage or forwarding over the link. Metadata-only updates are
99
+ * preserved, but updates with neither valid values nor meta are dropped.
100
+ */
101
+ function sanitizeDeltaForSignalK(delta) {
102
+ if (!delta || !Array.isArray(delta.updates)) {
103
+ return null;
104
+ }
105
+ let changed = false;
106
+ const sanitizedUpdates = [];
107
+ for (const rawUpdate of delta.updates) {
108
+ if (!isObject(rawUpdate)) {
109
+ changed = true;
110
+ continue;
111
+ }
112
+ const update = rawUpdate;
113
+ let updateChanged = false;
114
+ const rawValues = Array.isArray(update.values) ? update.values : [];
115
+ if (!Array.isArray(update.values)) {
116
+ updateChanged = true;
117
+ }
118
+ const values = [];
119
+ for (const rawValue of rawValues) {
120
+ if (!isObject(rawValue) || !isValidValuePath(rawValue.path)) {
121
+ updateChanged = true;
122
+ continue;
123
+ }
124
+ values.push(rawValue);
125
+ }
126
+ if (values.length !== rawValues.length) {
127
+ updateChanged = true;
128
+ }
129
+ const hasMeta = Array.isArray(update.meta) && update.meta.length > 0;
130
+ if (values.length === 0 && !hasMeta) {
131
+ changed = true;
132
+ continue;
133
+ }
134
+ if (updateChanged) {
135
+ changed = true;
136
+ }
137
+ sanitizedUpdates.push(updateChanged ? { ...update, values } : update);
138
+ }
139
+ if (sanitizedUpdates.length === 0) {
140
+ return null;
141
+ }
142
+ if (!changed && sanitizedUpdates.length === delta.updates.length) {
143
+ return delta;
144
+ }
145
+ return {
146
+ ...delta,
147
+ updates: sanitizedUpdates
148
+ };
149
+ }
150
+ function isDeltaLike(value) {
151
+ return isObject(value) && Array.isArray(value.updates);
152
+ }
153
+ function sanitizeDeltaPayloadForSignalK(delta) {
154
+ if (Array.isArray(delta)) {
155
+ const sanitized = delta
156
+ .map((item) => sanitizeDeltaForSignalK(item))
157
+ .filter((item) => item !== null);
158
+ return sanitized.length > 0 ? sanitized : null;
159
+ }
160
+ if (isDeltaLike(delta)) {
161
+ return sanitizeDeltaForSignalK(delta);
162
+ }
163
+ const sanitizedEntries = [];
164
+ for (const [key, value] of Object.entries(delta)) {
165
+ const sanitized = sanitizeDeltaForSignalK(value);
166
+ if (sanitized !== null) {
167
+ sanitizedEntries.push([key, sanitized]);
168
+ }
169
+ }
170
+ return sanitizedEntries.length > 0 ? Object.fromEntries(sanitizedEntries) : null;
171
+ }
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,185 @@ 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. When `skipOwnData` is set on
209
+ * a client connection, also drop value/meta entries this plugin publishes
210
+ * locally under the `networking.edgeLink.*` subtree, so the receiver's
211
+ * Signal K tree is not polluted with the sender's own edge-link metrics.
212
+ *
213
+ * Exception: RTT paths are always forwarded regardless of skipOwnData so
214
+ * the operator retains link-health visibility on both sides of the link.
215
+ * The carve-out covers both v2 edge-link RTT
216
+ * (`networking.edgeLink.rtt`, `networking.edgeLink.<instanceId>.rtt`) and
217
+ * the v1 modem RTT paths historically published by `publishRtt`
218
+ * (`networking.modem.rtt`, `networking.modem.<instanceId>.rtt`). See
219
+ * `stripOwnDataFromDelta` in `delta-sanitizer.ts` for the implementation.
201
220
  */
202
221
  function filterOutboundDelta(delta) {
203
- if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
204
- return null;
222
+ const sanitized = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
223
+ if (!sanitized || !options.skipOwnData) {
224
+ return sanitized;
225
+ }
226
+ return (0, delta_sanitizer_1.stripOwnDataFromDelta)(sanitized);
227
+ }
228
+ // ── Metadata streaming ────────────────────────────────────────────────────
229
+ /** In-memory cache of last-sent meta (hashed) per context+path. Used to
230
+ * compute diffs and to skip no-op periodic resends. */
231
+ const metaCache = new metadata_1.MetaCache();
232
+ /** Debounce window for coalescing live meta entries observed in the delta
233
+ * stream before they are transmitted as a single `diff` packet. */
234
+ const META_DIFF_DEBOUNCE_MS = 500;
235
+ /** Minimum gap between receiver-initiated snapshot sends. Prevents a noisy
236
+ * or malicious receiver from forcing snapshots on every delta. */
237
+ const META_REQUEST_RATE_LIMIT_MS = 5000;
238
+ /** Dispatches `entries` through the active pipeline. Returns true on a
239
+ * successful send so callers (e.g. `enqueueMetaDiff`) can decide whether
240
+ * to commit the MetaCache. Any failure is logged and returns false. */
241
+ async function sendMetaEntries(entries, kind) {
242
+ if (!options.udpAddress || !options.secretKey) {
243
+ return false;
244
+ }
245
+ if (entries.length === 0) {
246
+ return false;
247
+ }
248
+ const protoVer = options.protocolVersion ?? 2;
249
+ try {
250
+ if (protoVer === 1) {
251
+ if (!options.udpMetaPort || options.udpMetaPort <= 0) {
252
+ app.debug(`[${instanceId}] Meta skipped: v1 pipeline requires 'udpMetaPort' in connection config`);
253
+ return false;
254
+ }
255
+ await getV1Pipeline().packCryptMeta(entries, kind, options.secretKey, options.udpAddress, options.udpMetaPort);
256
+ }
257
+ else if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
258
+ await state.pipeline.sendMetadata(entries, kind, options.secretKey, options.udpAddress, options.udpPort);
259
+ }
260
+ else {
261
+ app.debug(`[${instanceId}] Meta skipped: pipeline not ready or does not support sendMetadata`);
262
+ return false;
263
+ }
264
+ return true;
265
+ }
266
+ catch (err) {
267
+ const msg = err instanceof Error ? err.message : String(err);
268
+ app.error(`[${instanceId}] sendMetaEntries failed: ${msg}`);
269
+ recordError("general", `sendMetaEntries failed: ${msg}`);
270
+ return false;
271
+ }
272
+ }
273
+ /**
274
+ * Build and transmit a full metadata snapshot from the current Signal K
275
+ * state tree. Resets the internal diff cache afterwards so the next diff is
276
+ * measured against what was just sent.
277
+ */
278
+ async function sendMetadataSnapshot() {
279
+ if (!state.metaConfig?.enabled || state.stopped || !state.readyToSend) {
280
+ return;
281
+ }
282
+ const entries = (0, metadata_1.collectSnapshot)(appProxy, state.metaConfig);
283
+ const sent = await sendMetaEntries(entries, "snapshot");
284
+ // Only prime the diff cache on a successful send; on failure the next
285
+ // snapshot (periodic or META_REQUEST-triggered) will still cover every
286
+ // path rather than the cache showing stale "already sent" state.
287
+ if (sent) {
288
+ metaCache.replaceAll(entries);
289
+ }
290
+ }
291
+ /** Coalesces live meta diffs extracted from deltas; flushes after a short
292
+ * debounce window so a burst of meta changes becomes one packet. */
293
+ function enqueueMetaDiff(entries) {
294
+ // Buffer raw entries; the actual change-detection (and cache commit)
295
+ // happens in the flush handler so a failed send doesn't leave the
296
+ // MetaCache thinking it transmitted something it never did.
297
+ if (entries.length === 0) {
298
+ return;
299
+ }
300
+ state.metaDiffBuffer.push(...entries);
301
+ if (state.metaDiffFlushTimer) {
302
+ return;
303
+ }
304
+ state.metaDiffFlushTimer = setTimeout(() => {
305
+ state.metaDiffFlushTimer = null;
306
+ const pending = state.metaDiffBuffer;
307
+ state.metaDiffBuffer = [];
308
+ const changed = metaCache.computeDiff(pending);
309
+ if (changed.length === 0) {
310
+ return;
311
+ }
312
+ sendMetaEntries(changed, "diff")
313
+ .then((sent) => {
314
+ if (sent) {
315
+ metaCache.commit(changed);
316
+ }
317
+ })
318
+ .catch((err) => {
319
+ const msg = err instanceof Error ? err.message : String(err);
320
+ app.debug(`[${instanceId}] meta diff flush failed: ${msg}`);
321
+ });
322
+ }, META_DIFF_DEBOUNCE_MS);
323
+ }
324
+ function restartMetadataTimer() {
325
+ if (state.metaTimer) {
326
+ clearInterval(state.metaTimer);
327
+ state.metaTimer = null;
328
+ }
329
+ if (!state.metaConfig?.enabled) {
330
+ return;
205
331
  }
206
- return delta;
332
+ const intervalMs = Math.max(30, state.metaConfig.intervalSec) * 1000;
333
+ state.metaTimer = setInterval(() => {
334
+ sendMetadataSnapshot().catch((err) => {
335
+ const msg = err instanceof Error ? err.message : String(err);
336
+ app.debug(`[${instanceId}] periodic snapshot failed: ${msg}`);
337
+ });
338
+ }, intervalMs);
339
+ }
340
+ /** Schedules a meta snapshot send after `delayMs`. Cancels any prior
341
+ * pending snapshot timer first — back-to-back (re)subscribes or socket
342
+ * recoveries should coalesce into a single pending snapshot rather than
343
+ * queue up multiple sends. The returned timer is tracked on
344
+ * state.metaSnapshotTimers so stop() can cancel it. */
345
+ function scheduleMetadataSnapshot(delayMs) {
346
+ for (const existing of state.metaSnapshotTimers) {
347
+ clearTimeout(existing);
348
+ }
349
+ state.metaSnapshotTimers.length = 0;
350
+ const handle = setTimeout(() => {
351
+ const idx = state.metaSnapshotTimers.indexOf(handle);
352
+ if (idx !== -1) {
353
+ state.metaSnapshotTimers.splice(idx, 1);
354
+ }
355
+ if (state.stopped) {
356
+ return;
357
+ }
358
+ sendMetadataSnapshot().catch(() => {
359
+ /* errors already logged inside sendMetadataSnapshot */
360
+ });
361
+ }, delayMs);
362
+ state.metaSnapshotTimers.push(handle);
363
+ }
364
+ /** Receiver asked for a fresh meta snapshot (META_REQUEST control packet).
365
+ * Rate-limited so a malformed or buggy receiver cannot force continuous
366
+ * snapshot work on the edge-link. */
367
+ function handleMetaRequest() {
368
+ if (!state.metaConfig?.enabled) {
369
+ return;
370
+ }
371
+ const now = Date.now();
372
+ if (now - state.lastMetaRequestAt < META_REQUEST_RATE_LIMIT_MS) {
373
+ return;
374
+ }
375
+ state.lastMetaRequestAt = now;
376
+ sendMetadataSnapshot().catch((err) => {
377
+ const msg = err instanceof Error ? err.message : String(err);
378
+ app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
379
+ });
380
+ }
381
+ /** Thin wrapper around the parser in `metadata.ts` so the instance log
382
+ * line is tagged with this connection's instanceId. Errors from the
383
+ * shared parser already have the `[meta-config]` prefix. */
384
+ function parseMetaConfig(raw) {
385
+ return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
207
386
  }
208
387
  /**
209
388
  * Processes an incoming delta from the subscription manager.
@@ -279,6 +458,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
279
458
  if (!state.readyToSend) {
280
459
  return;
281
460
  }
461
+ // Capture live meta BEFORE the delta flows into the pipeline encoder,
462
+ // because pathDictionary.transformDelta will strip `updates[].meta[]` when
463
+ // rebuilding the update objects. `extractLiveMeta` returns [] when meta
464
+ // streaming is disabled, so this is zero-cost in the default off state.
465
+ if (state.metaConfig?.enabled) {
466
+ const liveMeta = (0, metadata_1.extractLiveMeta)(delta, state.metaConfig, (0, metadata_1.resolveSelfContext)(appProxy));
467
+ if (liveMeta.length > 0) {
468
+ enqueueMetaDiff(liveMeta);
469
+ }
470
+ }
282
471
  const outboundDelta = filterOutboundDelta(delta);
283
472
  if (!outboundDelta) {
284
473
  return;
@@ -354,6 +543,21 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
354
543
  _setStatus("Subscription error - data transmission paused", false);
355
544
  recordError("subscription", `Subscription error: ${retrySubError}`);
356
545
  }, processDelta);
546
+ // Retry succeeded — perform the staged commit that the original
547
+ // processConfig catch block skipped. Without this, the operator's
548
+ // new meta block (stashed on state.pendingMetaConfig) would remain
549
+ // inactive even though subscribe() is now working.
550
+ if (state.pendingMetaConfig !== undefined) {
551
+ state.metaConfig = state.pendingMetaConfig;
552
+ state.pendingMetaConfig = undefined;
553
+ restartMetadataTimer();
554
+ metaCache.clear();
555
+ if (state.metaConfig?.enabled) {
556
+ scheduleMetadataSnapshot(2000);
557
+ }
558
+ }
559
+ state.readyToSend = true;
560
+ _setStatus("Subscription restored", true);
357
561
  }
358
562
  catch (retryError) {
359
563
  const msg = retryError instanceof Error ? retryError.message : String(retryError);
@@ -370,6 +574,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
370
574
  processConfig: (config) => {
371
575
  state.localSubscription = config;
372
576
  app.debug(`[${instanceId}] Subscription configuration updated`);
577
+ // Stage the new metadata config — do NOT yet touch state.metaConfig,
578
+ // the periodic timer, or metaCache. If subscribe() throws, the old
579
+ // subscription remains active until the retry succeeds, so its
580
+ // previous metadata behaviour must remain intact.
581
+ const previousMetaConfig = state.metaConfig;
582
+ const pendingMetaConfig = parseMetaConfig(config);
373
583
  // Capture the old cleanup handlers but do NOT call them yet.
374
584
  // We establish the new subscription first so data keeps flowing during
375
585
  // the handover; only after success do we release the old subscription.
@@ -386,11 +596,32 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
386
596
  }, processDelta);
387
597
  // New subscription established — release old cleanup handlers.
388
598
  previousUnsubscribes.forEach((f) => f());
599
+ // Commit the new metadata config AFTER a successful subscribe: swap
600
+ // state.metaConfig, (re)start the periodic timer, and reset the diff
601
+ // cache so the next snapshot represents the live state in full. We
602
+ // reset the cache unconditionally here because even "meta unchanged"
603
+ // still needs an empty cache for the new subscription's path set.
604
+ state.metaConfig = pendingMetaConfig;
605
+ restartMetadataTimer();
606
+ metaCache.clear();
607
+ // Prime the receiver's meta cache with a full snapshot once the
608
+ // Signal K state tree has had a moment to settle after (re)subscribe.
609
+ if (state.metaConfig?.enabled) {
610
+ scheduleMetadataSnapshot(2000);
611
+ }
389
612
  }
390
613
  catch (subscribeError) {
391
614
  // Re-subscribe failed — restore old handlers so stop() can still
392
615
  // clean up and the previous subscription remains active until retry.
616
+ // Leave state.metaConfig / metaCache / metaTimer untouched so the
617
+ // previous subscription's metadata stream keeps running unchanged.
393
618
  state.unsubscribes = previousUnsubscribes;
619
+ void previousMetaConfig; // explicit: intentionally unchanged
620
+ // Stash the new meta config on state so the scheduled retry can
621
+ // promote it when subscribe() finally succeeds. Otherwise the
622
+ // operator's new meta settings would silently sit unused until the
623
+ // user re-saved subscription.json.
624
+ state.pendingMetaConfig = pendingMetaConfig;
394
625
  const subErrMsg = subscribeError instanceof Error ? subscribeError.message : String(subscribeError);
395
626
  app.error(`[${instanceId}] Failed to subscribe: ${subErrMsg}`);
396
627
  state.readyToSend = false;
@@ -526,6 +757,34 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
526
757
  getV1Pipeline().unpackDecrypt(delta, options.secretKey);
527
758
  });
528
759
  app.debug(`[${instanceId}] [v1] Server pipeline initialized`);
760
+ // v1 has no packet-type byte, so meta is streamed on a separate UDP
761
+ // port by the client. Bind that port here when the operator has opted
762
+ // in. If `udpMetaPort` is unset we simply don't listen — keeping the
763
+ // receive side idle is the correct default for existing v1 peers that
764
+ // don't know about meta.
765
+ if (typeof options.udpMetaPort === "number" && options.udpMetaPort > 0) {
766
+ if (options.udpMetaPort === options.udpPort) {
767
+ app.error(`[${instanceId}] [v1] udpMetaPort (${options.udpMetaPort}) must differ from udpPort; meta disabled`);
768
+ }
769
+ else {
770
+ const metaSocket = dgram_1.default.createSocket({ type: "udp4", reuseAddr: true });
771
+ state.metaSocketUdp = metaSocket;
772
+ metaSocket.on("message", (msg) => {
773
+ getV1Pipeline()
774
+ .unpackDecryptMeta(msg, options.secretKey)
775
+ .catch((err) => {
776
+ const m = err instanceof Error ? err.message : String(err);
777
+ app.debug(`[${instanceId}] [v1] meta decrypt failed: ${m}`);
778
+ });
779
+ });
780
+ metaSocket.on("error", (err) => {
781
+ app.error(`[${instanceId}] [v1] meta socket error: ${err.message} (${err.code})`);
782
+ recordError("udpSend", `v1 meta socket error: ${err.message} (${err.code})`);
783
+ });
784
+ metaSocket.bind(options.udpMetaPort);
785
+ app.debug(`[${instanceId}] [v1] Meta listener bound to UDP :${options.udpMetaPort}`);
786
+ }
787
+ }
529
788
  }
530
789
  const startupSocket = state.socketUdp;
531
790
  await new Promise((resolve, reject) => {
@@ -691,6 +950,13 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
691
950
  state.readyToSend = true;
692
951
  _setStatus("UDP socket recovered", true);
693
952
  app.debug(`[${instanceId}] UDP socket recovered`);
953
+ // A socket-level recovery is the strongest local signal that the
954
+ // remote receiver may have restarted. Re-prime its meta cache
955
+ // with a full snapshot so it doesn't have to wait a full
956
+ // `intervalSec` for periodic resend.
957
+ if (state.metaConfig?.enabled) {
958
+ scheduleMetadataSnapshot(1000);
959
+ }
694
960
  }
695
961
  catch (recoveryErr) {
696
962
  state.socketRecoveryInProgress = false;
@@ -751,6 +1017,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
751
1017
  const v2Pipeline = (0, pipeline_v2_client_1.createPipelineV2Client)(appProxy, state, metricsApi);
752
1018
  state.pipeline = v2Pipeline;
753
1019
  v2Pipeline.setMonitoring(state.monitoring);
1020
+ if (typeof v2Pipeline.setMetaRequestHandler === "function") {
1021
+ v2Pipeline.setMetaRequestHandler(handleMetaRequest);
1022
+ }
754
1023
  v2Pipeline.startMetricsPublishing();
755
1024
  if (options.congestionControl && options.congestionControl.enabled) {
756
1025
  v2Pipeline.startCongestionControl();
@@ -833,6 +1102,18 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
833
1102
  // Clear timers
834
1103
  clearInterval(state.helloMessageSender ?? undefined);
835
1104
  state.helloMessageSender = null;
1105
+ clearInterval(state.metaTimer ?? undefined);
1106
+ state.metaTimer = null;
1107
+ clearTimeout(state.metaDiffFlushTimer ?? undefined);
1108
+ state.metaDiffFlushTimer = null;
1109
+ for (const handle of state.metaSnapshotTimers) {
1110
+ clearTimeout(handle);
1111
+ }
1112
+ state.metaSnapshotTimers = [];
1113
+ state.metaDiffBuffer = [];
1114
+ state.metaConfig = null;
1115
+ state.pendingMetaConfig = undefined;
1116
+ metaCache.clear();
836
1117
  clearTimeout(state.deltaTimer ?? undefined);
837
1118
  state.deltaTimer = null;
838
1119
  clearTimeout(state.pendingRetry ?? undefined);
@@ -889,12 +1170,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
889
1170
  state.pingMonitor.stop();
890
1171
  state.pingMonitor = null;
891
1172
  }
892
- // Close UDP socket
1173
+ // Close UDP socket(s)
893
1174
  if (state.socketUdp) {
894
1175
  state.socketUdp.close();
895
1176
  state.socketUdp = null;
896
1177
  app.debug(`[${instanceId}] Stopped`);
897
1178
  }
1179
+ if (state.metaSocketUdp) {
1180
+ try {
1181
+ state.metaSocketUdp.close();
1182
+ }
1183
+ catch (err) {
1184
+ const msg = err instanceof Error ? err.message : String(err);
1185
+ app.debug(`[${instanceId}] Meta socket close failed: ${msg}`);
1186
+ }
1187
+ state.metaSocketUdp = null;
1188
+ }
898
1189
  _setStatus("Stopped", false);
899
1190
  }
900
1191
  // ── Public API ────────────────────────────────────────────────────────────