signalk-edge-link 2.2.0 → 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,467 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MetaCache = void 0;
4
+ exports.collectSnapshot = collectSnapshot;
5
+ exports.parseMetaConfig = parseMetaConfig;
6
+ exports.resolveSelfContext = resolveSelfContext;
7
+ exports.extractLiveMeta = extractLiveMeta;
8
+ exports.isLikelyUnsafePathFilter = isLikelyUnsafePathFilter;
9
+ exports.splitIntoPackets = splitIntoPackets;
10
+ exports.buildMetaEnvelope = buildMetaEnvelope;
11
+ /**
12
+ * Signal K Edge Link - Metadata Streaming
13
+ *
14
+ * Collects Signal K path metadata (units, descriptions, zones, display names, ...)
15
+ * and packages it for transmission alongside the main delta stream.
16
+ *
17
+ * Meta is deliberately separated from deltas on the wire:
18
+ * - the existing delta encoder strips `updates[].meta[]` via pathDictionary
19
+ * `transformDelta`, so meta has never flowed through the pipeline; and
20
+ * - sending meta on every delta would multiply bandwidth for values that
21
+ * essentially never change.
22
+ *
23
+ * Strategy: snapshot once at startup from `app.signalk.retrieve()`, forward
24
+ * runtime changes via `extractLiveMeta`, and periodically re-broadcast the
25
+ * full snapshot so a restarted receiver recovers within one interval.
26
+ *
27
+ * @module lib/metadata
28
+ */
29
+ const crypto_1 = require("crypto");
30
+ /**
31
+ * Produces a stable JSON representation of a meta object for change detection.
32
+ * Sorts object keys recursively so `{units:"m",description:"x"}` and
33
+ * `{description:"x",units:"m"}` hash identically.
34
+ */
35
+ function stableStringify(value) {
36
+ if (value === null || typeof value !== "object") {
37
+ return JSON.stringify(value);
38
+ }
39
+ if (Array.isArray(value)) {
40
+ return "[" + value.map(stableStringify).join(",") + "]";
41
+ }
42
+ const keys = Object.keys(value).sort();
43
+ return ("{" +
44
+ keys
45
+ .map((k) => JSON.stringify(k) + ":" + stableStringify(value[k]))
46
+ .join(",") +
47
+ "}");
48
+ }
49
+ function hashMeta(meta) {
50
+ return (0, crypto_1.createHash)("sha1").update(stableStringify(meta)).digest("hex");
51
+ }
52
+ /**
53
+ * Cache of the last-sent meta value (hash) per `context+path` pair.
54
+ *
55
+ * `diff` returns only the entries whose hashed value has changed since the
56
+ * last call, so periodic snapshot re-broadcasts stay cheap when the fleet's
57
+ * meta is stable.
58
+ */
59
+ class MetaCache {
60
+ constructor() {
61
+ this.hashes = new Map();
62
+ }
63
+ keyFor(entry) {
64
+ return entry.context + "|" + entry.path;
65
+ }
66
+ /**
67
+ * Returns only the entries whose meta has changed (or is new) relative to
68
+ * this cache, and simultaneously updates the cache.
69
+ */
70
+ diff(entries) {
71
+ const changed = [];
72
+ for (const entry of entries) {
73
+ const key = this.keyFor(entry);
74
+ const h = hashMeta(entry.meta);
75
+ if (this.hashes.get(key) !== h) {
76
+ this.hashes.set(key, h);
77
+ changed.push(entry);
78
+ }
79
+ }
80
+ return changed;
81
+ }
82
+ /**
83
+ * Non-mutating variant of {@link diff}. Returns the subset of entries that
84
+ * are new or whose meta has changed without updating the internal cache.
85
+ * Used by the send pipeline so the cache is only updated after a
86
+ * successful transmission — a failed send leaves the cache untouched and
87
+ * the entries will be re-attempted on the next diff.
88
+ */
89
+ computeDiff(entries) {
90
+ const changed = [];
91
+ for (const entry of entries) {
92
+ const key = this.keyFor(entry);
93
+ const h = hashMeta(entry.meta);
94
+ if (this.hashes.get(key) !== h) {
95
+ changed.push(entry);
96
+ }
97
+ }
98
+ return changed;
99
+ }
100
+ /**
101
+ * Mark the supplied entries as sent by updating their hashes in the cache.
102
+ * Call this only after a successful send so future diffs don't re-emit
103
+ * the same content.
104
+ */
105
+ commit(entries) {
106
+ for (const entry of entries) {
107
+ this.hashes.set(this.keyFor(entry), hashMeta(entry.meta));
108
+ }
109
+ }
110
+ /**
111
+ * Overwrite the cache with the supplied entries. Used after a successful
112
+ * full-snapshot send so the next diff is computed against the transmitted
113
+ * state.
114
+ */
115
+ replaceAll(entries) {
116
+ this.hashes.clear();
117
+ for (const entry of entries) {
118
+ this.hashes.set(this.keyFor(entry), hashMeta(entry.meta));
119
+ }
120
+ }
121
+ clear() {
122
+ this.hashes.clear();
123
+ }
124
+ size() {
125
+ return this.hashes.size;
126
+ }
127
+ }
128
+ exports.MetaCache = MetaCache;
129
+ /**
130
+ * Walks the value recursively and calls `onMeta(path, metaValue)` for every
131
+ * subtree that has a `meta` child. Arrays are left alone — Signal K meta
132
+ * lives inside regular path nodes only.
133
+ */
134
+ function walkMeta(node, pathParts, onMeta) {
135
+ if (!node || typeof node !== "object" || Array.isArray(node)) {
136
+ return;
137
+ }
138
+ const obj = node;
139
+ if (obj.meta && typeof obj.meta === "object" && !Array.isArray(obj.meta)) {
140
+ onMeta(pathParts.join("."), obj.meta);
141
+ }
142
+ for (const key of Object.keys(obj)) {
143
+ // Signal K "value", "timestamp", "$source" are leaves, not sub-paths.
144
+ if (key === "meta" ||
145
+ key === "value" ||
146
+ key === "values" ||
147
+ key === "timestamp" ||
148
+ key === "$source" ||
149
+ key === "sentence") {
150
+ continue;
151
+ }
152
+ walkMeta(obj[key], pathParts.concat(key), onMeta);
153
+ }
154
+ }
155
+ /**
156
+ * Build a full metadata snapshot from the Signal K app state tree.
157
+ *
158
+ * Iterates `app.signalk.retrieve()` (when available) and collects every node
159
+ * that has a `meta` object. Returns entries scoped to the "self" vessel plus
160
+ * any other contexts present. Applies the `includePathsMatching` regex
161
+ * filter when configured.
162
+ *
163
+ * On signalk-server versions where `app.signalk` is not exposed to plugins,
164
+ * returns an empty array — live meta will still trickle in through
165
+ * `extractLiveMeta` once providers emit meta updates.
166
+ */
167
+ function collectSnapshot(app, config) {
168
+ if (!config || !config.enabled) {
169
+ return [];
170
+ }
171
+ if (!app.signalk || typeof app.signalk.retrieve !== "function") {
172
+ return [];
173
+ }
174
+ let tree;
175
+ try {
176
+ tree = app.signalk.retrieve();
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ if (!tree || typeof tree !== "object") {
182
+ return [];
183
+ }
184
+ const filter = buildPathFilter(config.includePathsMatching);
185
+ const entries = [];
186
+ for (const contextGroup of Object.keys(tree)) {
187
+ // `tree.self` is an alias string pointing to the local vessel URN, and
188
+ // `tree.version` is a server version string; both are leaves, not
189
+ // context containers, so skip them outright.
190
+ if (contextGroup === "self" || contextGroup === "version") {
191
+ continue;
192
+ }
193
+ const group = tree[contextGroup];
194
+ if (!group || typeof group !== "object") {
195
+ continue;
196
+ }
197
+ for (const contextId of Object.keys(group)) {
198
+ const contextNode = group[contextId];
199
+ if (!contextNode || typeof contextNode !== "object") {
200
+ continue;
201
+ }
202
+ const contextLabel = `${contextGroup}.${contextId}`;
203
+ walkMeta(contextNode, [], (path, meta) => {
204
+ if (!filter(path)) {
205
+ return;
206
+ }
207
+ entries.push({ context: contextLabel, path, meta });
208
+ });
209
+ }
210
+ }
211
+ return entries;
212
+ }
213
+ const META_CONFIG_LOG_PREFIX = "[meta-config]";
214
+ const META_DEFAULT_INTERVAL_SEC = 300;
215
+ const META_DEFAULT_MAX_PATHS = 500;
216
+ const META_INTERVAL_MIN = 30;
217
+ const META_INTERVAL_MAX = 86400;
218
+ const META_MAX_PATHS_MIN = 10;
219
+ const META_MAX_PATHS_MAX = 5000;
220
+ /**
221
+ * Parse the `meta` block out of a subscription.json document.
222
+ *
223
+ * Returns null when meta is absent, malformed, or explicitly disabled.
224
+ * Out-of-range numeric fields and unsafe `includePathsMatching` patterns
225
+ * fall back to defaults / null and report a `[meta-config]`-prefixed error
226
+ * via `report` so log analysis can grep for misconfiguration in one place.
227
+ *
228
+ * Lives here (not in `instance.ts`) so it can be unit-tested directly without
229
+ * spinning up an entire instance. The same parser is also used as the
230
+ * single source of truth for the plugin runtime via instance.ts.
231
+ */
232
+ function parseMetaConfig(raw, report, context = "") {
233
+ if (!raw || typeof raw !== "object") {
234
+ return null;
235
+ }
236
+ const obj = raw;
237
+ const m = obj.meta;
238
+ if (!m || typeof m !== "object") {
239
+ return null;
240
+ }
241
+ const mo = m;
242
+ if (mo.enabled !== true) {
243
+ return null;
244
+ }
245
+ const tag = context ? `${META_CONFIG_LOG_PREFIX} [${context}]` : META_CONFIG_LOG_PREFIX;
246
+ let intervalSec = META_DEFAULT_INTERVAL_SEC;
247
+ if (mo.intervalSec !== undefined) {
248
+ if (typeof mo.intervalSec === "number" &&
249
+ Number.isFinite(mo.intervalSec) &&
250
+ mo.intervalSec >= META_INTERVAL_MIN &&
251
+ mo.intervalSec <= META_INTERVAL_MAX) {
252
+ intervalSec = mo.intervalSec;
253
+ }
254
+ else {
255
+ report(`${tag} meta.intervalSec ${String(mo.intervalSec)} out of range ` +
256
+ `[${META_INTERVAL_MIN},${META_INTERVAL_MAX}]; using default ${META_DEFAULT_INTERVAL_SEC}s`);
257
+ }
258
+ }
259
+ let maxPathsPerPacket = META_DEFAULT_MAX_PATHS;
260
+ if (mo.maxPathsPerPacket !== undefined) {
261
+ if (typeof mo.maxPathsPerPacket === "number" &&
262
+ Number.isFinite(mo.maxPathsPerPacket) &&
263
+ mo.maxPathsPerPacket >= META_MAX_PATHS_MIN &&
264
+ mo.maxPathsPerPacket <= META_MAX_PATHS_MAX) {
265
+ maxPathsPerPacket = mo.maxPathsPerPacket;
266
+ }
267
+ else {
268
+ report(`${tag} meta.maxPathsPerPacket ${String(mo.maxPathsPerPacket)} out of range ` +
269
+ `[${META_MAX_PATHS_MIN},${META_MAX_PATHS_MAX}]; using default ${META_DEFAULT_MAX_PATHS}`);
270
+ }
271
+ }
272
+ let includePathsMatching = null;
273
+ if (typeof mo.includePathsMatching === "string" && mo.includePathsMatching.length > 0) {
274
+ const pattern = mo.includePathsMatching;
275
+ if (pattern.length > MAX_PATH_FILTER_PATTERN_LENGTH) {
276
+ report(`${tag} meta.includePathsMatching exceeds ${MAX_PATH_FILTER_PATTERN_LENGTH} chars; ignoring filter`);
277
+ }
278
+ else if (isLikelyUnsafePathFilter(pattern)) {
279
+ report(`${tag} meta.includePathsMatching "${pattern}" has a nested unbounded quantifier (ReDoS shape); ignoring filter`);
280
+ }
281
+ else {
282
+ try {
283
+ new RegExp(pattern);
284
+ includePathsMatching = pattern;
285
+ }
286
+ catch (err) {
287
+ report(`${tag} meta.includePathsMatching "${pattern}" failed to compile: ${err instanceof Error ? err.message : String(err)}; ignoring filter`);
288
+ }
289
+ }
290
+ }
291
+ return {
292
+ enabled: true,
293
+ intervalSec,
294
+ includePathsMatching,
295
+ maxPathsPerPacket
296
+ };
297
+ }
298
+ /**
299
+ * Resolve the local vessel's context string (e.g. `vessels.urn:mrn:...`) from
300
+ * the Signal K app. Used to normalize `delta.context === "vessels.self"` in
301
+ * the live meta stream to the same concrete URN `collectSnapshot` emits, so
302
+ * `MetaCache` can dedupe snapshot and diff entries against the same key.
303
+ *
304
+ * Returns `null` when the self URN is not yet known — a fallback to the
305
+ * literal `"vessels.self"` would reintroduce the snapshot/live-meta key
306
+ * mismatch. Callers should treat null as "self not resolvable yet" and
307
+ * decline to emit `vessels.self` live entries until a concrete URN arrives.
308
+ */
309
+ function resolveSelfContext(app) {
310
+ try {
311
+ const self = app.getSelfPath?.("");
312
+ if (self && typeof self === "object") {
313
+ const id = self.mmsi ?? self.uuid;
314
+ if (typeof id === "string" && id.length > 0) {
315
+ const prefix = self.mmsi
316
+ ? "urn:mrn:imo:mmsi:"
317
+ : "urn:mrn:signalk:uuid:";
318
+ return `vessels.${prefix}${id}`;
319
+ }
320
+ }
321
+ if (app.signalk && typeof app.signalk.retrieve === "function") {
322
+ const tree = app.signalk.retrieve();
323
+ const alias = tree?.self;
324
+ if (typeof alias === "string" && alias.length > 0) {
325
+ return `vessels.${alias}`;
326
+ }
327
+ }
328
+ }
329
+ catch {
330
+ /* fall through */
331
+ }
332
+ if (typeof app.debug === "function") {
333
+ app.debug("[metadata] self URN not yet resolvable; vessels.self live meta will be skipped");
334
+ }
335
+ return null;
336
+ }
337
+ /**
338
+ * Extract any `updates[].meta[]` entries from a live delta without mutating
339
+ * the delta object. Callers should invoke this BEFORE the delta is passed to
340
+ * the pipeline encoder (which silently drops meta).
341
+ */
342
+ function extractLiveMeta(delta, config, selfContext) {
343
+ if (!config || !config.enabled) {
344
+ return [];
345
+ }
346
+ if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
347
+ return [];
348
+ }
349
+ const filter = buildPathFilter(config.includePathsMatching);
350
+ const out = [];
351
+ for (const update of delta.updates) {
352
+ const metaArr = update.meta;
353
+ if (!Array.isArray(metaArr) || metaArr.length === 0) {
354
+ continue;
355
+ }
356
+ for (const m of metaArr) {
357
+ if (!m || typeof m.path !== "string" || !m.value || typeof m.value !== "object") {
358
+ continue;
359
+ }
360
+ if (!filter(m.path)) {
361
+ continue;
362
+ }
363
+ const rawContext = delta.context || "vessels.self";
364
+ // Normalize "vessels.self" to the concrete self URN so MetaCache keys
365
+ // match snapshot keys exactly. If the self URN isn't known yet, skip
366
+ // the entry rather than emit it under a context that will never match
367
+ // collectSnapshot's output — otherwise the receiver would see two
368
+ // copies (one under vessels.self, one under the real URN) and the
369
+ // local MetaCache diff logic would never dedupe them.
370
+ let context;
371
+ if (rawContext === "vessels.self") {
372
+ if (!selfContext) {
373
+ continue;
374
+ }
375
+ context = selfContext;
376
+ }
377
+ else {
378
+ context = rawContext;
379
+ }
380
+ out.push({
381
+ context,
382
+ path: m.path,
383
+ meta: m.value
384
+ });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ /** Maximum operator-supplied regex length. A typical path-matching regex is
390
+ * well under 100 chars; refusing huge patterns is a cheap safeguard against
391
+ * the obvious catastrophic-backtracking shapes (hundreds of nested `(a+)*`
392
+ * groups, etc.) without pulling in a re2 dependency. */
393
+ const MAX_PATH_FILTER_PATTERN_LENGTH = 256;
394
+ /**
395
+ * Heuristic detector for the most common ReDoS shape: nested unbounded
396
+ * quantifiers such as `(a+)+`, `(.*)+`, `(a*)*`, `(.+)*`.
397
+ *
398
+ * The check is deliberately narrow — it does not attempt a full ReDoS
399
+ * analysis (which would require pulling in `safe-regex2` or `re2`) — but it
400
+ * catches the specific failure mode that is easy to accidentally write and
401
+ * easy to verify by eye. Callers should also enforce
402
+ * {@link MAX_PATH_FILTER_PATTERN_LENGTH} and wrap regex compilation in
403
+ * try/catch so invalid patterns fail safely.
404
+ *
405
+ * Exported so the config parser can reject unsafe patterns at load time
406
+ * with a descriptive error rather than silently dropping to allow-all at
407
+ * runtime, which would hide operator mistakes.
408
+ */
409
+ function isLikelyUnsafePathFilter(pattern) {
410
+ // Matches a group whose body ends in an unbounded quantifier (* or +,
411
+ // optionally with ? for lazy), immediately followed by another unbounded
412
+ // quantifier. This is the classic (a+)+ / (a*)* / (a+)* / (a*)+ family.
413
+ const nested = /\([^()]*[*+][*+?]?\s*\)\s*[*+][*+?]?/;
414
+ return nested.test(pattern);
415
+ }
416
+ /**
417
+ * Build a path-inclusion predicate from the user-supplied regex string.
418
+ * Falsy / empty string / null ⇒ always-true. Invalid or oversized regex
419
+ * ⇒ always-true (silent fallback — operators see no filtering rather
420
+ * than hitting a hard error, which matches the existing behaviour).
421
+ */
422
+ function buildPathFilter(pattern) {
423
+ if (!pattern) {
424
+ return () => true;
425
+ }
426
+ if (pattern.length > MAX_PATH_FILTER_PATTERN_LENGTH) {
427
+ return () => true;
428
+ }
429
+ try {
430
+ const re = new RegExp(pattern);
431
+ return (p) => re.test(p);
432
+ }
433
+ catch {
434
+ return () => true;
435
+ }
436
+ }
437
+ /**
438
+ * Split a list of meta entries into packet-sized chunks.
439
+ * `max` is clamped to at least 1.
440
+ */
441
+ function splitIntoPackets(entries, max) {
442
+ const size = Math.max(1, Math.floor(max) || 1);
443
+ if (entries.length === 0) {
444
+ return [];
445
+ }
446
+ const chunks = [];
447
+ for (let i = 0; i < entries.length; i += size) {
448
+ chunks.push(entries.slice(i, i + size));
449
+ }
450
+ return chunks;
451
+ }
452
+ /**
453
+ * Construct an on-wire envelope for a single chunk of meta entries.
454
+ *
455
+ * The envelope is then JSON- or msgpack-serialized, compressed, encrypted,
456
+ * and wrapped in a METADATA (0x06) packet by the client pipeline.
457
+ */
458
+ function buildMetaEnvelope(entries, kind, seq, idx, total) {
459
+ return {
460
+ v: 1,
461
+ kind,
462
+ seq: seq >>> 0,
463
+ idx,
464
+ total,
465
+ entries
466
+ };
467
+ }
package/lib/metrics.js CHANGED
@@ -32,6 +32,10 @@ function createMetrics() {
32
32
  lastError: null,
33
33
  lastErrorTime: null,
34
34
  packetLoss: 0,
35
+ dataPacketsReceived: 0,
36
+ rateLimitedPackets: 0,
37
+ droppedDeltaBatches: 0,
38
+ droppedDeltaCount: 0,
35
39
  remoteNetworkQuality: {
36
40
  rtt: 0,
37
41
  jitter: 0,
@@ -55,6 +59,13 @@ function createMetrics() {
55
59
  rateOut: 0,
56
60
  rateIn: 0,
57
61
  compressionRatio: 0,
62
+ metaBytesOut: 0,
63
+ metaPacketsOut: 0,
64
+ metaBytesIn: 0,
65
+ metaPacketsIn: 0,
66
+ metaSnapshotsSent: 0,
67
+ metaDiffsSent: 0,
68
+ metaRateLimitedPackets: 0,
58
69
  // Explicit generic parameter so the type matches BandwidthMetrics.history
59
70
  // and removes the need for the `as any` cast on the whole object.
60
71
  history: new CircularBuffer(constants_1.BANDWIDTH_HISTORY_MAX)
@@ -142,7 +153,10 @@ function createMetrics() {
142
153
  acksSent: 0,
143
154
  naksSent: 0,
144
155
  duplicatePackets: 0,
145
- dataPacketsReceived: 0
156
+ dataPacketsReceived: 0,
157
+ rateLimitedPackets: 0,
158
+ droppedDeltaBatches: 0,
159
+ droppedDeltaCount: 0
146
160
  });
147
161
  Object.assign(metrics.bandwidth, {
148
162
  bytesOut: 0,
@@ -157,6 +171,13 @@ function createMetrics() {
157
171
  rateOut: 0,
158
172
  rateIn: 0,
159
173
  compressionRatio: 0,
174
+ metaBytesOut: 0,
175
+ metaPacketsOut: 0,
176
+ metaBytesIn: 0,
177
+ metaPacketsIn: 0,
178
+ metaSnapshotsSent: 0,
179
+ metaDiffsSent: 0,
180
+ metaRateLimitedPackets: 0,
160
181
  history: new CircularBuffer(constants_1.BANDWIDTH_HISTORY_MAX)
161
182
  });
162
183
  metrics.pathStats.clear();
package/lib/packet.js CHANGED
@@ -53,7 +53,9 @@ const PacketType = Object.freeze({
53
53
  ACK: 0x02,
54
54
  NAK: 0x03,
55
55
  HEARTBEAT: 0x04,
56
- HELLO: 0x05
56
+ HELLO: 0x05,
57
+ METADATA: 0x06,
58
+ META_REQUEST: 0x07
57
59
  });
58
60
  exports.PacketType = PacketType;
59
61
  /**
@@ -125,6 +127,11 @@ class PacketBuilder {
125
127
  */
126
128
  constructor(config = {}) {
127
129
  this._sequence = config.initialSequence ?? 0;
130
+ // METADATA lives in its own sequence space. DATA sequencing drives the
131
+ // cumulative ACK/NAK protocol on the server; mixing METADATA into it
132
+ // would create apparent gaps (receivers don't track METADATA sequences)
133
+ // and trigger spurious NAKs / retransmit churn for real data traffic.
134
+ this._metaSequence = 0;
128
135
  this._protocolVersion = normalizeProtocolVersion(config.protocolVersion);
129
136
  this._secretKey = config.secretKey || null;
130
137
  this._stretchAsciiKey = !!config.stretchAsciiKey;
@@ -144,6 +151,31 @@ class PacketBuilder {
144
151
  this._advanceSequence();
145
152
  return packet;
146
153
  }
154
+ /**
155
+ * Build a METADATA packet. Shares the flag set and the build/encrypt
156
+ * pipeline with buildDataPacket but uses packet type 0x06 and its own
157
+ * sequence space so that METADATA never steals DATA sequence numbers.
158
+ *
159
+ * METADATA is not ACKed/NAKed on the wire — recovery is handled by the
160
+ * application-level periodic snapshot and by META_REQUEST (0x07). The
161
+ * separate sequence counter exists purely so a receiver can detect
162
+ * duplicate or reordered METADATA packets within a single snapshot burst.
163
+ */
164
+ buildMetadataPacket(payload, flags = {}) {
165
+ const packet = this._buildPacket(PacketType.METADATA, payload, flags, {
166
+ sequence: this._metaSequence
167
+ });
168
+ this._metaSequence = (this._metaSequence + 1) >>> 0;
169
+ return packet;
170
+ }
171
+ /**
172
+ * Build a META_REQUEST control packet (receiver → client).
173
+ * Payload is empty; control-packet authentication/CRC is applied by
174
+ * _buildPacket the same way as ACK/NAK.
175
+ */
176
+ buildMetaRequestPacket(options = {}) {
177
+ return this._buildPacket(PacketType.META_REQUEST, Buffer.alloc(0), {}, options);
178
+ }
147
179
  /**
148
180
  * Build an ACK packet
149
181
  * @param {number} ackedSequence - Sequence number being acknowledged
@@ -226,6 +258,7 @@ class PacketBuilder {
226
258
  const header = Buffer.alloc(HEADER_SIZE);
227
259
  const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload || "");
228
260
  const protocolVersion = normalizeProtocolVersion(options.protocolVersion ?? this._protocolVersion);
261
+ const sequence = (options.sequence ?? this._sequence) >>> 0;
229
262
  // Magic bytes
230
263
  header[0] = MAGIC[0];
231
264
  header[1] = MAGIC[1];
@@ -248,13 +281,15 @@ class PacketBuilder {
248
281
  flagByte |= PacketFlags.PATH_DICTIONARY;
249
282
  }
250
283
  header[4] = flagByte;
251
- // Sequence number (uint32 big-endian)
252
- header.writeUInt32BE(this._sequence, 5);
284
+ // Sequence number (uint32 big-endian) — DATA uses this._sequence, METADATA
285
+ // uses this._metaSequence, control packets inherit this._sequence.
286
+ header.writeUInt32BE(sequence, 5);
253
287
  let finalPayload = payloadBuffer;
254
- // DATA packets are authenticated by AES-256-GCM. v2 control packets use a
255
- // trailing CRC for corruption detection; v3 control packets use an HMAC tag
256
- // so ACK/NAK/HEARTBEAT/HELLO cannot be forged off-path.
257
- if (type !== PacketType.DATA) {
288
+ // DATA and METADATA packets are authenticated by AES-256-GCM (their payload
289
+ // is already an AEAD ciphertext). v2 control packets use a trailing CRC for
290
+ // corruption detection; v3 control packets use an HMAC tag so
291
+ // ACK/NAK/HEARTBEAT/HELLO/META_REQUEST cannot be forged off-path.
292
+ if (type !== PacketType.DATA && type !== PacketType.METADATA) {
258
293
  if (usesAuthenticatedControl(protocolVersion)) {
259
294
  const secretKey = options.secretKey || this._secretKey;
260
295
  if (!secretKey) {
@@ -351,7 +386,7 @@ class PacketParser {
351
386
  }
352
387
  // Extract payload
353
388
  let payload = packet.subarray(HEADER_SIZE);
354
- if (type !== PacketType.DATA) {
389
+ if (type !== PacketType.DATA && type !== PacketType.METADATA) {
355
390
  if (usesAuthenticatedControl(version)) {
356
391
  if (payload.length < crypto_1.CONTROL_AUTH_TAG_LENGTH) {
357
392
  throw new Error("Control packet authentication tag missing");
@@ -369,11 +404,11 @@ class PacketParser {
369
404
  payload = payloadData;
370
405
  }
371
406
  else {
372
- // HEARTBEAT packets carry a 0-byte payload with no CRC — accept as-is.
373
- // ACK / NAK / HELLO must include a 2-byte CRC16 trailer; reject
374
- // undersized payloads so forged control frames cannot slip through
375
- // unverified.
376
- if (type !== PacketType.HEARTBEAT) {
407
+ // HEARTBEAT and META_REQUEST packets carry a 0-byte payload with no CRC
408
+ // — accept as-is. ACK / NAK / HELLO must include a 2-byte CRC16 trailer;
409
+ // reject undersized payloads so forged control frames cannot slip
410
+ // through unverified.
411
+ if (type !== PacketType.HEARTBEAT && type !== PacketType.META_REQUEST) {
377
412
  if (payload.length < 2) {
378
413
  throw new Error(`Control packet payload too short for CRC: ${payload.length} byte(s)`);
379
414
  }
@@ -467,7 +502,9 @@ function getTypeName(type) {
467
502
  [PacketType.ACK]: "ACK",
468
503
  [PacketType.NAK]: "NAK",
469
504
  [PacketType.HEARTBEAT]: "HEARTBEAT",
470
- [PacketType.HELLO]: "HELLO"
505
+ [PacketType.HELLO]: "HELLO",
506
+ [PacketType.METADATA]: "METADATA",
507
+ [PacketType.META_REQUEST]: "META_REQUEST"
471
508
  };
472
509
  return names[type] || "UNKNOWN";
473
510
  }
@@ -5,6 +5,8 @@ exports.encodePath = encodePath;
5
5
  exports.decodePath = decodePath;
6
6
  exports.encodeDelta = encodeDelta;
7
7
  exports.decodeDelta = decodeDelta;
8
+ exports.encodeMetaEntry = encodeMetaEntry;
9
+ exports.decodeMetaEntry = decodeMetaEntry;
8
10
  exports.getAllPaths = getAllPaths;
9
11
  exports.getPathsByCategory = getPathsByCategory;
10
12
  exports.getDictionarySize = getDictionarySize;
@@ -394,7 +396,10 @@ function transformDelta(delta, pathTransform, shouldTransform) {
394
396
  transformedValues = new Array(values.length);
395
397
  for (let j = 0; j < values.length; j++) {
396
398
  const value = values[j];
397
- if (shouldTransform(value)) {
399
+ if (!value || typeof value !== "object") {
400
+ transformedValues[j] = value;
401
+ }
402
+ else if (shouldTransform(value)) {
398
403
  const transformedPath = pathTransform(value.path);
399
404
  transformedValues[j] = { ...value, path: transformedPath };
400
405
  }
@@ -432,6 +437,20 @@ function encodeDelta(delta) {
432
437
  function decodeDelta(delta) {
433
438
  return transformDelta(delta, decodePath, (value) => value.path !== undefined);
434
439
  }
440
+ /**
441
+ * Encode the `path` field of a metadata entry using the path dictionary.
442
+ * The `meta` payload itself is intentionally not touched — dictionary
443
+ * compression applies to the path strings only.
444
+ */
445
+ function encodeMetaEntry(entry) {
446
+ return { ...entry, path: encodePath(entry.path) };
447
+ }
448
+ /**
449
+ * Decode the `path` field of a metadata entry. Inverse of encodeMetaEntry.
450
+ */
451
+ function decodeMetaEntry(entry) {
452
+ return { ...entry, path: decodePath(entry.path) };
453
+ }
435
454
  /**
436
455
  * Get all known paths as an array
437
456
  * @returns Array of all known SignalK paths