signalk-edge-link 2.7.0 → 2.9.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,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeCompactDelta = encodeCompactDelta;
4
+ exports.encodeCompactPayload = encodeCompactPayload;
5
+ exports.decodeCompactDelta = decodeCompactDelta;
6
+ exports.isCompactDeltaArray = isCompactDeltaArray;
7
+ exports.decodeCompactDeltaArray = decodeCompactDeltaArray;
8
+ // Update tuple position constants — keeps the encoder and decoder honest.
9
+ const POS_SOURCE = 0;
10
+ const POS_DOLLAR_SOURCE = 1;
11
+ const POS_TIMESTAMP = 2;
12
+ const POS_VALUES = 3;
13
+ const POS_META = 4;
14
+ // Length we always emit so positional access is stable.
15
+ const UPDATE_TUPLE_LEN = 5;
16
+ /**
17
+ * Encode a single update block into a positional 5-element array.
18
+ * Missing fields are encoded as `null` to keep positions stable.
19
+ */
20
+ function encodeUpdate(update) {
21
+ const valuesArr = [];
22
+ if (Array.isArray(update.values)) {
23
+ for (const v of update.values) {
24
+ if (v && typeof v === "object" && v.path !== undefined) {
25
+ valuesArr.push([v.path, v.value]);
26
+ }
27
+ }
28
+ }
29
+ const metaArr = [];
30
+ if (Array.isArray(update.meta)) {
31
+ for (const m of update.meta) {
32
+ if (m && typeof m === "object" && m.path !== undefined) {
33
+ metaArr.push([m.path, m.value]);
34
+ }
35
+ }
36
+ }
37
+ // Use null for absent source/timestamp; null compresses to a single byte in msgpack.
38
+ return [
39
+ update.source ?? null,
40
+ update.$source ?? null,
41
+ update.timestamp ?? null,
42
+ valuesArr,
43
+ metaArr.length > 0 ? metaArr : null
44
+ ];
45
+ }
46
+ /**
47
+ * Encode a single Delta into a positional 2-element array.
48
+ *
49
+ * [context, [updateTuple, updateTuple, ...]]
50
+ */
51
+ function encodeCompactDelta(delta) {
52
+ const updates = Array.isArray(delta.updates) ? delta.updates : [];
53
+ return [delta.context ?? null, updates.map(encodeUpdate)];
54
+ }
55
+ /**
56
+ * Encode a Delta, Delta[], or Record<string, Delta> as a flat array of
57
+ * compact deltas. The shape (single / array / record) is collapsed to
58
+ * an array because the receiver pipeline already handles both array
59
+ * and object batch forms identically.
60
+ */
61
+ function encodeCompactPayload(payload) {
62
+ if (Array.isArray(payload)) {
63
+ return payload.map(encodeCompactDelta);
64
+ }
65
+ if (payload && typeof payload === "object" && Array.isArray(payload.updates)) {
66
+ return [encodeCompactDelta(payload)];
67
+ }
68
+ // Record<string, Delta>
69
+ const out = [];
70
+ for (const d of Object.values(payload)) {
71
+ if (d && typeof d === "object") {
72
+ out.push(encodeCompactDelta(d));
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ // ── Decoding ─────────────────────────────────────────────────────────────────
78
+ function decodeUpdate(tuple) {
79
+ if (!Array.isArray(tuple) || tuple.length < UPDATE_TUPLE_LEN)
80
+ return null;
81
+ const source = tuple[POS_SOURCE];
82
+ const dollarSource = tuple[POS_DOLLAR_SOURCE];
83
+ const timestamp = tuple[POS_TIMESTAMP];
84
+ const rawValues = tuple[POS_VALUES];
85
+ const rawMeta = tuple[POS_META];
86
+ const values = [];
87
+ if (Array.isArray(rawValues)) {
88
+ for (const vt of rawValues) {
89
+ if (Array.isArray(vt) && vt.length >= 2) {
90
+ values.push({ path: vt[0], value: vt[1] });
91
+ }
92
+ }
93
+ }
94
+ const update = { values };
95
+ if (source !== null && source !== undefined) {
96
+ update.source = source;
97
+ }
98
+ if (typeof dollarSource === "string") {
99
+ update.$source = dollarSource;
100
+ }
101
+ if (typeof timestamp === "string") {
102
+ update.timestamp = timestamp;
103
+ }
104
+ if (Array.isArray(rawMeta)) {
105
+ const meta = [];
106
+ for (const mt of rawMeta) {
107
+ if (Array.isArray(mt) && mt.length >= 2) {
108
+ meta.push({ path: mt[0], value: mt[1] });
109
+ }
110
+ }
111
+ if (meta.length > 0)
112
+ update.meta = meta;
113
+ }
114
+ return update;
115
+ }
116
+ /**
117
+ * Decode a single compact-encoded delta back into a Signal K Delta.
118
+ * Returns null for malformed input so the caller can drop it cleanly.
119
+ */
120
+ function decodeCompactDelta(payload) {
121
+ if (!Array.isArray(payload) || payload.length < 2)
122
+ return null;
123
+ const context = payload[0];
124
+ const rawUpdates = payload[1];
125
+ if (!Array.isArray(rawUpdates))
126
+ return null;
127
+ const updates = [];
128
+ for (const u of rawUpdates) {
129
+ const decoded = decodeUpdate(u);
130
+ if (decoded)
131
+ updates.push(decoded);
132
+ }
133
+ const out = {
134
+ context: typeof context === "string" ? context : "",
135
+ updates
136
+ };
137
+ return out;
138
+ }
139
+ /**
140
+ * Detect whether a parsed message is compact-encoded (flat array of
141
+ * 2-element arrays) versus a standard Signal K Delta array. We use the
142
+ * first element's shape as the discriminator:
143
+ * - compact: [[ctx, [...updates]], ...] → element 0 is itself a 2-tuple
144
+ * - standard: [{context, updates}, ...] → element 0 is an object
145
+ */
146
+ function isCompactDeltaArray(value) {
147
+ if (!Array.isArray(value) || value.length === 0)
148
+ return false;
149
+ const first = value[0];
150
+ return (Array.isArray(first) && first.length >= 2 && Array.isArray(first[1]) // updates slot is itself an array
151
+ );
152
+ }
153
+ /**
154
+ * Decode a compact-encoded array of deltas into an array of standard
155
+ * Signal K Deltas. Skips entries that fail to decode.
156
+ */
157
+ function decodeCompactDeltaArray(payload) {
158
+ if (!Array.isArray(payload))
159
+ return [];
160
+ const out = [];
161
+ for (const item of payload) {
162
+ const d = decodeCompactDelta(item);
163
+ if (d)
164
+ out.push(d);
165
+ }
166
+ return out;
167
+ }
@@ -53,7 +53,14 @@ const constants_1 = require("./constants");
53
53
  const { readFile, writeFile, mkdir } = fs_1.promises;
54
54
  function createDebouncedConfigHandler(opts) {
55
55
  const { name, getFilePath, processConfig, state, instanceId, app, readFallback } = opts;
56
- async function runLoad() {
56
+ // Serialize concurrent runLoad calls: while one is in flight, a follow-up
57
+ // call awaits its completion and only then evaluates its own work. The
58
+ // previous hash-claim trick had a window between "claim hash" and
59
+ // "processConfig await" in which a second runLoad could observe the new
60
+ // hash, skip, and silently drop a legitimate event if the first one then
61
+ // threw. A simple promise-chain serialization is strictly more correct.
62
+ let runInFlight = null;
63
+ async function runLoadInner() {
57
64
  if (state.stopped)
58
65
  return;
59
66
  let content;
@@ -72,48 +79,73 @@ function createDebouncedConfigHandler(opts) {
72
79
  app.debug(`[${instanceId}] ${name} file unchanged, skipping`);
73
80
  return;
74
81
  }
75
- // Claim the hash BEFORE awaiting processConfig so a concurrent runLoad
76
- // (e.g. the initial flush() racing an fs.watch event triggered during
77
- // startup) sees the hash and skips instead of running processConfig
78
- // twice. Without this, two processConfigs can both observe the same
79
- // pre-claim hash and both call subscribe(), leaving leaked listeners
80
- // for any path whose bus is created between the new subscribe() and
81
- // the old previousUnsubscribes.forEach() in the second call.
82
- const previousHash = state.configContentHashes[name];
83
- function revertHashClaim() {
84
- if (state.configContentHashes[name] !== contentHash)
85
- return;
86
- if (previousHash === undefined) {
87
- delete state.configContentHashes[name];
88
- }
89
- else {
90
- state.configContentHashes[name] = previousHash;
91
- }
92
- }
93
- state.configContentHashes[name] = contentHash;
94
82
  let parsed;
95
83
  try {
96
84
  parsed = content ? JSON.parse(content) : readFallback;
97
85
  }
98
86
  catch (parseErr) {
99
- // Parse failure leaves nothing valid to process. Revert the claim so
100
- // a subsequent file event (presumably with corrected content) is not
101
- // silently skipped on the unchanged-hash check.
102
- revertHashClaim();
87
+ // Parse failure: do not advance the hash so a subsequent file event
88
+ // (presumably with corrected content) is not silently skipped.
103
89
  throw parseErr;
104
90
  }
105
91
  try {
106
92
  await processConfig(parsed);
107
93
  }
108
94
  catch (err) {
109
- revertHashClaim();
95
+ // Processing failed: leave the previous hash intact so a retry can
96
+ // re-detect the same content as still-pending.
110
97
  throw err;
111
98
  }
112
- // If stop() ran between claim and the processConfig microtask, treat
113
- // the hash as unclaimed so a fresh start() will reload from disk
114
- // instead of inheriting our claim (the in-memory state is gone).
115
- if (state.stopped) {
116
- revertHashClaim();
99
+ // Only after processConfig completes successfully do we mark this
100
+ // content as the last-known-good. Holding the hash update to the
101
+ // success path means a failed apply does not silently swallow the next
102
+ // identical event.
103
+ if (!state.stopped) {
104
+ state.configContentHashes[name] = contentHash;
105
+ }
106
+ }
107
+ let rerunRequested = false;
108
+ async function runLoad() {
109
+ // Single-producer serialization: only the first caller spawns the
110
+ // runLoadInner loop; later callers set rerunRequested and return,
111
+ // so the producer drains them before clearing runInFlight. A naive
112
+ // "attach + recreate" pattern would let two callers both spawn a
113
+ // fresh runLoadInner and reintroduce overlap.
114
+ if (runInFlight) {
115
+ rerunRequested = true;
116
+ await runInFlight.catch(() => {
117
+ /* errors surface through the caller's .catch */
118
+ });
119
+ return;
120
+ }
121
+ runInFlight = (async () => {
122
+ // Drain queued reruns even if an earlier pass threw — a parse or
123
+ // apply failure must not strand the queued follow-up that
124
+ // arrived between the failure and now. The latest error is
125
+ // reported once draining is complete.
126
+ let lastError;
127
+ while (!state.stopped) {
128
+ rerunRequested = false;
129
+ try {
130
+ await runLoadInner();
131
+ lastError = undefined;
132
+ }
133
+ catch (err) {
134
+ lastError = err;
135
+ }
136
+ if (!rerunRequested) {
137
+ break;
138
+ }
139
+ }
140
+ if (lastError !== undefined) {
141
+ throw lastError;
142
+ }
143
+ })();
144
+ try {
145
+ await runInFlight;
146
+ }
147
+ finally {
148
+ runInFlight = null;
117
149
  }
118
150
  }
119
151
  const handleChange = function () {
@@ -17,7 +17,13 @@ exports.VALID_CONNECTION_KEYS = [
17
17
  "secretKey",
18
18
  "stretchAsciiKey",
19
19
  "useMsgpack",
20
+ "useValueDedup",
21
+ "useCompactDeltas",
22
+ "pathFilter",
20
23
  "usePathDictionary",
24
+ "brotliQuality",
25
+ "pathPrecision",
26
+ "pathThrottle",
21
27
  "enableNotifications",
22
28
  "skipOwnData",
23
29
  "protocolVersion",
@@ -100,6 +106,98 @@ function validateConnectionConfig(connection, prefix = "") {
100
106
  if (conn.useMsgpack !== undefined && typeof conn.useMsgpack !== "boolean") {
101
107
  return `${p}useMsgpack must be a boolean`;
102
108
  }
109
+ if (conn.useValueDedup !== undefined && typeof conn.useValueDedup !== "boolean") {
110
+ return `${p}useValueDedup must be a boolean`;
111
+ }
112
+ if (conn.useCompactDeltas !== undefined && typeof conn.useCompactDeltas !== "boolean") {
113
+ return `${p}useCompactDeltas must be a boolean`;
114
+ }
115
+ const _pv = conn.protocolVersion ?? 1;
116
+ if (conn.useValueDedup === true && _pv < 2) {
117
+ return `${p}useValueDedup is only supported on protocolVersion 2 or 3`;
118
+ }
119
+ if (conn.useCompactDeltas === true) {
120
+ if (_pv < 2) {
121
+ return `${p}useCompactDeltas is only supported on protocolVersion 2 or 3`;
122
+ }
123
+ if (conn.useMsgpack !== true) {
124
+ return `${p}useCompactDeltas requires useMsgpack to be enabled`;
125
+ }
126
+ }
127
+ if (conn.pathFilter !== undefined) {
128
+ if (typeof conn.pathFilter !== "object" ||
129
+ conn.pathFilter === null ||
130
+ Array.isArray(conn.pathFilter)) {
131
+ return `${p}pathFilter must be an object`;
132
+ }
133
+ const pf = conn.pathFilter;
134
+ for (const key of Object.keys(pf)) {
135
+ if (key !== "allow" && key !== "deny") {
136
+ return `${p}pathFilter.${key} is not a supported property`;
137
+ }
138
+ }
139
+ for (const key of ["allow", "deny"]) {
140
+ if (pf[key] !== undefined) {
141
+ if (!Array.isArray(pf[key])) {
142
+ return `${p}pathFilter.${key} must be an array of strings`;
143
+ }
144
+ const arr = pf[key];
145
+ for (let i = 0; i < arr.length; i++) {
146
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
147
+ return `${p}pathFilter.${key}[${i}] must be a non-empty string`;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (conn.brotliQuality !== undefined) {
154
+ const q = conn.brotliQuality;
155
+ if (!Number.isInteger(q) || q < 0 || q > 11) {
156
+ return `${p}brotliQuality must be an integer between 0 and 11`;
157
+ }
158
+ }
159
+ if (conn.pathPrecision !== undefined) {
160
+ if (typeof conn.pathPrecision !== "object" ||
161
+ conn.pathPrecision === null ||
162
+ Array.isArray(conn.pathPrecision)) {
163
+ return `${p}pathPrecision must be an object mapping path to integer 0..15`;
164
+ }
165
+ for (const [path, decimals] of Object.entries(conn.pathPrecision)) {
166
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 15) {
167
+ return `${p}pathPrecision["${path}"] must be an integer between 0 and 15`;
168
+ }
169
+ }
170
+ }
171
+ if (conn.pathThrottle !== undefined) {
172
+ if (typeof conn.pathThrottle !== "object" ||
173
+ conn.pathThrottle === null ||
174
+ Array.isArray(conn.pathThrottle)) {
175
+ return `${p}pathThrottle must be an object mapping path to a rule`;
176
+ }
177
+ for (const [path, rule] of Object.entries(conn.pathThrottle)) {
178
+ if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
179
+ return `${p}pathThrottle["${path}"] must be an object`;
180
+ }
181
+ const r = rule;
182
+ for (const key of Object.keys(r)) {
183
+ if (key !== "minIntervalMs" && key !== "deadband") {
184
+ return `${p}pathThrottle["${path}"].${key} is not a supported property`;
185
+ }
186
+ }
187
+ if (r.minIntervalMs !== undefined) {
188
+ if (!Number.isInteger(r.minIntervalMs) ||
189
+ r.minIntervalMs < 0 ||
190
+ r.minIntervalMs > 3600000) {
191
+ return `${p}pathThrottle["${path}"].minIntervalMs must be an integer between 0 and 3600000`;
192
+ }
193
+ }
194
+ if (r.deadband !== undefined) {
195
+ if (!isFiniteNumber(r.deadband) || r.deadband < 0) {
196
+ return `${p}pathThrottle["${path}"].deadband must be a non-negative number`;
197
+ }
198
+ }
199
+ }
200
+ }
103
201
  if (conn.usePathDictionary !== undefined && typeof conn.usePathDictionary !== "boolean") {
104
202
  return `${p}usePathDictionary must be a boolean`;
105
203
  }
package/lib/constants.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MONITORING_HEATMAP_BUCKETS = exports.PATH_STATS_MAX_SIZE = exports.BANDWIDTH_HISTORY_MAX = exports.METRICS_PUBLISH_INTERVAL = exports.MAX_PARSE_PAYLOAD_SIZE = exports.MAX_DECOMPRESSED_SIZE = exports.UDP_RATE_LIMIT_MAX_PACKETS = exports.UDP_RATE_LIMIT_WINDOW = exports.MAX_CLIENT_SESSIONS = exports.BONDING_RTT_EMA_ALPHA = exports.BONDING_HEALTH_WINDOW_SIZE = exports.BONDING_FAILBACK_LOSS_HYSTERESIS = exports.BONDING_FAILBACK_RTT_HYSTERESIS = exports.BONDING_HEARTBEAT_TIMEOUT = exports.BONDING_FAILBACK_DELAY = exports.BONDING_LOSS_THRESHOLD = exports.BONDING_RTT_THRESHOLD = exports.BONDING_HEALTH_CHECK_INTERVAL = exports.CONGESTION_DECREASE_FACTOR = exports.CONGESTION_INCREASE_FACTOR = exports.CONGESTION_RTT_MULTIPLIER_HIGH = exports.CONGESTION_LOSS_THRESHOLD_HIGH = exports.CONGESTION_LOSS_THRESHOLD_LOW = exports.CONGESTION_SMOOTHING_FACTOR = exports.CONGESTION_MAX_ADJUSTMENT = exports.CONGESTION_ADJUST_INTERVAL = exports.CONGESTION_TARGET_RTT = exports.CONGESTION_MAX_DELTA_TIMER = exports.CONGESTION_MIN_DELTA_TIMER = exports.RATE_LIMIT_MAX_REQUESTS = exports.RATE_LIMIT_WINDOW = exports.SMART_BATCH_MAX_DELTAS = exports.SMART_BATCH_MIN_DELTAS = exports.SMART_BATCH_INITIAL_ESTIMATE = exports.SMART_BATCH_SMOOTHING = exports.SMART_BATCH_SAFETY_MARGIN = exports.UDP_SEND_TIMEOUT_MS = exports.UDP_RETRY_DELAY = exports.UDP_RETRY_MAX = exports.BROTLI_QUALITY_HIGH = exports.MAX_SAFE_UDP_PAYLOAD = exports.WATCHER_RECOVERY_DELAY = exports.CONTENT_HASH_ALGORITHM = exports.FILE_WATCH_DEBOUNCE_DELAY = exports.MAX_DELTAS_PER_PACKET = exports.DELTA_BUFFER_DROP_RATIO = exports.MAX_DELTAS_BUFFER_SIZE = exports.MILLISECONDS_PER_MINUTE = exports.PING_TIMEOUT_BUFFER = exports.DEFAULT_DELTA_TIMER = void 0;
4
- exports.PACKET_INSPECTOR_MAX_CLIENTS = exports.PACKET_CAPTURE_MAX_PACKETS = exports.MONITORING_ALERT_COOLDOWN = exports.MONITORING_PATH_LATENCY_WINDOW = exports.MONITORING_RETRANSMIT_HISTORY_SIZE = exports.MONITORING_HEATMAP_BUCKET_DURATION = void 0;
3
+ exports.BANDWIDTH_HISTORY_MAX = exports.METRICS_PUBLISH_INTERVAL = exports.MAX_PARSE_PAYLOAD_SIZE = exports.MAX_DECOMPRESSED_SIZE = exports.UDP_RATE_LIMIT_MAX_PACKETS = exports.UDP_RATE_LIMIT_WINDOW = exports.MAX_CLIENT_SESSIONS = exports.BONDING_RTT_EMA_ALPHA = exports.BONDING_HEALTH_WINDOW_SIZE = exports.BONDING_FAILBACK_LOSS_HYSTERESIS = exports.BONDING_FAILBACK_RTT_HYSTERESIS = exports.BONDING_HEARTBEAT_TIMEOUT = exports.BONDING_FAILBACK_DELAY = exports.BONDING_LOSS_THRESHOLD = exports.BONDING_RTT_THRESHOLD = exports.BONDING_HEALTH_CHECK_INTERVAL = exports.CONGESTION_DECREASE_FACTOR = exports.CONGESTION_INCREASE_FACTOR = exports.CONGESTION_RTT_MULTIPLIER_HIGH = exports.CONGESTION_LOSS_THRESHOLD_HIGH = exports.CONGESTION_LOSS_THRESHOLD_LOW = exports.CONGESTION_SMOOTHING_FACTOR = exports.CONGESTION_MAX_ADJUSTMENT = exports.CONGESTION_ADJUST_INTERVAL = exports.CONGESTION_TARGET_RTT = exports.CONGESTION_MAX_DELTA_TIMER = exports.CONGESTION_MIN_DELTA_TIMER = exports.RATE_LIMIT_MAX_REQUESTS = exports.RATE_LIMIT_WINDOW = exports.SMART_BATCH_MAX_DELTAS = exports.SMART_BATCH_MIN_DELTAS = exports.SMART_BATCH_INITIAL_ESTIMATE = exports.SMART_BATCH_SMOOTHING = exports.SMART_BATCH_SAFETY_MARGIN = exports.UDP_SEND_TIMEOUT_MS = exports.UDP_RETRY_DELAY = exports.UDP_RETRY_MAX = exports.BROTLI_QUALITY_MAX = exports.BROTLI_QUALITY_MIN = exports.BROTLI_QUALITY_HIGH = exports.MAX_SAFE_UDP_PAYLOAD = exports.WATCHER_RECOVERY_DELAY = exports.CONTENT_HASH_ALGORITHM = exports.FILE_WATCH_DEBOUNCE_DELAY = exports.MAX_DELTAS_PER_PACKET = exports.DELTA_BUFFER_DROP_RATIO = exports.MAX_DELTAS_BUFFER_SIZE = exports.MILLISECONDS_PER_MINUTE = exports.PING_TIMEOUT_BUFFER = exports.DEFAULT_DELTA_TIMER = void 0;
4
+ exports.PACKET_INSPECTOR_MAX_CLIENTS = exports.PACKET_CAPTURE_MAX_PACKETS = exports.MONITORING_ALERT_COOLDOWN = exports.MONITORING_PATH_LATENCY_WINDOW = exports.MONITORING_RETRANSMIT_HISTORY_SIZE = exports.MONITORING_HEATMAP_BUCKET_DURATION = exports.MONITORING_HEATMAP_BUCKETS = exports.HELLO_PAYLOAD_MAX_BYTES = exports.SNAPSHOT_REPLAY_CHUNK_SIZE = exports.DELTA_SEND_RETRY_BACKOFF_MS = exports.DELTA_SEND_MAX_RETRIES = exports.SOURCE_SNAPSHOT_MAX_OBJECT_KEYS = exports.SOURCE_SNAPSHOT_MAX_ARRAY_LENGTH = exports.SOURCE_SNAPSHOT_MAX_DEPTH = exports.SOURCE_SNAPSHOT_MAX_STRING_LENGTH = exports.SOURCE_SNAPSHOT_MAX_KEY_LENGTH = exports.SOURCE_SNAPSHOT_MAX_PROVIDERS = exports.SOURCE_SNAPSHOT_INTERVAL_MS = exports.SOURCE_REGISTRY_TTL_MS = exports.SOURCE_REGISTRY_MAX_RECORDS = exports.OUTBOUND_DEDUPE_MAX_ENTRIES = exports.OUTBOUND_DEDUPE_CLEANUP_INTERVAL_MS = exports.SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = exports.OUTBOUND_DUPLICATE_SUPPRESS_MS = exports.PATH_THROTTLE_STATE_MAX = exports.VALUE_DEDUP_CACHE_MAX = exports.PATH_STATS_MAX_SIZE = void 0;
5
5
  exports.calculateMaxDeltasPerBatch = calculateMaxDeltasPerBatch;
6
+ exports.clampBytesPerDeltaSample = clampBytesPerDeltaSample;
6
7
  // Delta and timing
7
8
  exports.DEFAULT_DELTA_TIMER = 1000; // milliseconds
8
9
  exports.PING_TIMEOUT_BUFFER = 10000; // milliseconds - extra buffer for ping timeout
@@ -17,6 +18,8 @@ exports.WATCHER_RECOVERY_DELAY = 5000; // milliseconds
17
18
  // UDP and network
18
19
  exports.MAX_SAFE_UDP_PAYLOAD = 1400; // Maximum safe UDP payload size (avoid fragmentation)
19
20
  exports.BROTLI_QUALITY_HIGH = 6; // Balanced quality: ~90% of max compression at ~10% of the CPU cost
21
+ exports.BROTLI_QUALITY_MIN = 0; // Fastest, lowest ratio
22
+ exports.BROTLI_QUALITY_MAX = 11; // Highest ratio, ~3-5× more CPU than quality 6
20
23
  exports.UDP_RETRY_MAX = 3; // Maximum UDP send retries
21
24
  exports.UDP_RETRY_DELAY = 100; // milliseconds - base retry delay
22
25
  exports.UDP_SEND_TIMEOUT_MS = 5000; // Maximum ms to wait for socket.send() callback
@@ -66,6 +69,43 @@ exports.MAX_PARSE_PAYLOAD_SIZE = 512 * 1024; // 512 KB
66
69
  exports.METRICS_PUBLISH_INTERVAL = 1000; // Interval (ms) for publishing metrics to Signal K
67
70
  exports.BANDWIDTH_HISTORY_MAX = 60; // Keep 60 data points (5 minutes at 5s intervals)
68
71
  exports.PATH_STATS_MAX_SIZE = 500; // Max tracked paths in pathStats Map (prevent unbounded growth)
72
+ // Outbound bandwidth-optimization per-(context,path) caches. Bounded with
73
+ // LRU eviction so a link that sees many distinct paths (e.g. N2K source-address
74
+ // churn or many vessel contexts) cannot grow these maps without limit. The
75
+ // cap is far above the unique-path count of a normal boat, so eviction is a
76
+ // safety valve rather than a routine event.
77
+ exports.VALUE_DEDUP_CACHE_MAX = 10000; // Max (context,path) entries in the dedup cache
78
+ exports.PATH_THROTTLE_STATE_MAX = 10000; // Max (context,path) entries in the throttle state
79
+ // Outbound delta deduplication. signalk-server's subscriptionmanager can
80
+ // deliver the same cached/live pair about one fixed-policy window apart;
81
+ // 1500 ms catches that without suppressing legitimate periodic resends.
82
+ exports.OUTBOUND_DUPLICATE_SUPPRESS_MS = 1500;
83
+ exports.SUPPRESSED_DUPLICATE_STATS_MAX_SIZE = 50;
84
+ exports.OUTBOUND_DEDUPE_CLEANUP_INTERVAL_MS = 1000;
85
+ exports.OUTBOUND_DEDUPE_MAX_ENTRIES = 5000;
86
+ // Source replication registry. 7-day TTL chosen to retain records across a
87
+ // typical maintenance gap; 5000 records covers the busiest production
88
+ // boats observed with N2K source-address rotation churn.
89
+ exports.SOURCE_REGISTRY_MAX_RECORDS = 5000;
90
+ exports.SOURCE_REGISTRY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
91
+ // Source snapshot replication (per-peer source tree)
92
+ exports.SOURCE_SNAPSHOT_INTERVAL_MS = 60000;
93
+ exports.SOURCE_SNAPSHOT_MAX_PROVIDERS = 256;
94
+ exports.SOURCE_SNAPSHOT_MAX_KEY_LENGTH = 128;
95
+ exports.SOURCE_SNAPSHOT_MAX_STRING_LENGTH = 1024;
96
+ exports.SOURCE_SNAPSHOT_MAX_DEPTH = 8;
97
+ // Per-container limits inside a provider — without these, an authorised
98
+ // peer could stay within depth/string rules and still force large
99
+ // allocations by sending one extremely wide array or object.
100
+ exports.SOURCE_SNAPSHOT_MAX_ARRAY_LENGTH = 256;
101
+ exports.SOURCE_SNAPSHOT_MAX_OBJECT_KEYS = 256;
102
+ // Delta send retry behaviour
103
+ exports.DELTA_SEND_MAX_RETRIES = 1;
104
+ exports.DELTA_SEND_RETRY_BACKOFF_MS = 100;
105
+ // Full-status snapshot replay backpressure
106
+ exports.SNAPSHOT_REPLAY_CHUNK_SIZE = 50; // Yield to event loop every N deltas
107
+ // HELLO payload bound (UDP MTU is already a soft cap; reject anything larger)
108
+ exports.HELLO_PAYLOAD_MAX_BYTES = 4096;
69
109
  // Enhanced monitoring
70
110
  exports.MONITORING_HEATMAP_BUCKETS = 60; // Number of time buckets for packet loss heatmap
71
111
  exports.MONITORING_HEATMAP_BUCKET_DURATION = 5000; // Duration of each bucket (5 seconds)
@@ -83,3 +123,21 @@ function calculateMaxDeltasPerBatch(avgBytes) {
83
123
  const raw = Math.floor((exports.MAX_SAFE_UDP_PAYLOAD * exports.SMART_BATCH_SAFETY_MARGIN) / avgBytes);
84
124
  return Math.max(exports.SMART_BATCH_MIN_DELTAS, Math.min(exports.SMART_BATCH_MAX_DELTAS, raw));
85
125
  }
126
+ /**
127
+ * Clamp a single bytes-per-delta observation before it feeds the smart-batch
128
+ * EMA. Aggressive per-path throttling/dedup can collapse a batch to a single
129
+ * delta whose packet is dominated by fixed overhead (compression header,
130
+ * crypto IV + auth tag) or that is itself oversized. Without a bound, one such
131
+ * outlier sample can drag the rolling average to an extreme and keep the
132
+ * batcher mis-sized long after the burst passes. Clamping each sample to
133
+ * [1, MAX_SAFE_UDP_PAYLOAD] keeps the EMA stable while still letting it track
134
+ * genuine payload-size shifts.
135
+ *
136
+ * @param bytesPerDelta - Raw observation (packet bytes / deltas in packet)
137
+ * @returns Sample clamped to a sane range
138
+ */
139
+ function clampBytesPerDeltaSample(bytesPerDelta) {
140
+ if (!Number.isFinite(bytesPerDelta) || bytesPerDelta < 1)
141
+ return 1;
142
+ return Math.min(exports.MAX_SAFE_UDP_PAYLOAD, bytesPerDelta);
143
+ }
package/lib/crypto.js CHANGED
@@ -128,7 +128,9 @@ function normalizeKey(secretKey, options = {}) {
128
128
  // the raw ASCII key (hex-encoded for Map use) so the plaintext key is not
129
129
  // retained in process memory beyond the live derivation. The cache is
130
130
  // effectively bounded by the number of distinct configured keys (typically
131
- // one or two per Signal K instance).
131
+ // one or two per Signal K instance); the LRU cap is a defensive measure
132
+ // against a future caller that passes externally-influenced strings here.
133
+ const ASCII_KEY_CACHE_MAX = 32;
132
134
  const asciiKeyCache = new Map();
133
135
  function asciiKeyCacheKey(asciiKey) {
134
136
  return crypto.createHash("sha256").update(asciiKey, "utf8").digest("hex");
@@ -137,9 +139,18 @@ function getOrDeriveAsciiKey(asciiKey) {
137
139
  const cacheKey = asciiKeyCacheKey(asciiKey);
138
140
  const cached = asciiKeyCache.get(cacheKey);
139
141
  if (cached) {
142
+ // LRU refresh: move to tail by delete-then-set on insertion order Map.
143
+ asciiKeyCache.delete(cacheKey);
144
+ asciiKeyCache.set(cacheKey, cached);
140
145
  return cached;
141
146
  }
142
147
  const derived = deriveKeyFromPassphrase(asciiKey);
148
+ if (asciiKeyCache.size >= ASCII_KEY_CACHE_MAX) {
149
+ const oldest = asciiKeyCache.keys().next();
150
+ if (!oldest.done) {
151
+ asciiKeyCache.delete(oldest.value);
152
+ }
153
+ }
143
154
  asciiKeyCache.set(cacheKey, derived);
144
155
  return derived;
145
156
  }