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