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.
package/lib/pipeline.js CHANGED
@@ -35,8 +35,18 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  const msgpack = __importStar(require("@msgpack/msgpack"));
36
36
  const crypto_1 = require("./crypto");
37
37
  const pathDictionary_1 = require("./pathDictionary");
38
+ const delta_sanitizer_1 = require("./delta-sanitizer");
38
39
  const pipeline_utils_1 = require("./pipeline-utils");
39
40
  const constants_1 = require("./constants");
41
+ const metadata_1 = require("./metadata");
42
+ /** Leading magic that distinguishes v1 meta payloads from v1 deltas, placed
43
+ * inside the encrypted plaintext so existing v1 receivers (which do not
44
+ * recognise it) simply reject the packet rather than misinterpreting it. */
45
+ const V1_META_MAGIC = Buffer.from("SKM1", "ascii");
46
+ /** Threshold for v1 sender-restart detection — see the v2 server's
47
+ * META_RESTART_THRESHOLD comment. envSeq=0 is treated as a restart only when
48
+ * the last accepted seq has moved beyond this small reorder window. */
49
+ const META_RESTART_THRESHOLD_V1 = 8;
40
50
  /**
41
51
  * Creates the data processing pipeline (compress, encrypt, send / receive, decrypt, decompress).
42
52
  * @param app - SignalK app object (for logging)
@@ -47,6 +57,12 @@ const constants_1 = require("./constants");
47
57
  function createPipeline(app, state, metricsApi) {
48
58
  const { metrics, recordError, trackPathStats } = metricsApi;
49
59
  const setStatus = app.setPluginStatus || app.setProviderStatus || (() => { });
60
+ let metaEnvelopeSeqV1 = 0;
61
+ // Last accepted inner-envelope seq on the receive side. v1 has no
62
+ // per-session concept (one socket per pipeline instance), so a single
63
+ // closure variable is sufficient. Used to drop stale/duplicate envelopes
64
+ // that UDP reorders or replays.
65
+ let lastIngestedMetaEnvSeqV1 = null;
50
66
  /**
51
67
  * Compresses, encrypts, and sends delta data via UDP.
52
68
  * Pipeline: Serialize -> Compress -> Encrypt (AES-256-GCM) -> Send
@@ -131,6 +147,73 @@ function createPipeline(app, state, metricsApi) {
131
147
  }
132
148
  }
133
149
  }
150
+ /**
151
+ * Sends Signal K path metadata to the receiver using the v1 wire format on a
152
+ * separate UDP port.
153
+ *
154
+ * v1 has no packet-type byte so we cannot multiplex meta with deltas on the
155
+ * existing port without breaking every deployed v1 receiver. To keep the
156
+ * change backward-compatible, meta is sent on `udpMetaPort` with a 4-byte
157
+ * `SKM1` magic prefix inside the encrypted plaintext — a v1 receiver that
158
+ * has not been upgraded will fail to JSON-parse the payload and simply drop
159
+ * it without side effects.
160
+ */
161
+ async function packCryptMeta(entries, kind, secretKey, udpAddress, udpMetaPort) {
162
+ try {
163
+ if (!state.options) {
164
+ app.debug("packCryptMeta called but plugin is stopped, ignoring");
165
+ return;
166
+ }
167
+ if (!udpMetaPort || udpMetaPort <= 0) {
168
+ app.debug("packCryptMeta: no udpMetaPort configured, meta disabled on v1");
169
+ return;
170
+ }
171
+ if (entries.length === 0) {
172
+ return;
173
+ }
174
+ const usePathDict = !!state.options.usePathDictionary;
175
+ const useMsgpack = !!state.options.useMsgpack;
176
+ const maxPerPacket = state.metaConfig?.maxPathsPerPacket ?? 500;
177
+ const chunks = (0, metadata_1.splitIntoPackets)(entries, maxPerPacket);
178
+ const envelopeSeq = metaEnvelopeSeqV1++ >>> 0;
179
+ for (let i = 0; i < chunks.length; i++) {
180
+ const chunk = chunks[i];
181
+ const processed = usePathDict ? chunk.map(pathDictionary_1.encodeMetaEntry) : chunk;
182
+ const envelope = (0, metadata_1.buildMetaEnvelope)(processed, kind, envelopeSeq, i, chunks.length);
183
+ const serialized = (0, pipeline_utils_1.deltaBuffer)(envelope, useMsgpack);
184
+ const withMagic = Buffer.concat([V1_META_MAGIC, serialized]);
185
+ const compressed = await (0, pipeline_utils_1.compressPayload)(withMagic, useMsgpack);
186
+ const packet = (0, crypto_1.encryptBinary)(compressed, secretKey, {
187
+ stretchAsciiKey: !!state.options.stretchAsciiKey
188
+ });
189
+ if (packet.length > constants_1.MAX_SAFE_UDP_PAYLOAD) {
190
+ app.debug(`Warning: v1 meta packet size ${packet.length} bytes exceeds safe MTU (${constants_1.MAX_SAFE_UDP_PAYLOAD})`);
191
+ }
192
+ await udpSendAsync(packet, udpAddress, udpMetaPort);
193
+ metrics.bandwidth.metaBytesOut = (metrics.bandwidth.metaBytesOut || 0) + packet.length;
194
+ metrics.bandwidth.metaPacketsOut = (metrics.bandwidth.metaPacketsOut || 0) + 1;
195
+ metrics.bandwidth.bytesOut += packet.length;
196
+ metrics.bandwidth.packetsOut++;
197
+ }
198
+ if (kind === "snapshot") {
199
+ metrics.bandwidth.metaSnapshotsSent = (metrics.bandwidth.metaSnapshotsSent || 0) + 1;
200
+ }
201
+ else {
202
+ metrics.bandwidth.metaDiffsSent = (metrics.bandwidth.metaDiffsSent || 0) + 1;
203
+ }
204
+ app.debug(`v1 meta sent: kind=${kind}, entries=${entries.length}, chunks=${chunks.length}, envSeq=${envelopeSeq}`);
205
+ }
206
+ catch (error) {
207
+ const msg = error instanceof Error ? error.message : String(error);
208
+ app.error(`packCryptMeta error: ${msg}`);
209
+ recordError("general", `packCryptMeta error: ${msg}`);
210
+ // Re-throw so the caller (sendMetaEntries) can tell the send failed
211
+ // and refrain from committing the MetaCache. Without this, a broken
212
+ // socket/encryption/compression would silently suppress every future
213
+ // diff for the affected paths.
214
+ throw error instanceof Error ? error : new Error(msg);
215
+ }
216
+ }
134
217
  /**
135
218
  * Decompresses, decrypts, and processes received UDP data.
136
219
  * Pipeline: Receive -> Decrypt (AES-256-GCM) -> Decompress -> Parse -> Process
@@ -206,6 +289,11 @@ function createPipeline(app, state, metricsApi) {
206
289
  app.debug(`Skipping null delta message after decoding at index ${i}`);
207
290
  continue;
208
291
  }
292
+ deltaMessage = (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(deltaMessage);
293
+ if (deltaMessage === null) {
294
+ app.debug(`Skipping delta with no valid Signal K values at index ${i}`);
295
+ continue;
296
+ }
209
297
  // Track path stats for server-side analytics
210
298
  trackPathStats(deltaMessage, decompressed.length / deltas.length);
211
299
  app.handleMessage("", deltaMessage);
@@ -267,6 +355,138 @@ function createPipeline(app, state, metricsApi) {
267
355
  }
268
356
  });
269
357
  }
270
- return { packCrypt, unpackDecrypt };
358
+ /**
359
+ * Receive-side counterpart to `packCryptMeta` for v1. Decrypts a packet
360
+ * arrived on `udpMetaPort`, verifies the 4-byte `SKM1` magic inside the
361
+ * plaintext (packets without the magic are dropped — v1 has no packet-type
362
+ * byte, so the magic is the only signal that this is a meta payload and not
363
+ * a corrupted delta), and dispatches each entry as a minimal Signal K delta
364
+ * with `updates[].meta[]` via `app.handleMessage`.
365
+ */
366
+ async function unpackDecryptMeta(packet, secretKey) {
367
+ try {
368
+ if (!state.options) {
369
+ app.debug("unpackDecryptMeta called but plugin is stopped, ignoring");
370
+ return;
371
+ }
372
+ // Bump bytesIn/packetsIn AND the meta-scoped counters at the same
373
+ // gate — any packet that reached this code is a meta packet (the
374
+ // separate udpMetaPort ensures that), so bytesIn should always equal
375
+ // metaBytesIn for this pipeline path. Keeping them in lockstep lets
376
+ // consumers cross-check: bytesIn === dataBytesIn + metaBytesIn.
377
+ metrics.bandwidth.bytesIn += packet.length;
378
+ metrics.bandwidth.packetsIn++;
379
+ metrics.bandwidth.metaBytesIn = (metrics.bandwidth.metaBytesIn || 0) + packet.length;
380
+ metrics.bandwidth.metaPacketsIn = (metrics.bandwidth.metaPacketsIn || 0) + 1;
381
+ const decrypted = (0, crypto_1.decryptBinary)(packet, secretKey, {
382
+ stretchAsciiKey: !!state.options.stretchAsciiKey
383
+ });
384
+ const decompressed = (await (0, pipeline_utils_1.brotliDecompressAsync)(decrypted, {
385
+ maxOutputLength: constants_1.MAX_DECOMPRESSED_SIZE
386
+ }));
387
+ if (decompressed.length < V1_META_MAGIC.length) {
388
+ app.debug("v1 meta: decompressed payload too short, ignoring");
389
+ return;
390
+ }
391
+ // Reject anything that isn't prefixed with the SKM1 magic so a stray
392
+ // non-meta packet on the meta port (misconfiguration, replay, attacker)
393
+ // cannot be misinterpreted. The magic lives INSIDE the encrypted
394
+ // plaintext, so this check is authenticated.
395
+ if (decompressed.subarray(0, V1_META_MAGIC.length).compare(V1_META_MAGIC) !== 0) {
396
+ app.debug("v1 meta: missing SKM1 magic, dropping");
397
+ return;
398
+ }
399
+ const body = decompressed.subarray(V1_META_MAGIC.length);
400
+ if (body.length > constants_1.MAX_PARSE_PAYLOAD_SIZE) {
401
+ app.error(`v1 meta: payload too large to parse: ${body.length} bytes`);
402
+ return;
403
+ }
404
+ let content;
405
+ if (state.options.useMsgpack) {
406
+ try {
407
+ content = msgpack.decode(body);
408
+ }
409
+ catch {
410
+ content = JSON.parse(body.toString());
411
+ }
412
+ }
413
+ else {
414
+ content = JSON.parse(body.toString());
415
+ }
416
+ if (!content || typeof content !== "object" || Array.isArray(content)) {
417
+ app.debug("v1 meta: envelope was not an object, dropping");
418
+ return;
419
+ }
420
+ const env = content;
421
+ if (!Array.isArray(env.entries) || env.entries.length === 0) {
422
+ return;
423
+ }
424
+ // Drop stale/duplicate envelopes. The inner envelope `seq` is shared
425
+ // across all chunks of the same batch, so equal-seq chunks are still
426
+ // accepted; only earlier batches are rejected. Uint32-wrap aware so a
427
+ // long-running sender's wrap doesn't trigger mass-rejection.
428
+ //
429
+ // Sender-restart detection: the v1 client's meta envelope counter
430
+ // initialises to 0 at process start. Treat envSeq=0 as a peer restart
431
+ // only once lastIngestedMetaEnvSeqV1 has advanced beyond a small
432
+ // reorder window — below the threshold, envSeq=0 is ambiguous with
433
+ // first-packet replay and falls through to normal dedup.
434
+ if (typeof env.seq === "number" && Number.isFinite(env.seq)) {
435
+ const envSeq = env.seq >>> 0;
436
+ if (lastIngestedMetaEnvSeqV1 !== null &&
437
+ envSeq === 0 &&
438
+ lastIngestedMetaEnvSeqV1 >= META_RESTART_THRESHOLD_V1) {
439
+ app.debug(`v1 meta: sender restart detected (last seq was ${lastIngestedMetaEnvSeqV1}); resetting`);
440
+ lastIngestedMetaEnvSeqV1 = null;
441
+ }
442
+ if (lastIngestedMetaEnvSeqV1 !== null) {
443
+ const distance = (envSeq - lastIngestedMetaEnvSeqV1) >>> 0;
444
+ if (distance !== 0 && distance >= 0x80000000) {
445
+ app.debug(`v1 meta: stale envelope seq=${envSeq} (last=${lastIngestedMetaEnvSeqV1}), dropping`);
446
+ return;
447
+ }
448
+ if (distance !== 0) {
449
+ lastIngestedMetaEnvSeqV1 = envSeq;
450
+ }
451
+ }
452
+ else {
453
+ lastIngestedMetaEnvSeqV1 = envSeq;
454
+ }
455
+ }
456
+ const nowIso = new Date().toISOString();
457
+ const usePathDict = !!state.options.usePathDictionary;
458
+ for (const rawEntry of env.entries) {
459
+ if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
460
+ continue;
461
+ }
462
+ const entry = usePathDict
463
+ ? (0, pathDictionary_1.decodeMetaEntry)(rawEntry)
464
+ : rawEntry;
465
+ const path = typeof entry.path === "string" ? entry.path : String(entry.path ?? "");
466
+ if (!path) {
467
+ continue;
468
+ }
469
+ const context = typeof rawEntry.context === "string" ? rawEntry.context : "vessels.self";
470
+ const delta = {
471
+ context,
472
+ updates: [
473
+ {
474
+ timestamp: nowIso,
475
+ values: [],
476
+ meta: [{ path, value: entry.meta }]
477
+ }
478
+ ]
479
+ };
480
+ app.handleMessage("", delta);
481
+ }
482
+ app.debug(`v1 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}`);
483
+ }
484
+ catch (error) {
485
+ const msg = error instanceof Error ? error.message : String(error);
486
+ app.error(`unpackDecryptMeta error: ${msg}`);
487
+ recordError("general", `unpackDecryptMeta error: ${msg}`);
488
+ }
489
+ }
490
+ return { packCrypt, packCryptMeta, unpackDecrypt, unpackDecryptMeta };
271
491
  }
