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/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 +164 -164
- 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/main.f1780db6593b0c07a48c.js +0 -2
- package/public/main.f1780db6593b0c07a48c.js.map +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeDeltaForSignalK = sanitizeDeltaForSignalK;
|
|
4
|
+
exports.sanitizeDeltaPayloadForSignalK = sanitizeDeltaPayloadForSignalK;
|
|
5
|
+
function isObject(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function isValidValuePath(path) {
|
|
9
|
+
return typeof path === "string" && path.trim().length > 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Remove Signal K value entries that the server will reject before calling
|
|
13
|
+
* app.handleMessage or forwarding over the link. Metadata-only updates are
|
|
14
|
+
* preserved, but updates with neither valid values nor meta are dropped.
|
|
15
|
+
*/
|
|
16
|
+
function sanitizeDeltaForSignalK(delta) {
|
|
17
|
+
if (!delta || !Array.isArray(delta.updates)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let changed = false;
|
|
21
|
+
const sanitizedUpdates = [];
|
|
22
|
+
for (const rawUpdate of delta.updates) {
|
|
23
|
+
if (!isObject(rawUpdate)) {
|
|
24
|
+
changed = true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const update = rawUpdate;
|
|
28
|
+
let updateChanged = false;
|
|
29
|
+
const rawValues = Array.isArray(update.values) ? update.values : [];
|
|
30
|
+
if (!Array.isArray(update.values)) {
|
|
31
|
+
updateChanged = true;
|
|
32
|
+
}
|
|
33
|
+
const values = [];
|
|
34
|
+
for (const rawValue of rawValues) {
|
|
35
|
+
if (!isObject(rawValue) || !isValidValuePath(rawValue.path)) {
|
|
36
|
+
updateChanged = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
values.push(rawValue);
|
|
40
|
+
}
|
|
41
|
+
if (values.length !== rawValues.length) {
|
|
42
|
+
updateChanged = true;
|
|
43
|
+
}
|
|
44
|
+
const hasMeta = Array.isArray(update.meta) && update.meta.length > 0;
|
|
45
|
+
if (values.length === 0 && !hasMeta) {
|
|
46
|
+
changed = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (updateChanged) {
|
|
50
|
+
changed = true;
|
|
51
|
+
}
|
|
52
|
+
sanitizedUpdates.push(updateChanged ? { ...update, values } : update);
|
|
53
|
+
}
|
|
54
|
+
if (sanitizedUpdates.length === 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!changed && sanitizedUpdates.length === delta.updates.length) {
|
|
58
|
+
return delta;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
...delta,
|
|
62
|
+
updates: sanitizedUpdates
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function isDeltaLike(value) {
|
|
66
|
+
return isObject(value) && Array.isArray(value.updates);
|
|
67
|
+
}
|
|
68
|
+
function sanitizeDeltaPayloadForSignalK(delta) {
|
|
69
|
+
if (Array.isArray(delta)) {
|
|
70
|
+
const sanitized = delta
|
|
71
|
+
.map((item) => sanitizeDeltaForSignalK(item))
|
|
72
|
+
.filter((item) => item !== null);
|
|
73
|
+
return sanitized.length > 0 ? sanitized : null;
|
|
74
|
+
}
|
|
75
|
+
if (isDeltaLike(delta)) {
|
|
76
|
+
return sanitizeDeltaForSignalK(delta);
|
|
77
|
+
}
|
|
78
|
+
const sanitizedEntries = [];
|
|
79
|
+
for (const [key, value] of Object.entries(delta)) {
|
|
80
|
+
const sanitized = sanitizeDeltaForSignalK(value);
|
|
81
|
+
if (sanitized !== null) {
|
|
82
|
+
sanitizedEntries.push([key, sanitized]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sanitizedEntries.length > 0 ? Object.fromEntries(sanitizedEntries) : null;
|
|
86
|
+
}
|
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,170 @@ 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.
|
|
201
209
|
*/
|
|
202
210
|
function filterOutboundDelta(delta) {
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
return (0, delta_sanitizer_1.sanitizeDeltaForSignalK)(delta);
|
|
212
|
+
}
|
|
213
|
+
// ── Metadata streaming ────────────────────────────────────────────────────
|
|
214
|
+
/** In-memory cache of last-sent meta (hashed) per context+path. Used to
|
|
215
|
+
* compute diffs and to skip no-op periodic resends. */
|
|
216
|
+
const metaCache = new metadata_1.MetaCache();
|
|
217
|
+
/** Debounce window for coalescing live meta entries observed in the delta
|
|
218
|
+
* stream before they are transmitted as a single `diff` packet. */
|
|
219
|
+
const META_DIFF_DEBOUNCE_MS = 500;
|
|
220
|
+
/** Minimum gap between receiver-initiated snapshot sends. Prevents a noisy
|
|
221
|
+
* or malicious receiver from forcing snapshots on every delta. */
|
|
222
|
+
const META_REQUEST_RATE_LIMIT_MS = 5000;
|
|
223
|
+
/** Dispatches `entries` through the active pipeline. Returns true on a
|
|
224
|
+
* successful send so callers (e.g. `enqueueMetaDiff`) can decide whether
|
|
225
|
+
* to commit the MetaCache. Any failure is logged and returns false. */
|
|
226
|
+
async function sendMetaEntries(entries, kind) {
|
|
227
|
+
if (!options.udpAddress || !options.secretKey) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (entries.length === 0) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const protoVer = options.protocolVersion ?? 2;
|
|
234
|
+
try {
|
|
235
|
+
if (protoVer === 1) {
|
|
236
|
+
if (!options.udpMetaPort || options.udpMetaPort <= 0) {
|
|
237
|
+
app.debug(`[${instanceId}] Meta skipped: v1 pipeline requires 'udpMetaPort' in connection config`);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
await getV1Pipeline().packCryptMeta(entries, kind, options.secretKey, options.udpAddress, options.udpMetaPort);
|
|
241
|
+
}
|
|
242
|
+
else if (state.pipeline && typeof state.pipeline.sendMetadata === "function") {
|
|
243
|
+
await state.pipeline.sendMetadata(entries, kind, options.secretKey, options.udpAddress, options.udpPort);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
app.debug(`[${instanceId}] Meta skipped: pipeline not ready or does not support sendMetadata`);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
app.error(`[${instanceId}] sendMetaEntries failed: ${msg}`);
|
|
254
|
+
recordError("general", `sendMetaEntries failed: ${msg}`);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Build and transmit a full metadata snapshot from the current Signal K
|
|
260
|
+
* state tree. Resets the internal diff cache afterwards so the next diff is
|
|
261
|
+
* measured against what was just sent.
|
|
262
|
+
*/
|
|
263
|
+
async function sendMetadataSnapshot() {
|
|
264
|
+
if (!state.metaConfig?.enabled || state.stopped || !state.readyToSend) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const entries = (0, metadata_1.collectSnapshot)(appProxy, state.metaConfig);
|
|
268
|
+
const sent = await sendMetaEntries(entries, "snapshot");
|
|
269
|
+
// Only prime the diff cache on a successful send; on failure the next
|
|
270
|
+
// snapshot (periodic or META_REQUEST-triggered) will still cover every
|
|
271
|
+
// path rather than the cache showing stale "already sent" state.
|
|
272
|
+
if (sent) {
|
|
273
|
+
metaCache.replaceAll(entries);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Coalesces live meta diffs extracted from deltas; flushes after a short
|
|
277
|
+
* debounce window so a burst of meta changes becomes one packet. */
|
|
278
|
+
function enqueueMetaDiff(entries) {
|
|
279
|
+
// Buffer raw entries; the actual change-detection (and cache commit)
|
|
280
|
+
// happens in the flush handler so a failed send doesn't leave the
|
|
281
|
+
// MetaCache thinking it transmitted something it never did.
|
|
282
|
+
if (entries.length === 0) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
state.metaDiffBuffer.push(...entries);
|
|
286
|
+
if (state.metaDiffFlushTimer) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
state.metaDiffFlushTimer = setTimeout(() => {
|
|
290
|
+
state.metaDiffFlushTimer = null;
|
|
291
|
+
const pending = state.metaDiffBuffer;
|
|
292
|
+
state.metaDiffBuffer = [];
|
|
293
|
+
const changed = metaCache.computeDiff(pending);
|
|
294
|
+
if (changed.length === 0) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
sendMetaEntries(changed, "diff")
|
|
298
|
+
.then((sent) => {
|
|
299
|
+
if (sent) {
|
|
300
|
+
metaCache.commit(changed);
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
.catch((err) => {
|
|
304
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
305
|
+
app.debug(`[${instanceId}] meta diff flush failed: ${msg}`);
|
|
306
|
+
});
|
|
307
|
+
}, META_DIFF_DEBOUNCE_MS);
|
|
308
|
+
}
|
|
309
|
+
function restartMetadataTimer() {
|
|
310
|
+
if (state.metaTimer) {
|
|
311
|
+
clearInterval(state.metaTimer);
|
|
312
|
+
state.metaTimer = null;
|
|
313
|
+
}
|
|
314
|
+
if (!state.metaConfig?.enabled) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const intervalMs = Math.max(30, state.metaConfig.intervalSec) * 1000;
|
|
318
|
+
state.metaTimer = setInterval(() => {
|
|
319
|
+
sendMetadataSnapshot().catch((err) => {
|
|
320
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
321
|
+
app.debug(`[${instanceId}] periodic snapshot failed: ${msg}`);
|
|
322
|
+
});
|
|
323
|
+
}, intervalMs);
|
|
324
|
+
}
|
|
325
|
+
/** Schedules a meta snapshot send after `delayMs`. Cancels any prior
|
|
326
|
+
* pending snapshot timer first — back-to-back (re)subscribes or socket
|
|
327
|
+
* recoveries should coalesce into a single pending snapshot rather than
|
|
328
|
+
* queue up multiple sends. The returned timer is tracked on
|
|
329
|
+
* state.metaSnapshotTimers so stop() can cancel it. */
|
|
330
|
+
function scheduleMetadataSnapshot(delayMs) {
|
|
331
|
+
for (const existing of state.metaSnapshotTimers) {
|
|
332
|
+
clearTimeout(existing);
|
|
333
|
+
}
|
|
334
|
+
state.metaSnapshotTimers.length = 0;
|
|
335
|
+
const handle = setTimeout(() => {
|
|
336
|
+
const idx = state.metaSnapshotTimers.indexOf(handle);
|
|
337
|
+
if (idx !== -1) {
|
|
338
|
+
state.metaSnapshotTimers.splice(idx, 1);
|
|
339
|
+
}
|
|
340
|
+
if (state.stopped) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
sendMetadataSnapshot().catch(() => {
|
|
344
|
+
/* errors already logged inside sendMetadataSnapshot */
|
|
345
|
+
});
|
|
346
|
+
}, delayMs);
|
|
347
|
+
state.metaSnapshotTimers.push(handle);
|
|
348
|
+
}
|
|
349
|
+
/** Receiver asked for a fresh meta snapshot (META_REQUEST control packet).
|
|
350
|
+
* Rate-limited so a malformed or buggy receiver cannot force continuous
|
|
351
|
+
* snapshot work on the edge-link. */
|
|
352
|
+
function handleMetaRequest() {
|
|
353
|
+
if (!state.metaConfig?.enabled) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
if (now - state.lastMetaRequestAt < META_REQUEST_RATE_LIMIT_MS) {
|
|
358
|
+
return;
|
|
205
359
|
}
|
|
206
|
-
|
|
360
|
+
state.lastMetaRequestAt = now;
|
|
361
|
+
sendMetadataSnapshot().catch((err) => {
|
|
362
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
363
|
+
app.debug(`[${instanceId}] META_REQUEST snapshot failed: ${msg}`);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** Thin wrapper around the parser in `metadata.ts` so the instance log
|
|
367
|
+
* line is tagged with this connection's instanceId. Errors from the
|
|
368
|
+
* shared parser already have the `[meta-config]` prefix. */
|
|
369
|
+
function parseMetaConfig(raw) {
|
|
370
|
+
return (0, metadata_1.parseMetaConfig)(raw, (msg) => app.error(msg), instanceId);
|
|
207
371
|
}
|
|
208
372
|
/**
|
|
209
373
|
* Processes an incoming delta from the subscription manager.
|
|
@@ -279,6 +443,16 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
279
443
|
if (!state.readyToSend) {
|
|
280
444
|
return;
|
|
281
445
|
}
|
|
446
|
+
// Capture live meta BEFORE the delta flows into the pipeline encoder,
|
|
447
|
+
// because pathDictionary.transformDelta will strip `updates[].meta[]` when
|
|
448
|
+
// rebuilding the update objects. `extractLiveMeta` returns [] when meta
|
|
449
|
+
// streaming is disabled, so this is zero-cost in the default off state.
|
|
450
|
+
if (state.metaConfig?.enabled) {
|
|
451
|
+
const liveMeta = (0, metadata_1.extractLiveMeta)(delta, state.metaConfig, (0, metadata_1.resolveSelfContext)(appProxy));
|
|
452
|
+
if (liveMeta.length > 0) {
|
|
453
|
+
enqueueMetaDiff(liveMeta);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
282
456
|
const outboundDelta = filterOutboundDelta(delta);
|
|
283
457
|
if (!outboundDelta) {
|
|
284
458
|
return;
|
|
@@ -354,6 +528,21 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
354
528
|
_setStatus("Subscription error - data transmission paused", false);
|
|
355
529
|
recordError("subscription", `Subscription error: ${retrySubError}`);
|
|
356
530
|
}, processDelta);
|
|
531
|
+
// Retry succeeded — perform the staged commit that the original
|
|
532
|
+
// processConfig catch block skipped. Without this, the operator's
|
|
533
|
+
// new meta block (stashed on state.pendingMetaConfig) would remain
|
|
534
|
+
// inactive even though subscribe() is now working.
|
|
535
|
+
if (state.pendingMetaConfig !== undefined) {
|
|
536
|
+
state.metaConfig = state.pendingMetaConfig;
|
|
537
|
+
state.pendingMetaConfig = undefined;
|
|
538
|
+
restartMetadataTimer();
|
|
539
|
+
metaCache.clear();
|
|
540
|
+
if (state.metaConfig?.enabled) {
|
|
541
|
+
scheduleMetadataSnapshot(2000);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
state.readyToSend = true;
|
|
545
|
+
_setStatus("Subscription restored", true);
|
|
357
546
|
}
|
|
358
547
|
catch (retryError) {
|
|
359
548
|
const msg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
@@ -370,6 +559,12 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
370
559
|
processConfig: (config) => {
|
|
371
560
|
state.localSubscription = config;
|
|
372
561
|
app.debug(`[${instanceId}] Subscription configuration updated`);
|
|
562
|
+
// Stage the new metadata config — do NOT yet touch state.metaConfig,
|
|
563
|
+
// the periodic timer, or metaCache. If subscribe() throws, the old
|
|
564
|
+
// subscription remains active until the retry succeeds, so its
|
|
565
|
+
// previous metadata behaviour must remain intact.
|
|
566
|
+
const previousMetaConfig = state.metaConfig;
|
|
567
|
+
const pendingMetaConfig = parseMetaConfig(config);
|
|
373
568
|
// Capture the old cleanup handlers but do NOT call them yet.
|
|
374
569
|
// We establish the new subscription first so data keeps flowing during
|
|
375
570
|
// the handover; only after success do we release the old subscription.
|
|
@@ -386,11 +581,32 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
386
581
|
}, processDelta);
|
|
387
582
|
// New subscription established — release old cleanup handlers.
|
|
388
583
|
previousUnsubscribes.forEach((f) => f());
|
|
584
|
+
// Commit the new metadata config AFTER a successful subscribe: swap
|
|
585
|
+
// state.metaConfig, (re)start the periodic timer, and reset the diff
|
|
586
|
+
// cache so the next snapshot represents the live state in full. We
|
|
587
|
+
// reset the cache unconditionally here because even "meta unchanged"
|
|
588
|
+
// still needs an empty cache for the new subscription's path set.
|
|
589
|
+
state.metaConfig = pendingMetaConfig;
|
|
590
|
+
restartMetadataTimer();
|
|
591
|
+
metaCache.clear();
|
|
592
|
+
// Prime the receiver's meta cache with a full snapshot once the
|
|
593
|
+
// Signal K state tree has had a moment to settle after (re)subscribe.
|
|
594
|
+
if (state.metaConfig?.enabled) {
|
|
595
|
+
scheduleMetadataSnapshot(2000);
|
|
596
|
+
}
|
|
389
597
|
}
|
|
390
598
|
catch (subscribeError) {
|
|
391
599
|
// Re-subscribe failed — restore old handlers so stop() can still
|
|
392
600
|
// clean up and the previous subscription remains active until retry.
|
|
601
|
+
// Leave state.metaConfig / metaCache / metaTimer untouched so the
|
|
602
|
+
// previous subscription's metadata stream keeps running unchanged.
|
|
393
603
|
state.unsubscribes = previousUnsubscribes;
|
|
604
|
+
void previousMetaConfig; // explicit: intentionally unchanged
|
|
605
|
+
// Stash the new meta config on state so the scheduled retry can
|
|
606
|
+
// promote it when subscribe() finally succeeds. Otherwise the
|
|
607
|
+
// operator's new meta settings would silently sit unused until the
|
|
608
|
+
// user re-saved subscription.json.
|
|
609
|
+
state.pendingMetaConfig = pendingMetaConfig;
|
|
394
610
|
const subErrMsg = subscribeError instanceof Error ? subscribeError.message : String(subscribeError);
|
|
395
611
|
app.error(`[${instanceId}] Failed to subscribe: ${subErrMsg}`);
|
|
396
612
|
state.readyToSend = false;
|
|
@@ -526,6 +742,34 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
526
742
|
getV1Pipeline().unpackDecrypt(delta, options.secretKey);
|
|
527
743
|
});
|
|
528
744
|
app.debug(`[${instanceId}] [v1] Server pipeline initialized`);
|
|
745
|
+
// v1 has no packet-type byte, so meta is streamed on a separate UDP
|
|
746
|
+
// port by the client. Bind that port here when the operator has opted
|
|
747
|
+
// in. If `udpMetaPort` is unset we simply don't listen — keeping the
|
|
748
|
+
// receive side idle is the correct default for existing v1 peers that
|
|
749
|
+
// don't know about meta.
|
|
750
|
+
if (typeof options.udpMetaPort === "number" && options.udpMetaPort > 0) {
|
|
751
|
+
if (options.udpMetaPort === options.udpPort) {
|
|
752
|
+
app.error(`[${instanceId}] [v1] udpMetaPort (${options.udpMetaPort}) must differ from udpPort; meta disabled`);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
const metaSocket = dgram_1.default.createSocket({ type: "udp4", reuseAddr: true });
|
|
756
|
+
state.metaSocketUdp = metaSocket;
|
|
757
|
+
metaSocket.on("message", (msg) => {
|
|
758
|
+
getV1Pipeline()
|
|
759
|
+
.unpackDecryptMeta(msg, options.secretKey)
|
|
760
|
+
.catch((err) => {
|
|
761
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
762
|
+
app.debug(`[${instanceId}] [v1] meta decrypt failed: ${m}`);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
metaSocket.on("error", (err) => {
|
|
766
|
+
app.error(`[${instanceId}] [v1] meta socket error: ${err.message} (${err.code})`);
|
|
767
|
+
recordError("udpSend", `v1 meta socket error: ${err.message} (${err.code})`);
|
|
768
|
+
});
|
|
769
|
+
metaSocket.bind(options.udpMetaPort);
|
|
770
|
+
app.debug(`[${instanceId}] [v1] Meta listener bound to UDP :${options.udpMetaPort}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
529
773
|
}
|
|
530
774
|
const startupSocket = state.socketUdp;
|
|
531
775
|
await new Promise((resolve, reject) => {
|
|
@@ -691,6 +935,13 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
691
935
|
state.readyToSend = true;
|
|
692
936
|
_setStatus("UDP socket recovered", true);
|
|
693
937
|
app.debug(`[${instanceId}] UDP socket recovered`);
|
|
938
|
+
// A socket-level recovery is the strongest local signal that the
|
|
939
|
+
// remote receiver may have restarted. Re-prime its meta cache
|
|
940
|
+
// with a full snapshot so it doesn't have to wait a full
|
|
941
|
+
// `intervalSec` for periodic resend.
|
|
942
|
+
if (state.metaConfig?.enabled) {
|
|
943
|
+
scheduleMetadataSnapshot(1000);
|
|
944
|
+
}
|
|
694
945
|
}
|
|
695
946
|
catch (recoveryErr) {
|
|
696
947
|
state.socketRecoveryInProgress = false;
|
|
@@ -751,6 +1002,9 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
751
1002
|
const v2Pipeline = (0, pipeline_v2_client_1.createPipelineV2Client)(appProxy, state, metricsApi);
|
|
752
1003
|
state.pipeline = v2Pipeline;
|
|
753
1004
|
v2Pipeline.setMonitoring(state.monitoring);
|
|
1005
|
+
if (typeof v2Pipeline.setMetaRequestHandler === "function") {
|
|
1006
|
+
v2Pipeline.setMetaRequestHandler(handleMetaRequest);
|
|
1007
|
+
}
|
|
754
1008
|
v2Pipeline.startMetricsPublishing();
|
|
755
1009
|
if (options.congestionControl && options.congestionControl.enabled) {
|
|
756
1010
|
v2Pipeline.startCongestionControl();
|
|
@@ -833,6 +1087,18 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
833
1087
|
// Clear timers
|
|
834
1088
|
clearInterval(state.helloMessageSender ?? undefined);
|
|
835
1089
|
state.helloMessageSender = null;
|
|
1090
|
+
clearInterval(state.metaTimer ?? undefined);
|
|
1091
|
+
state.metaTimer = null;
|
|
1092
|
+
clearTimeout(state.metaDiffFlushTimer ?? undefined);
|
|
1093
|
+
state.metaDiffFlushTimer = null;
|
|
1094
|
+
for (const handle of state.metaSnapshotTimers) {
|
|
1095
|
+
clearTimeout(handle);
|
|
1096
|
+
}
|
|
1097
|
+
state.metaSnapshotTimers = [];
|
|
1098
|
+
state.metaDiffBuffer = [];
|
|
1099
|
+
state.metaConfig = null;
|
|
1100
|
+
state.pendingMetaConfig = undefined;
|
|
1101
|
+
metaCache.clear();
|
|
836
1102
|
clearTimeout(state.deltaTimer ?? undefined);
|
|
837
1103
|
state.deltaTimer = null;
|
|
838
1104
|
clearTimeout(state.pendingRetry ?? undefined);
|
|
@@ -889,12 +1155,22 @@ function createInstance(app, options, instanceId, pluginId, onStatusChange) {
|
|
|
889
1155
|
state.pingMonitor.stop();
|
|
890
1156
|
state.pingMonitor = null;
|
|
891
1157
|
}
|
|
892
|
-
// Close UDP socket
|
|
1158
|
+
// Close UDP socket(s)
|
|
893
1159
|
if (state.socketUdp) {
|
|
894
1160
|
state.socketUdp.close();
|
|
895
1161
|
state.socketUdp = null;
|
|
896
1162
|
app.debug(`[${instanceId}] Stopped`);
|
|
897
1163
|
}
|
|
1164
|
+
if (state.metaSocketUdp) {
|
|
1165
|
+
try {
|
|
1166
|
+
state.metaSocketUdp.close();
|
|
1167
|
+
}
|
|
1168
|
+
catch (err) {
|
|
1169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1170
|
+
app.debug(`[${instanceId}] Meta socket close failed: ${msg}`);
|
|
1171
|
+
}
|
|
1172
|
+
state.metaSocketUdp = null;
|
|
1173
|
+
}
|
|
898
1174
|
_setStatus("Stopped", false);
|
|
899
1175
|
}
|
|
900
1176
|
// ── Public API ────────────────────────────────────────────────────────────
|