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.
@@ -19,12 +19,14 @@ exports.createPipelineV2Client = createPipelineV2Client;
19
19
  const CircularBuffer_1 = __importDefault(require("./CircularBuffer"));
20
20
  const crypto_1 = require("./crypto");
21
21
  const pathDictionary_1 = require("./pathDictionary");
22
+ const delta_sanitizer_1 = require("./delta-sanitizer");
22
23
  const pipeline_utils_1 = require("./pipeline-utils");
23
24
  const packet_1 = require("./packet");
24
25
  const retransmit_queue_1 = require("./retransmit-queue");
25
26
  const metrics_publisher_1 = require("./metrics-publisher");
26
27
  const congestion_1 = require("./congestion");
27
28
  const bonding_1 = require("./bonding");
29
+ const metadata_1 = require("./metadata");
28
30
  const constants_1 = require("./constants");
29
31
  /**
30
32
  * Creates the v2 client pipeline
@@ -100,6 +102,35 @@ function createPipelineV2Client(app, state, metricsApi) {
100
102
  let lastRetransmissions = 0;
101
103
  // Enhanced monitoring hooks (set externally via setMonitoring)
102
104
  let monitoringHooks = null;
105
+ // Callback fired when the receiver asks for a fresh metadata snapshot
106
+ // (META_REQUEST control packet). Wired up by instance.ts, which is the only
107
+ // layer that knows how to build a snapshot from `app.signalk.retrieve()`.
108
+ let metaRequestHandler = null;
109
+ let metaEnvelopeSeq = 0;
110
+ // Seed all four meta bandwidth counters so downstream consumers (metrics
111
+ // publishers, prometheus exporter, tests) always see numeric zeros rather
112
+ // than undefined on a fresh pipeline. Uses || 0 at write sites elsewhere as
113
+ // belt-and-braces, but consistent snapshots require consistent seeding.
114
+ if (metrics.bandwidth) {
115
+ if (metrics.bandwidth.metaBytesOut === undefined) {
116
+ metrics.bandwidth.metaBytesOut = 0;
117
+ }
118
+ if (metrics.bandwidth.metaPacketsOut === undefined) {
119
+ metrics.bandwidth.metaPacketsOut = 0;
120
+ }
121
+ if (metrics.bandwidth.metaBytesIn === undefined) {
122
+ metrics.bandwidth.metaBytesIn = 0;
123
+ }
124
+ if (metrics.bandwidth.metaPacketsIn === undefined) {
125
+ metrics.bandwidth.metaPacketsIn = 0;
126
+ }
127
+ if (metrics.bandwidth.metaSnapshotsSent === undefined) {
128
+ metrics.bandwidth.metaSnapshotsSent = 0;
129
+ }
130
+ if (metrics.bandwidth.metaDiffsSent === undefined) {
131
+ metrics.bandwidth.metaDiffsSent = 0;
132
+ }
133
+ }
103
134
  // RTT tracking for jitter calculation (CircularBuffer gives O(1) push with auto-eviction)
104
135
  const rttSamples = new CircularBuffer_1.default(10);
105
136
  let lastAckedSeq = null;
@@ -116,12 +147,33 @@ function createPipelineV2Client(app, state, metricsApi) {
116
147
  const distance = (seq - reference) >>> 0;
117
148
  return distance !== 0 && distance < 0x80000000;
118
149
  }
150
+ function isSingleDeltaPayload(deltaPayload) {
151
+ return !Array.isArray(deltaPayload) && Array.isArray(deltaPayload.updates);
152
+ }
153
+ function deltaPayloadItems(deltaPayload) {
154
+ if (Array.isArray(deltaPayload)) {
155
+ return deltaPayload;
156
+ }
157
+ if (isSingleDeltaPayload(deltaPayload)) {
158
+ return [deltaPayload];
159
+ }
160
+ return Object.values(deltaPayload);
161
+ }
162
+ function encodeDeltaPayload(deltaPayload) {
163
+ if (Array.isArray(deltaPayload)) {
164
+ return deltaPayload.map(pathDictionary_1.encodeDelta);
165
+ }
166
+ if (isSingleDeltaPayload(deltaPayload)) {
167
+ return (0, pathDictionary_1.encodeDelta)(deltaPayload);
168
+ }
169
+ return Object.fromEntries(Object.entries(deltaPayload).map(([key, value]) => [key, (0, pathDictionary_1.encodeDelta)(value)]));
170
+ }
119
171
  function recordPathLatencies(deltaPayload) {
120
172
  if (!monitoringHooks || !monitoringHooks.pathLatencyTracker) {
121
173
  return;
122
174
  }
123
175
  const now = Date.now();
124
- const deltas = Array.isArray(deltaPayload) ? deltaPayload : [deltaPayload];
176
+ const deltas = deltaPayloadItems(deltaPayload);
125
177
  for (const delta of deltas) {
126
178
  if (!delta || !Array.isArray(delta.updates)) {
127
179
  continue;
@@ -253,22 +305,23 @@ function createPipelineV2Client(app, state, metricsApi) {
253
305
  app.debug("sendDelta called but plugin is stopped, ignoring");
254
306
  return;
255
307
  }
308
+ const sanitizedDelta = (0, delta_sanitizer_1.sanitizeDeltaPayloadForSignalK)(delta);
309
+ if (sanitizedDelta === null) {
310
+ app.debug("sendDelta skipped: no valid Signal K values");
311
+ return;
312
+ }
256
313
  // Apply path dictionary encoding if enabled
257
314
  const processedDelta = state.options.usePathDictionary
258
- ? Array.isArray(delta)
259
- ? delta.map(pathDictionary_1.encodeDelta)
260
- : (0, pathDictionary_1.encodeDelta)(delta)
261
- : delta;
315
+ ? encodeDeltaPayload(sanitizedDelta)
316
+ : sanitizedDelta;
262
317
  // Serialize to buffer
263
318
  const serialized = (0, pipeline_utils_1.deltaBuffer)(processedDelta, state.options.useMsgpack);
264
319
  metrics.bandwidth.bytesOutRaw += serialized.length;
265
- if (Array.isArray(delta)) {
266
- delta.forEach((d) => trackPathStats(d, serialized.length / delta.length));
320
+ const sanitizedItems = deltaPayloadItems(sanitizedDelta);
321
+ for (const item of sanitizedItems) {
322
+ trackPathStats(item, serialized.length / sanitizedItems.length);
267
323
  }
268
- else {
269
- trackPathStats(delta, serialized.length);
270
- }
271
- recordPathLatencies(delta);
324
+ recordPathLatencies(sanitizedDelta);
272
325
  // Compress
273
326
  const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, state.options?.useMsgpack ?? false);
274
327
  // Encrypt
@@ -317,7 +370,7 @@ function createPipelineV2Client(app, state, metricsApi) {
317
370
  lossWindow.push(false);
318
371
  // Update smart batching model
319
372
  // Guard against empty array: treat 0 as 1 to avoid Infinity in bytesPerDelta.
320
- const deltaCount = Array.isArray(delta) && delta.length > 0 ? delta.length : 1;
373
+ const deltaCount = Math.max(1, sanitizedItems.length);
321
374
  const bytesPerDelta = packet.length / deltaCount;
322
375
  state.avgBytesPerDelta =
323
376
  (1 - constants_1.SMART_BATCH_SMOOTHING) * state.avgBytesPerDelta +
@@ -345,6 +398,92 @@ function createPipelineV2Client(app, state, metricsApi) {
345
398
  throw error;
346
399
  }
347
400
  }
401
+ /**
402
+ * Send a batch of Signal K metadata entries to the receiver as one or more
403
+ * METADATA (0x06) packets. Mirrors the compress → encrypt → packet-build
404
+ * pipeline of `sendDelta` but uses a meta envelope so the receiver can
405
+ * reconstruct multi-chunk snapshots.
406
+ *
407
+ * Snapshots are NOT inserted into the retransmit queue — eventual
408
+ * consistency is provided by the periodic resend timer in instance.ts, and
409
+ * the receiver can always request a fresh snapshot via META_REQUEST.
410
+ */
411
+ async function sendMetadata(entries, kind, secretKey, udpAddress, udpPort) {
412
+ try {
413
+ if (!state.options) {
414
+ app.debug("sendMetadata called but plugin is stopped, ignoring");
415
+ return;
416
+ }
417
+ if (entries.length === 0) {
418
+ return;
419
+ }
420
+ const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
421
+ const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
422
+ const usePathDict = !!state.options.usePathDictionary;
423
+ const useMsgpack = !!state.options.useMsgpack;
424
+ // Assign one envelope seq per chunk group so the receiver can correlate
425
+ // `idx/total` inside a single snapshot/diff operation.
426
+ const envelopeSeq = metaEnvelopeSeq++ >>> 0;
427
+ for (let i = 0; i < chunks.length; i++) {
428
+ const chunk = chunks[i];
429
+ const processedEntries = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
430
+ const envelope = (0, metadata_1.buildMetaEnvelope)(processedEntries, kind, envelopeSeq, i, chunks.length);
431
+ const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
432
+ const compressed = await (0, pipeline_utils_1.compressPayload)(serialized, useMsgpack);
433
+ const encrypted = (0, crypto_1.encryptBinary)(compressed, secretKey, { stretchAsciiKey });
434
+ const packet = packetBuilder.buildMetadataPacket(encrypted, {
435
+ compressed: true,
436
+ encrypted: true,
437
+ messagepack: useMsgpack,
438
+ pathDictionary: usePathDict
439
+ });
440
+ // Mirror sendDelta's MTU guard + monitoring hooks so META traffic is
441
+ // visible to the same observability surfaces as DATA. Oversized META
442
+ // packets would otherwise fragment silently, and a user running a
443
+ // packet capture would see DATA but not META — confusing.
444
+ if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
445
+ app.debug(`Warning: v2 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD}), may fragment.`);
446
+ metrics.smartBatching.oversizedPackets++;
447
+ }
448
+ await udpSendAsync(packet, udpAddress, udpPort);
449
+ if (monitoringHooks) {
450
+ const rinfo = { address: udpAddress, port: udpPort };
451
+ if (monitoringHooks.packetCapture) {
452
+ monitoringHooks.packetCapture.capture(packet, "send", rinfo);
453
+ }
454
+ if (monitoringHooks.packetInspector) {
455
+ monitoringHooks.packetInspector.inspect(packet, "send", rinfo);
456
+ }
457
+ }
458
+ metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
459
+ metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
460
+ metrics.bandwidth.bytesOut += packet.length;
461
+ metrics.bandwidth.packetsOut++;
462
+ }
463
+ // Count one envelope per call (a multi-chunk envelope is logically one
464
+ // snapshot/diff, even though it shows up in metaPacketsOut as N).
465
+ if (kind === "snapshot") {
466
+ metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
467
+ }
468
+ else {
469
+ metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
470
+ }
471
+ app.debug(`v2 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
472
+ }
473
+ catch (error) {
474
+ const msg = error instanceof Error ? error.message : String(error);
475
+ app.error(`v2 sendMetadata error: ${msg}`);
476
+ recordError("general", `v2 sendMetadata error: ${msg}`);
477
+ // Re-throw so callers (e.g., sendMetaEntries in instance.ts) can
478
+ // distinguish a successful send from a swallowed failure. Without the
479
+ // rethrow the caller would commit the MetaCache despite nothing
480
+ // reaching the wire, silently suppressing the next diff.
481
+ throw error instanceof Error ? error : new Error(msg);
482
+ }
483
+ }
484
+ function setMetaRequestHandler(handler) {
485
+ metaRequestHandler = handler;
486
+ }
348
487
  /**
349
488
  * Handle incoming ACK packet from server.
350
489
  * Removes acknowledged packets from the retransmit queue.
@@ -461,6 +600,30 @@ function createPipelineV2Client(app, state, metricsApi) {
461
600
  else if (parsed.type === packet_1.PacketType.NAK) {
462
601
  await receiveNAK(parsed, rinfo.address, rinfo.port);
463
602
  }
603
+ else if (parsed.type === packet_1.PacketType.META_REQUEST) {
604
+ // Receiver asks us to re-send the full meta snapshot. Rate-limited in
605
+ // the handler (instance.ts) to prevent a malformed receiver from
606
+ // pinning our CPU/bandwidth on snapshot generation. The handler
607
+ // itself is synchronous, but if a future implementation returns a
608
+ // Promise we swallow rejections here so they don't bubble up into
609
+ // the control-packet parse error path (which increments
610
+ // metrics.malformedPackets and would mis-classify the failure).
611
+ if (metaRequestHandler) {
612
+ try {
613
+ // Wrap in Promise.resolve so any thenable (PromiseLike) returned
614
+ // by the handler — not just real Promises with .catch — gets a
615
+ // .catch attached. This handles unusual user-supplied thenables
616
+ // and is simpler than feature-detecting .catch / .then directly.
617
+ Promise.resolve(metaRequestHandler()).catch((err) => {
618
+ app.debug(`META_REQUEST handler rejected: ${err instanceof Error ? err.message : String(err)}`);
619
+ });
620
+ }
621
+ catch (err) {
622
+ const errMsg = err instanceof Error ? err.message : String(err);
623
+ app.debug(`META_REQUEST handler error: ${errMsg}`);
624
+ }
625
+ }
626
+ }
464
627
  // Ignore other packet types on client side
465
628
  }
466
629
  catch (err) {
@@ -792,6 +955,8 @@ function createPipelineV2Client(app, state, metricsApi) {
792
955
  }
793
956
  return {
794
957
  sendDelta,
958
+ sendMetadata,
959
+ setMetaRequestHandler,
795
960
  getPacketBuilder,
796
961
  getRetransmitQueue,
797
962
  getMetricsPublisher,
@@ -55,6 +55,7 @@ const node_zlib_1 = __importDefault(require("node:zlib"));
55
55
  const msgpack = __importStar(require("@msgpack/msgpack"));
56
56
  const crypto_1 = require("./crypto");
57
57
  const pathDictionary_1 = require("./pathDictionary");
58
+ const delta_sanitizer_1 = require("./delta-sanitizer");
58
59
  const packet_1 = require("./packet");
59
60
  const sequence_1 = require("./sequence");
60
61
  const metrics_publisher_1 = require("./metrics-publisher");
@@ -95,6 +96,12 @@ function createPipelineV2Server(app, state, metricsApi) {
95
96
  // session table (MAX_CLIENT_SESSIONS) by spoofing many source ports from one
96
97
  // IP, evicting all legitimate sessions (DoS).
97
98
  const MAX_SESSIONS_PER_IP = 5;
99
+ // Threshold for sender-restart detection on the META envelope sequence.
100
+ // An incoming envSeq of 0 is treated as a peer restart only once
101
+ // lastMetaEnvSeq has advanced beyond this value — below it, envSeq=0 is
102
+ // ambiguous (could be a legitimate first-packet replay) and falls through
103
+ // to normal dedup. UDP reorders >8 packets backwards are exceedingly rare.
104
+ const META_RESTART_THRESHOLD = 8;
98
105
  let ackTimer = null;
99
106
  /**
100
107
  * Per-client session map, keyed by "address:port".
@@ -165,7 +172,10 @@ function createPipelineV2Server(app, state, metricsApi) {
165
172
  lastLossExpected: 0,
166
173
  lastLossReceived: 0,
167
174
  rateLimitCount: 0,
168
- rateLimitWindowStart: Date.now()
175
+ rateLimitWindowStart: Date.now(),
176
+ metaRequested: false,
177
+ lastMetaEnvSeq: null,
178
+ seenMetaChunkIdx: new Set()
169
179
  };
170
180
  }
171
181
  const session = {
@@ -191,7 +201,12 @@ function createPipelineV2Server(app, state, metricsApi) {
191
201
  lastLossReceived: 0,
192
202
  // per-session UDP rate limiting
193
203
  rateLimitCount: 0,
194
- rateLimitWindowStart: Date.now()
204
+ rateLimitWindowStart: Date.now(),
205
+ // META_REQUEST bookkeeping
206
+ metaRequested: false,
207
+ // Stale-envelope rejection for METADATA packets
208
+ lastMetaEnvSeq: null,
209
+ seenMetaChunkIdx: new Set()
195
210
  };
196
211
  clientSessions.set(key, session);
197
212
  app.debug(`[v2-server] new client session: ${key}`);
@@ -435,6 +450,188 @@ function createPipelineV2Server(app, state, metricsApi) {
435
450
  ackTimer = null;
436
451
  }
437
452
  }
453
+ /**
454
+ * Decrypt and dispatch a METADATA (0x06) packet.
455
+ *
456
+ * The payload envelope is `{ v, kind, seq, idx, total, entries }` where each
457
+ * entry is `{ context, path, meta }`. We convert every entry back into a
458
+ * minimal Signal K delta carrying `updates[].meta[]` so the local Signal K
459
+ * server picks it up through the normal `app.handleMessage` integration
460
+ * point — no special receiver API is needed.
461
+ */
462
+ async function handleMetadataPacket(parsed, secretKey, session) {
463
+ try {
464
+ const decrypted = (0, crypto_1.decryptBinary)(parsed.payload, secretKey, { stretchAsciiKey });
465
+ const decompressed = (await brotliDecompressAsync(decrypted, {
466
+ maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
467
+ }));
468
+ // Count a successful decrypt+decompress as "meta received on the wire"
469
+ // regardless of whether the envelope parses — this mirrors the DATA
470
+ // bandwidth accounting and keeps metaBytesIn useful even when a peer
471
+ // emits malformed envelopes.
472
+ metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + parsed.payload.length;
473
+ metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
474
+ if (decompressed.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
475
+ metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
476
+ app.error(`[v2] META payload too large to parse: ${decompressed.length} bytes (limit ${constants_1.MAX_PARSE_PAYLOAD_SIZE})`);
477
+ recordError("general", `META payload too large: ${decompressed.length} bytes (limit ${constants_1.MAX_PARSE_PAYLOAD_SIZE})`);
478
+ return;
479
+ }
480
+ let content;
481
+ if (parsed.flags.messagepack) {
482
+ try {
483
+ content = msgpack.decode(decompressed);
484
+ }
485
+ catch {
486
+ content = JSON.parse(decompressed.toString());
487
+ }
488
+ }
489
+ else {
490
+ content = JSON.parse(decompressed.toString());
491
+ }
492
+ if (!content || typeof content !== "object" || Array.isArray(content)) {
493
+ metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
494
+ app.debug("v2 META envelope was not an object, dropping");
495
+ recordError("general", "v2 META envelope was not an object");
496
+ return;
497
+ }
498
+ const env = content;
499
+ if (!Array.isArray(env.entries) || env.entries.length === 0) {
500
+ metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
501
+ app.debug("v2 META envelope has no entries, dropping");
502
+ recordError("general", "v2 META envelope has no entries");
503
+ return;
504
+ }
505
+ // Drop stale/duplicate envelopes that UDP reordered or replayed. The
506
+ // inner envelope `seq` identifies a batch (shared across all chunks of
507
+ // a multi-chunk snapshot/diff); the inner `idx` identifies a specific
508
+ // chunk within that batch. Two-level dedup:
509
+ // - reject envelopes whose seq is behind in uint32 space ("stale")
510
+ // - within the current batch, reject any (seq, idx) pair already
511
+ // processed ("exact replay"); other idx values for the same seq
512
+ // remain accepted so multi-chunk batches still apply in full.
513
+ if (session && typeof env.seq === "number" && Number.isFinite(env.seq)) {
514
+ const envSeq = env.seq >>> 0;
515
+ const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
516
+ // Sender-restart detection: the client's meta sequence counter is
517
+ // initialised to 0 at process start, so an incoming envSeq of 0 with
518
+ // a sufficiently-advanced lastMetaEnvSeq is a strong signal that the
519
+ // peer restarted. The "sufficiently advanced" threshold guards
520
+ // against the first-packet-replay case (lastMetaEnvSeq=0, envSeq=0)
521
+ // which is indistinguishable from a true restart unless prior
522
+ // traffic has pushed the seq above a small reorder window. Backwards
523
+ // reorders of more than META_RESTART_THRESHOLD packets are
524
+ // exceedingly rare in UDP delivery; treating envSeq=0 as a restart
525
+ // only above that threshold keeps the dedup-vs-restart decision
526
+ // unambiguous in the common case.
527
+ if (session.lastMetaEnvSeq !== null &&
528
+ envSeq === 0 &&
529
+ session.lastMetaEnvSeq >= META_RESTART_THRESHOLD) {
530
+ app.debug(`[v2-server] META sender restart detected for ${session.key} ` +
531
+ `(last seq was ${session.lastMetaEnvSeq}); resetting meta state`);
532
+ session.lastMetaEnvSeq = null;
533
+ session.seenMetaChunkIdx.clear();
534
+ session.metaRequested = false;
535
+ }
536
+ if (session.lastMetaEnvSeq !== null) {
537
+ const distance = (envSeq - session.lastMetaEnvSeq) >>> 0;
538
+ if (distance !== 0 && distance >= 0x80000000) {
539
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
540
+ app.debug(`[v2-server] stale META envelope seq=${envSeq} from ${session.key} (last=${session.lastMetaEnvSeq}), dropping`);
541
+ return;
542
+ }
543
+ if (distance !== 0) {
544
+ // New batch: advance and reset the per-batch chunk set.
545
+ session.lastMetaEnvSeq = envSeq;
546
+ session.seenMetaChunkIdx.clear();
547
+ }
548
+ else if (session.seenMetaChunkIdx.has(envIdx)) {
549
+ // Same batch, exact duplicate chunk — drop.
550
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
551
+ app.debug(`[v2-server] duplicate META chunk seq=${envSeq} idx=${envIdx} from ${session.key}, dropping`);
552
+ return;
553
+ }
554
+ }
555
+ else {
556
+ session.lastMetaEnvSeq = envSeq;
557
+ session.seenMetaChunkIdx.clear();
558
+ }
559
+ session.seenMetaChunkIdx.add(envIdx);
560
+ }
561
+ // Group entries by context so the local Signal K server sees one delta
562
+ // per context rather than one per path. Reduces app.handleMessage
563
+ // overhead on big snapshots without changing semantics.
564
+ const nowIso = new Date().toISOString();
565
+ const byContext = new Map();
566
+ for (const rawEntry of env.entries) {
567
+ if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
568
+ continue;
569
+ }
570
+ // Require a present path before attempting pathDictionary decode;
571
+ // decodeMetaEntry would otherwise coerce `undefined` into "undefined".
572
+ if (rawEntry.path === null || rawEntry.path === undefined) {
573
+ continue;
574
+ }
575
+ if (typeof rawEntry.path !== "string" && typeof rawEntry.path !== "number") {
576
+ continue;
577
+ }
578
+ const entry = parsed.flags.pathDictionary
579
+ ? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
580
+ : rawEntry;
581
+ const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
582
+ if (!path) {
583
+ continue;
584
+ }
585
+ const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
586
+ const bucket = byContext.get(context);
587
+ const metaItem = { path, value: entry.meta };
588
+ if (bucket) {
589
+ bucket.push(metaItem);
590
+ }
591
+ else {
592
+ byContext.set(context, [metaItem]);
593
+ }
594
+ }
595
+ for (const [context, metaItems] of byContext) {
596
+ const deltaMessage = {
597
+ context,
598
+ updates: [
599
+ {
600
+ timestamp: nowIso,
601
+ values: [],
602
+ meta: metaItems
603
+ }
604
+ ]
605
+ };
606
+ app.handleMessage("", deltaMessage);
607
+ }
608
+ app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}, contexts=${byContext.size}, envSeq=${env.v ?? "?"}`);
609
+ }
610
+ catch (err) {
611
+ const msg = err instanceof Error ? err.message : String(err);
612
+ metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
613
+ app.error(`v2 handleMetadataPacket error: ${msg}`);
614
+ recordError("general", `v2 META decode error: ${msg}`);
615
+ }
616
+ }
617
+ /**
618
+ * Build and send a META_REQUEST (0x07) control packet to a client.
619
+ * Instructs the client to emit a fresh metadata snapshot — used on first
620
+ * contact from a new session so the receiver doesn't have to wait for the
621
+ * client's periodic resend cycle.
622
+ */
623
+ async function _sendMetaRequest(session, secretKey) {
624
+ try {
625
+ const packet = packetBuilder.buildMetaRequestPacket({ secretKey });
626
+ await _sendUDP(packet, { address: session.address, port: session.port });
627
+ app.debug(`[v2-server] META_REQUEST sent to ${session.key}`);
628
+ }
629
+ catch (err) {
630
+ // Re-throw so the caller's .catch() records it once, rather than
631
+ // double-logging here.
632
+ throw err;
633
+ }
634
+ }
438
635
  /**
439
636
  * Send UDP packet to a destination
440
637
  * @private
@@ -519,6 +716,37 @@ function createPipelineV2Server(app, state, metricsApi) {
519
716
  catch (parseErr) {
520
717
  app.error(`v2 failed to parse HELLO payload: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
521
718
  }
719
+ // HELLO is the earliest reliable indication of a live peer, so use it
720
+ // as the trigger to demand a fresh metadata snapshot. The client
721
+ // self-rate-limits META_REQUEST responses (5 s window), and we only
722
+ // emit one per session, so this is safe even across rapid reconnects.
723
+ if (session && !session.metaRequested) {
724
+ session.metaRequested = true;
725
+ _sendMetaRequest(session, secretKey).catch((err) => {
726
+ app.debug(`[v2-server] META_REQUEST send failed: ${err instanceof Error ? err.message : String(err)}`);
727
+ });
728
+ }
729
+ return;
730
+ }
731
+ if (parsed.type === packet_1.PacketType.METADATA) {
732
+ // Apply the same per-session rate limit used for DATA so a malformed
733
+ // or hostile peer can't overwhelm the meta decoder path.
734
+ if (session) {
735
+ const now = Date.now();
736
+ if (now - session.rateLimitWindowStart >= constants_1.UDP_RATE_LIMIT_WINDOW) {
737
+ session.rateLimitCount = 0;
738
+ session.rateLimitWindowStart = now;
739
+ }
740
+ session.rateLimitCount++;
741
+ if (session.rateLimitCount > constants_1.UDP_RATE_LIMIT_MAX_PACKETS) {
742
+ metrics.rateLimitedPackets = (metrics.rateLimitedPackets || 0) + 1;
743
+ metrics.bandwidth.metaRateLimitedPackets =
744
+ (metrics.bandwidth.metaRateLimitedPackets || 0) + 1;
745
+ app.debug(`[v2-server] rate limited META from ${session.key}`);
746
+ return;
747
+ }
748
+ }
749
+ await handleMetadataPacket(parsed, secretKey, session);
522
750
  return;
523
751
  }
524
752
  if (parsed.type !== packet_1.PacketType.DATA) {
@@ -645,6 +873,12 @@ function createPipelineV2Server(app, state, metricsApi) {
645
873
  app.debug(`v2 skipping null delta after decoding at index ${i}`);
646
874
  continue;
647
875
  }
876
+ const sanitizedDelta = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(deltaMessage);
877
+ if (sanitizedDelta === null) {
878
+ app.debug(`v2 skipping delta with no valid Signal K values at index ${i}`);
879
+ continue;
880
+ }
881
+ deltaMessage = sanitizedDelta;
648
882
  _ingestRemoteTelemetry(deltaMessage);
649
883
  if (!Array.isArray(deltaMessage.updates) || deltaMessage.updates.length === 0) {
650
884
  continue;