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.
- package/lib/delta-sanitizer.js +86 -0
- package/lib/instance.js +284 -8
- package/lib/metadata.js +467 -0
- package/lib/metrics.js +22 -1
- package/lib/packet.js +51 -14
- package/lib/pathDictionary.js +20 -1
- package/lib/pipeline-v2-client.js +177 -12
- package/lib/pipeline-v2-server.js +236 -2
- package/lib/pipeline.js +221 -1
- package/lib/prometheus.js +11 -0
- package/lib/routes/config-validation.js +49 -0
- package/lib/routes/metrics.js +1 -0
- package/lib/routes.js +25 -4
- package/package.json +1 -1
- package/public/{982.b207a377ed6542e2fb4a.js → 982.63949a2b2f6c5854e034.js} +2 -2
- package/public/982.63949a2b2f6c5854e034.js.map +1 -0
- package/public/index.html +1 -1
- package/public/main.0b6f5e3267731da945f0.js +2 -0
- package/public/main.0b6f5e3267731da945f0.js.map +1 -0
- package/public/main.js +467 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/982.b207a377ed6542e2fb4a.js.map +0 -1
- package/public/main.f1780db6593b0c07a48c.js +0 -2
- package/public/main.f1780db6593b0c07a48c.js.map +0 -1
package/lib/metadata.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
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
|
|
373
|
-
// ACK / NAK / HELLO must include a 2-byte CRC16 trailer;
|
|
374
|
-
// undersized payloads so forged control frames cannot slip
|
|
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
|
}
|
package/lib/pathDictionary.js
CHANGED
|
@@ -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 (
|
|
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
|