272
492
  module.exports = createPipeline;
package/lib/prometheus.js CHANGED
@@ -42,6 +42,10 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
42
42
  // Delta counters
43
43
  counter("deltas_sent_total", "Total deltas sent", metrics.deltasSent);
44
44
  counter("deltas_received_total", "Total deltas received", metrics.deltasReceived);
45
+ counter("data_packets_received_total", "Total v2 data packets received", metrics.dataPacketsReceived || 0);
46
+ counter("rate_limited_packets_total", "Total packets dropped by rate limiting", metrics.rateLimitedPackets || 0);
47
+ counter("dropped_delta_batches_total", "Total delta batches dropped before send", metrics.droppedDeltaBatches || 0);
48
+ counter("dropped_deltas_total", "Total deltas dropped before send", metrics.droppedDeltaCount || 0);
45
49
  // Error counters
46
50
  counter("udp_send_errors_total", "Total UDP send errors", metrics.udpSendErrors);
47
51
  counter("udp_retries_total", "Total UDP send retries", metrics.udpRetries);
@@ -63,6 +67,13 @@ function formatPrometheusMetrics(metrics, state, extra = {}, opts = {}) {
63
67
  counter("bytes_in_raw_total", "Total bytes received (raw/uncompressed)", metrics.bandwidth.bytesInRaw);
64
68
  counter("packets_out_total", "Total packets sent", metrics.bandwidth.packetsOut);
65
69
  counter("packets_in_total", "Total packets received", metrics.bandwidth.packetsIn);
70
+ counter("metadata_bytes_out_total", "Total bytes sent as metadata packets", metrics.bandwidth.metaBytesOut || 0);
71
+ counter("metadata_bytes_in_total", "Total bytes received as metadata packets", metrics.bandwidth.metaBytesIn || 0);
72
+ counter("metadata_packets_out_total", "Total metadata packets sent", metrics.bandwidth.metaPacketsOut || 0);
73
+ counter("metadata_packets_in_total", "Total metadata packets received", metrics.bandwidth.metaPacketsIn || 0);
74
+ counter("metadata_snapshots_sent_total", "Total metadata snapshot envelopes sent", metrics.bandwidth.metaSnapshotsSent || 0);
75
+ counter("metadata_diffs_sent_total", "Total metadata diff envelopes sent", metrics.bandwidth.metaDiffsSent || 0);
76
+ counter("metadata_rate_limited_packets_total", "Total metadata packets dropped by rate limiting", metrics.bandwidth.metaRateLimitedPackets || 0);
66
77
  gauge("bandwidth_rate_out_bytes", "Current outbound bandwidth (bytes/s)", metrics.bandwidth.rateOut);
67
78
  gauge("bandwidth_rate_in_bytes", "Current inbound bandwidth (bytes/s)", metrics.bandwidth.rateIn);
68
79
  gauge("compression_ratio_percent", "Current compression ratio percentage", metrics.bandwidth.compressionRatio);
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateRuntimeConfigBody = validateRuntimeConfigBody;
4
+ const metadata_1 = require("../metadata");
5
+ /** Upper bound on meta.includePathsMatching length. Same constant as the
6
+ * metadata runtime — mirrored here so the validator rejects patterns at
7
+ * save time rather than letting the runtime silently fall back to
8
+ * allow-all. */
9
+ const META_FILTER_MAX_LENGTH = 256;
4
10
  function validateRuntimeConfigBody(filename, body) {
5
11
  if (!body || typeof body !== "object" || Array.isArray(body)) {
6
12
  return "Request body must be a JSON object";
@@ -23,6 +29,49 @@ function validateRuntimeConfigBody(filename, body) {
23
29
  }
24
30
  }
25
31
  }
32
+ if (body.meta !== undefined) {
33
+ if (body.meta === null || typeof body.meta !== "object" || Array.isArray(body.meta)) {
34
+ return "meta must be an object";
35
+ }
36
+ const m = body.meta;
37
+ if (m.enabled !== undefined && typeof m.enabled !== "boolean") {
38
+ return "meta.enabled must be a boolean";
39
+ }
40
+ if (m.intervalSec !== undefined &&
41
+ (typeof m.intervalSec !== "number" ||
42
+ !Number.isFinite(m.intervalSec) ||
43
+ m.intervalSec < 30 ||
44
+ m.intervalSec > 86400)) {
45
+ return "meta.intervalSec must be a number between 30 and 86400";
46
+ }
47
+ if (m.includePathsMatching !== undefined && m.includePathsMatching !== null) {
48
+ if (typeof m.includePathsMatching !== "string") {
49
+ return "meta.includePathsMatching must be a string or null";
50
+ }
51
+ // Same three checks the runtime applies, hoisted to save-time so
52
+ // persisted subscription.json cannot contain patterns the runtime
53
+ // would silently ignore. Keeps the API and the runtime in agreement.
54
+ if (m.includePathsMatching.length > META_FILTER_MAX_LENGTH) {
55
+ return `meta.includePathsMatching must be at most ${META_FILTER_MAX_LENGTH} characters`;
56
+ }
57
+ if ((0, metadata_1.isLikelyUnsafePathFilter)(m.includePathsMatching)) {
58
+ return "meta.includePathsMatching contains a nested unbounded quantifier (ReDoS shape); refused";
59
+ }
60
+ try {
61
+ new RegExp(m.includePathsMatching);
62
+ }
63
+ catch (err) {
64
+ return `meta.includePathsMatching failed to compile: ${err instanceof Error ? err.message : String(err)}`;
65
+ }
66
+ }
67
+ if (m.maxPathsPerPacket !== undefined &&
68
+ (typeof m.maxPathsPerPacket !== "number" ||
69
+ !Number.isFinite(m.maxPathsPerPacket) ||
70
+ m.maxPathsPerPacket < 10 ||
71
+ m.maxPathsPerPacket > 5000)) {
72
+ return "meta.maxPathsPerPacket must be a number between 10 and 5000";
73
+ }
74
+ }
26
75
  }
27
76
  else if (filename === "sentence_filter.json") {
28
77
  if (body.excludedSentences !== undefined && !Array.isArray(body.excludedSentences)) {
@@ -37,6 +37,7 @@ function register(router, ctx) {
37
37
  packetLoss: effectiveNetwork.packetLoss,
38
38
  retransmissions: effectiveNetwork.retransmissions,
39
39
  queueDepth: effectiveNetwork.queueDepth,
40
+ retransmitRate: effectiveNetwork.retransmitRate,
40
41
  acksSent: metrics.acksSent || 0,
41
42
  naksSent: metrics.naksSent || 0,
42
43
  activeLink: effectiveNetwork.activeLink,
package/lib/routes.js CHANGED
@@ -289,6 +289,12 @@ function createRoutes(app, instanceRegistry, pluginRef) {
289
289
  }
290
290
  return localVal ?? 0;
291
291
  }
292
+ const bondingManager = state.pipeline && state.pipeline.getBondingManager
293
+ ? state.pipeline.getBondingManager()
294
+ : null;
295
+ const localActiveLink = bondingManager
296
+ ? bondingManager.getActiveLinkName() || "primary"
297
+ : "primary";
292
298
  return {
293
299
  rtt: selectMetric(remote.rtt, metrics.rtt),
294
300
  jitter: selectMetric(remote.jitter, metrics.jitter),
@@ -296,7 +302,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
296
302
  retransmissions: selectMetric(remote.retransmissions, metrics.retransmissions),
297
303
  queueDepth: selectMetric(remote.queueDepth, metrics.queueDepth),
298
304
  retransmitRate: selectMetric(remote.retransmitRate, clientRetransmitRate),
299
- activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") : "primary",
305
+ activeLink: hasFreshRemote ? (remote.activeLink ?? "primary") : localActiveLink,
300
306
  dataSource: hasFreshRemote ? "remote-client" : "local",
301
307
  lastUpdate: hasFreshRemote ? remote.lastUpdate : 0
302
308
  };
@@ -336,6 +342,10 @@ function createRoutes(app, instanceRegistry, pluginRef) {
336
342
  subscriptionErrors: metrics.subscriptionErrors,
337
343
  duplicatePackets: metrics.duplicatePackets || 0,
338
344
  malformedPackets: metrics.malformedPackets || 0,
345
+ dataPacketsReceived: metrics.dataPacketsReceived || 0,
346
+ rateLimitedPackets: metrics.rateLimitedPackets || 0,
347
+ droppedDeltaBatches: metrics.droppedDeltaBatches || 0,
348
+ droppedDeltaCount: metrics.droppedDeltaCount || 0,
339
349
  errorCounts: { ...(metrics.errorCounts || {}) }
340
350
  },
341
351
  status: {
@@ -348,6 +358,8 @@ function createRoutes(app, instanceRegistry, pluginRef) {
348
358
  : metrics.bandwidth.packetsOut;
349
359
  const bytes = state.isServerMode ? metrics.bandwidth.bytesIn : metrics.bandwidth.bytesOut;
350
360
  const avgPacketSize = packets > 0 ? Math.round(bytes / packets) : 0;
361
+ const metaBytesOut = metrics.bandwidth.metaBytesOut || 0;
362
+ const metaBytesIn = metrics.bandwidth.metaBytesIn || 0;
351
363
  return {
352
364
  bytesOut: metrics.bandwidth.bytesOut,
353
365
  bytesIn: metrics.bandwidth.bytesIn,
@@ -356,6 +368,7 @@ function createRoutes(app, instanceRegistry, pluginRef) {
356
368
  bytesOutFormatted: formatBytes(metrics.bandwidth.bytesOut),
357
369
  bytesInFormatted: formatBytes(metrics.bandwidth.bytesIn),
358
370
  bytesOutRawFormatted: formatBytes(metrics.bandwidth.bytesOutRaw),
371
+ bytesInRawFormatted: formatBytes(metrics.bandwidth.bytesInRaw),
359
372
  packetsOut: metrics.bandwidth.packetsOut,
360
373
  packetsIn: metrics.bandwidth.packetsIn,
361
374
  rateOut: metrics.bandwidth.rateOut,
@@ -365,6 +378,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
365
378
  compressionRatio: metrics.bandwidth.compressionRatio,
366
379
  avgPacketSize,
367
380
  avgPacketSizeFormatted: avgPacketSize > 0 ? formatBytes(avgPacketSize) : "0 B",
381
+ metaBytesOut,
382
+ metaBytesIn,
383
+ metaBytesOutFormatted: formatBytes(metaBytesOut),
384
+ metaBytesInFormatted: formatBytes(metaBytesIn),
385
+ metaPacketsOut: metrics.bandwidth.metaPacketsOut || 0,
386
+ metaPacketsIn: metrics.bandwidth.metaPacketsIn || 0,
387
+ metaSnapshotsSent: metrics.bandwidth.metaSnapshotsSent || 0,
388
+ metaDiffsSent: metrics.bandwidth.metaDiffsSent || 0,
389
+ metaRateLimitedPackets: metrics.bandwidth.metaRateLimitedPackets || 0,
368
390
  history: metrics.bandwidth.history.toArray().slice(-30)
369
391
  };
370
392
  })(),
@@ -387,16 +409,15 @@ function createRoutes(app, instanceRegistry, pluginRef) {
387
409
  packetLoss: effectiveNetwork.packetLoss,
388
410
  retransmissions: effectiveNetwork.retransmissions,
389
411
  queueDepth: effectiveNetwork.queueDepth,
412
+ retransmitRate: effectiveNetwork.retransmitRate,
390
413
  acksSent: metrics.acksSent || 0,
391
414
  naksSent: metrics.naksSent || 0,
415
+ activeLink: effectiveNetwork.activeLink,
392
416
  dataSource: effectiveNetwork.dataSource
393
417
  };
394
418
  if (state.isServerMode && effectiveNetwork.lastUpdate > 0) {
395
419
  networkData.lastRemoteUpdate = effectiveNetwork.lastUpdate;
396
420
  }
397
- if (state.isServerMode) {
398
- networkData.activeLink = effectiveNetwork.activeLink;
399
- }
400
421
  const publisher = getActiveMetricsPublisher(state);
401
422
  if (publisher) {
402
423
  networkData.linkQuality = publisher.calculateLinkQuality({