signalk-edge-link 2.3.0 → 2.4.1

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.
@@ -56,6 +56,8 @@ const msgpack = __importStar(require("@msgpack/msgpack"));
56
56
  const crypto_1 = require("./crypto");
57
57
  const pathDictionary_1 = require("./pathDictionary");
58
58
  const delta_sanitizer_1 = require("./delta-sanitizer");
59
+ const source_dispatch_1 = require("./source-dispatch");
60
+ const source_snapshot_1 = require("./source-snapshot");
59
61
  const packet_1 = require("./packet");
60
62
  const sequence_1 = require("./sequence");
61
63
  const metrics_publisher_1 = require("./metrics-publisher");
@@ -154,6 +156,8 @@ function createPipelineV2Server(app, state, metricsApi) {
154
156
  // packet can still be processed for this request without polluting state.
155
157
  return {
156
158
  key,
159
+ sourceClientInstanceId: null,
160
+ clientId: null,
157
161
  address: rinfo.address,
158
162
  port: rinfo.port,
159
163
  sequenceTracker: new sequence_1.SequenceTracker({
@@ -175,11 +179,15 @@ function createPipelineV2Server(app, state, metricsApi) {
175
179
  rateLimitWindowStart: Date.now(),
176
180
  metaRequested: false,
177
181
  lastMetaEnvSeq: null,
178
- seenMetaChunkIdx: new Set()
182
+ seenMetaChunkIdx: new Set(),
183
+ lastSourceEnvSeq: null,
184
+ seenSourceChunkIdx: new Set()
179
185
  };
180
186
  }
181
187
  const session = {
182
188
  key,
189
+ sourceClientInstanceId: null,
190
+ clientId: null,
183
191
  address: rinfo.address,
184
192
  port: rinfo.port,
185
193
  sequenceTracker: new sequence_1.SequenceTracker({
@@ -206,7 +214,9 @@ function createPipelineV2Server(app, state, metricsApi) {
206
214
  metaRequested: false,
207
215
  // Stale-envelope rejection for METADATA packets
208
216
  lastMetaEnvSeq: null,
209
- seenMetaChunkIdx: new Set()
217
+ seenMetaChunkIdx: new Set(),
218
+ lastSourceEnvSeq: null,
219
+ seenSourceChunkIdx: new Set()
210
220
  };
211
221
  clientSessions.set(key, session);
212
222
  app.debug(`[v2-server] new client session: ${key}`);
@@ -252,6 +262,8 @@ function createPipelineV2Server(app, state, metricsApi) {
252
262
  let lastMetricsTime = Date.now();
253
263
  let lastBytesReceived = 0;
254
264
  let lastPacketsReceived = 0;
265
+ let previousSourceMissingIdentity = 0;
266
+ let previousSourceConflicts = 0;
255
267
  // Rate-limit operator-visible warnings for protocol-version mismatches so a
256
268
  // persistently misconfigured peer is noticeable in logs without flooding them.
257
269
  const PROTOCOL_VERSION_MISMATCH_WARN_INTERVAL_MS = 60000;
@@ -450,6 +462,61 @@ function createPipelineV2Server(app, state, metricsApi) {
450
462
  ackTimer = null;
451
463
  }
452
464
  }
465
+ function shouldDropEnvelopeBySeq(session, env, channel) {
466
+ if (!session || typeof env.seq !== "number" || !Number.isFinite(env.seq)) {
467
+ return false;
468
+ }
469
+ const envSeq = env.seq >>> 0;
470
+ const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
471
+ const isSource = channel === "source snapshot";
472
+ const seenChunkIdx = isSource ? session.seenSourceChunkIdx : session.seenMetaChunkIdx;
473
+ let lastEnvSeq = isSource ? session.lastSourceEnvSeq : session.lastMetaEnvSeq;
474
+ const setLastEnvSeq = (value) => {
475
+ if (isSource) {
476
+ session.lastSourceEnvSeq = value;
477
+ }
478
+ else {
479
+ session.lastMetaEnvSeq = value;
480
+ }
481
+ lastEnvSeq = value;
482
+ };
483
+ // Sender-restart detection: the client's envelope sequence counter is
484
+ // initialised to 0 at process start, so an incoming envSeq of 0 with a
485
+ // sufficiently-advanced previous seq is a strong signal that the peer
486
+ // restarted. The threshold guards against first-packet replays.
487
+ if (lastEnvSeq !== null && envSeq === 0 && lastEnvSeq >= META_RESTART_THRESHOLD) {
488
+ app.debug(`[v2-server] ${channel} sender restart detected for ${session.key} ` +
489
+ `(last seq was ${lastEnvSeq}); resetting ${channel} state`);
490
+ setLastEnvSeq(null);
491
+ seenChunkIdx.clear();
492
+ if (!isSource) {
493
+ session.metaRequested = false;
494
+ }
495
+ }
496
+ if (lastEnvSeq !== null) {
497
+ const distance = (envSeq - lastEnvSeq) >>> 0;
498
+ if (distance !== 0 && distance >= 0x80000000) {
499
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
500
+ app.debug(`[v2-server] stale ${channel} envelope seq=${envSeq} from ${session.key} (last=${lastEnvSeq}), dropping`);
501
+ return true;
502
+ }
503
+ if (distance !== 0) {
504
+ setLastEnvSeq(envSeq);
505
+ seenChunkIdx.clear();
506
+ }
507
+ else if (seenChunkIdx.has(envIdx)) {
508
+ metrics.duplicatePackets = (metrics.duplicatePackets || 0) + 1;
509
+ app.debug(`[v2-server] duplicate ${channel} chunk seq=${envSeq} idx=${envIdx} from ${session.key}, dropping`);
510
+ return true;
511
+ }
512
+ }
513
+ else {
514
+ setLastEnvSeq(envSeq);
515
+ seenChunkIdx.clear();
516
+ }
517
+ seenChunkIdx.add(envIdx);
518
+ return false;
519
+ }
453
520
  /**
454
521
  * Decrypt and dispatch a METADATA (0x06) packet.
455
522
  *
@@ -496,12 +563,20 @@ function createPipelineV2Server(app, state, metricsApi) {
496
563
  return;
497
564
  }
498
565
  const env = content;
499
- if (!Array.isArray(env.entries) || env.entries.length === 0) {
566
+ const hasSourceSnapshot = env.kind === "sources" &&
567
+ env.sources !== null &&
568
+ typeof env.sources === "object" &&
569
+ !Array.isArray(env.sources);
570
+ const entries = Array.isArray(env.entries) ? env.entries : [];
571
+ if (!hasSourceSnapshot && entries.length === 0) {
500
572
  metrics.malformedPackets = (metrics.malformedPackets || 0) + 1;
501
573
  app.debug("v2 META envelope has no entries, dropping");
502
574
  recordError("general", "v2 META envelope has no entries");
503
575
  return;
504
576
  }
577
+ if (hasSourceSnapshot && shouldDropEnvelopeBySeq(session, env, "source snapshot")) {
578
+ return;
579
+ }
505
580
  // Drop stale/duplicate envelopes that UDP reordered or replayed. The
506
581
  // inner envelope `seq` identifies a batch (shared across all chunks of
507
582
  // a multi-chunk snapshot/diff); the inner `idx` identifies a specific
@@ -510,7 +585,10 @@ function createPipelineV2Server(app, state, metricsApi) {
510
585
  // - within the current batch, reject any (seq, idx) pair already
511
586
  // processed ("exact replay"); other idx values for the same seq
512
587
  // remain accepted so multi-chunk batches still apply in full.
513
- if (session && typeof env.seq === "number" && Number.isFinite(env.seq)) {
588
+ if (!hasSourceSnapshot &&
589
+ session &&
590
+ typeof env.seq === "number" &&
591
+ Number.isFinite(env.seq)) {
514
592
  const envSeq = env.seq >>> 0;
515
593
  const envIdx = typeof env.idx === "number" && Number.isFinite(env.idx) ? env.idx >>> 0 : 0;
516
594
  // Sender-restart detection: the client's meta sequence counter is
@@ -558,12 +636,17 @@ function createPipelineV2Server(app, state, metricsApi) {
558
636
  }
559
637
  session.seenMetaChunkIdx.add(envIdx);
560
638
  }
639
+ if (hasSourceSnapshot) {
640
+ const added = (0, source_snapshot_1.mergeSourceSnapshot)(app, env.sources);
641
+ app.debug(`v2 source snapshot received: sources=${Object.keys(env.sources || {}).length}, added=${added}, envSeq=${env.seq ?? "?"}`);
642
+ return;
643
+ }
561
644
  // Group entries by context so the local Signal K server sees one delta
562
645
  // per context rather than one per path. Reduces app.handleMessage
563
646
  // overhead on big snapshots without changing semantics.
564
647
  const nowIso = new Date().toISOString();
565
648
  const byContext = new Map();
566
- for (const rawEntry of env.entries) {
649
+ for (const rawEntry of entries) {
567
650
  if (!rawEntry || typeof rawEntry.meta !== "object" || !rawEntry.meta) {
568
651
  continue;
569
652
  }
@@ -605,7 +688,7 @@ function createPipelineV2Server(app, state, metricsApi) {
605
688
  };
606
689
  app.handleMessage("", deltaMessage);
607
690
  }
608
- app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${env.entries.length}, contexts=${byContext.size}, envSeq=${env.v ?? "?"}`);
691
+ app.debug(`v2 meta received: kind=${env.kind ?? "?"}, entries=${entries.length}, contexts=${byContext.size}, envSeq=${env.seq ?? "?"}`);
609
692
  }
610
693
  catch (err) {
611
694
  const msg = err instanceof Error ? err.message : String(err);
@@ -712,6 +795,16 @@ function createPipelineV2Server(app, state, metricsApi) {
712
795
  try {
713
796
  const info = JSON.parse(parsed.payload.toString());
714
797
  app.debug(`v2 hello from client: ${JSON.stringify(info)}`);
798
+ if (session && info && typeof info === "object") {
799
+ const helloClientId = typeof info.clientId === "string" && info.clientId.trim()
800
+ ? info.clientId.trim()
801
+ : null;
802
+ const helloInstanceId = typeof info.instanceId === "string" && info.instanceId.trim()
803
+ ? info.instanceId.trim()
804
+ : null;
805
+ session.clientId = helloClientId;
806
+ session.sourceClientInstanceId = helloInstanceId || helloClientId;
807
+ }
715
808
  }
716
809
  catch (parseErr) {
717
810
  app.error(`v2 failed to parse HELLO payload: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
@@ -879,12 +972,25 @@ function createPipelineV2Server(app, state, metricsApi) {
879
972
  continue;
880
973
  }
881
974
  deltaMessage = sanitizedDelta;
975
+ deltaMessage = (0, source_dispatch_1.normalizeDeltaSourceRefs)(deltaMessage);
882
976
  _ingestRemoteTelemetry(deltaMessage);
883
977
  if (!Array.isArray(deltaMessage.updates) || deltaMessage.updates.length === 0) {
884
978
  continue;
885
979
  }
980
+ if (state.sourceRegistry && typeof state.sourceRegistry.upsertFromDelta === "function") {
981
+ const deltaRecord = deltaMessage;
982
+ const deltaSourceInstanceId = deltaMessage &&
983
+ typeof deltaMessage === "object" &&
984
+ typeof deltaRecord.sourceClientInstanceId === "string"
985
+ ? deltaRecord.sourceClientInstanceId || null
986
+ : null;
987
+ const stableSourceClientId = (session && (session.sourceClientInstanceId || session.clientId)) ||
988
+ deltaSourceInstanceId ||
989
+ "unknown";
990
+ state.sourceRegistry.upsertFromDelta(deltaMessage, stableSourceClientId);
991
+ }
886
992
  trackPathStats(deltaMessage, decompressed.length / deltas.length);
887
- app.handleMessage("", deltaMessage);
993
+ (0, source_dispatch_1.handleMessageBySource)(app, deltaMessage);
888
994
  metrics.deltasReceived++;
889
995
  }
890
996
  app.debug(`v2 received: seq=${parsed.sequence}, ${deltaCount} deltas, ${packet.length} bytes`);
@@ -943,7 +1049,8 @@ function createPipelineV2Server(app, state, metricsApi) {
943
1049
  sessions,
944
1050
  totalSessions: clientSessions.size,
945
1051
  acksSent: metrics.acksSent,
946
- naksSent: metrics.naksSent
1052
+ naksSent: metrics.naksSent,
1053
+ sourceReplication: state.sourceRegistry ? state.sourceRegistry.getMetrics() : null
947
1054
  };
948
1055
  }
949
1056
  /**
@@ -1027,6 +1134,16 @@ function createPipelineV2Server(app, state, metricsApi) {
1027
1134
  const effectiveQueueDepth = hasRemoteTelemetry ? remote.queueDepth || 0 : 0;
1028
1135
  const effectiveRetransmitRate = hasRemoteTelemetry ? remote.retransmitRate || 0 : 0;
1029
1136
  const effectiveActiveLink = hasRemoteTelemetry ? remote.activeLink || "primary" : "primary";
1137
+ const sourceReplicationMetrics = state.sourceRegistry
1138
+ ? state.sourceRegistry.getMetrics()
1139
+ : { upserts: 0, noops: 0, missingIdentity: 0, conflicts: 0 };
1140
+ const deltaMissing = sourceReplicationMetrics.missingIdentity - previousSourceMissingIdentity;
1141
+ const deltaConflicts = sourceReplicationMetrics.conflicts - previousSourceConflicts;
1142
+ if (deltaMissing > 0 || deltaConflicts > 0) {
1143
+ app.debug(`[source-replication] +missingIdentity=${deltaMissing} +conflicts=${deltaConflicts} totalMissingIdentity=${sourceReplicationMetrics.missingIdentity} totalConflicts=${sourceReplicationMetrics.conflicts} size=${state.sourceRegistry.snapshot().size}`);
1144
+ }
1145
+ previousSourceMissingIdentity = sourceReplicationMetrics.missingIdentity;
1146
+ previousSourceConflicts = sourceReplicationMetrics.conflicts;
1030
1147
  // Publish to Signal K
1031
1148
  metricsPublisher.publish({
1032
1149
  rtt: effectiveRtt,
package/lib/pipeline.js CHANGED
@@ -36,6 +36,7 @@ const msgpack = __importStar(require("@msgpack/msgpack"));
36
36
  const crypto_1 = require("./crypto");
37
37
  const pathDictionary_1 = require("./pathDictionary");
38
38
  const delta_sanitizer_1 = require("./delta-sanitizer");
39
+ const source_dispatch_1 = require("./source-dispatch");
39
40
  const pipeline_utils_1 = require("./pipeline-utils");
40
41
  const constants_1 = require("./constants");
41
42
  const metadata_1 = require("./metadata");
@@ -294,9 +295,10 @@ function createPipeline(app, state, metricsApi) {
294
295
  app.debug(`Skipping delta with no valid Signal K values at index ${i}`);
295
296
  continue;
296
297
  }
298
+ deltaMessage = (0, source_dispatch_1.normalizeDeltaSourceRefs)(deltaMessage);
297
299
  // Track path stats for server-side analytics
298
300
  trackPathStats(deltaMessage, decompressed.length / deltas.length);
299
- app.handleMessage("", deltaMessage);
301
+ (0, source_dispatch_1.handleMessageBySource)(app, deltaMessage);
300
302
  // Log a compact summary only — never log full delta values which may
301
303
  // contain sensitive data (position, fuel, MMSI) in plaintext logs.
302
304
  app.debug(`delta ctx=${deltaMessage.context ?? "?"} updates=${Array.isArray(deltaMessage.updates) ? deltaMessage.updates.length : 0}`);
@@ -9,7 +9,7 @@ const prometheus_1 = require("../prometheus");
9
9
  * @param ctx - Shared route context (helpers, middleware, registry)
10
10
  */
11
11
  function register(router, ctx) {
12
- const { rateLimitMiddleware, instanceRegistry, getFirstBundle, getEffectiveNetworkQuality, getActiveMetricsPublisher, buildFullMetricsResponse } = ctx;
12
+ const { rateLimitMiddleware, instanceRegistry, getFirstBundle, getEffectiveNetworkQuality, getActiveMetricsPublisher, buildFullMetricsResponse, managementAuthMiddleware } = ctx;
13
13
  router.get("/metrics", rateLimitMiddleware, (req, res) => {
14
14
  try {
15
15
  const bundle = getFirstBundle();
@@ -124,4 +124,28 @@ function register(router, ctx) {
124
124
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
125
125
  }
126
126
  });
127
+ router.get("/sources", rateLimitMiddleware, managementAuthMiddleware("sources.read"), (req, res) => {
128
+ try {
129
+ const serverBundle = instanceRegistry
130
+ .getAll()
131
+ .find((bundle) => bundle.state && bundle.state.isServerMode && bundle.state.sourceRegistry) || null;
132
+ const bundle = serverBundle || getFirstBundle();
133
+ if (!bundle) {
134
+ return res.status(503).json({ error: "Plugin not started" });
135
+ }
136
+ const { state } = bundle;
137
+ if (!state.sourceRegistry) {
138
+ return res.json({
139
+ schemaVersion: 1,
140
+ size: 0,
141
+ sources: [],
142
+ legacy: { byLabel: {}, bySourceRef: {} }
143
+ });
144
+ }
145
+ return res.json(state.sourceRegistry.snapshot());
146
+ }
147
+ catch (err) {
148
+ return res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
149
+ }
150
+ });
127
151
  }
package/lib/routes.js CHANGED
@@ -442,6 +442,12 @@ function createRoutes(app, instanceRegistry, pluginRef) {
442
442
  timestamp: metrics.lastErrorTime,
443
443
  timeAgo: metrics.lastErrorTime ? Date.now() - metrics.lastErrorTime : null
444
444
  }
445
+ : null,
446
+ sourceReplication: state.sourceRegistry
447
+ ? {
448
+ metrics: state.sourceRegistry.getMetrics(),
449
+ registry: null
450
+ }
445
451
  : null
446
452
  };
447
453
  return metricsData;
@@ -15,7 +15,7 @@
15
15
  * results to `RJSFSchema` at call sites.
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.alertThresholdsProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.commonConnectionProperties = void 0;
18
+ exports.alertThresholdsProperty = exports.skipOwnDataProperty = exports.enableNotificationsProperty = exports.bondingProperty = exports.congestionControlProperty = exports.serverReliabilityProperty = exports.clientReliabilityProperty = exports.clientTransportProperties = exports.commonConnectionProperties = void 0;
19
19
  exports.buildConnectionItemSchema = buildConnectionItemSchema;
20
20
  exports.buildWebappConnectionSchema = buildWebappConnectionSchema;
21
21
  const crypto_constants_1 = require("./crypto-constants");
@@ -424,6 +424,13 @@ exports.enableNotificationsProperty = {
424
424
  description: "Emit Signal K notifications for alerts and failover events.",
425
425
  default: false
426
426
  };
427
+ // ── Client-only: skip forwarding plugin-generated data ────────────────────────
428
+ exports.skipOwnDataProperty = {
429
+ type: "boolean",
430
+ title: "Skip Plugin's Own Data",
431
+ description: "Do not forward data this plugin publishes locally over the link. Strips entries under 'networking.edgeLink.*' and the v1 RTT path 'networking.modem.rtt' / 'networking.modem.<id>.rtt'; other 'networking.modem.*' paths from external providers are left intact. Also suppresses the v2/v3 client telemetry packet that mirrors local link metrics to the receiver.",
432
+ default: false
433
+ };
427
434
  // ── v2/v3 monitoring alert thresholds (client) ────────────────────────────────
428
435
  exports.alertThresholdsProperty = {
429
436
  type: "object",
@@ -501,6 +508,7 @@ function buildConnectionItemSchema() {
501
508
  congestionControl: exports.congestionControlProperty,
502
509
  bonding: exports.bondingProperty,
503
510
  enableNotifications: exports.enableNotificationsProperty,
511
+ skipOwnData: exports.skipOwnDataProperty,
504
512
  alertThresholds: exports.alertThresholdsProperty
505
513
  },
506
514
  required: ["udpAddress", "testAddress", "testPort"]
@@ -524,6 +532,7 @@ function buildWebappConnectionSchema(isClient, protocolVersion) {
524
532
  if (isClient) {
525
533
  Object.assign(props, exports.clientTransportProperties);
526
534
  props.enableNotifications = exports.enableNotificationsProperty;
535
+ props.skipOwnData = exports.skipOwnDataProperty;
527
536
  required.push("udpAddress", "testAddress", "testPort");
528
537
  if (isReliableProtocol) {
529
538
  props.reliability = exports.clientReliabilityProperty;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeDeltaSourceRefs = normalizeDeltaSourceRefs;
4
+ exports.handleMessageBySource = handleMessageBySource;
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ function getSourceLabel(update) {
9
+ const source = isRecord(update.source) ? update.source : null;
10
+ const label = source && typeof source.label === "string" ? source.label.trim() : "";
11
+ return label.length > 0 ? label : "";
12
+ }
13
+ function hasStaleEdgeLinkSourceRef(update) {
14
+ const sourceLabel = getSourceLabel(update);
15
+ const sourceRef = typeof update.$source === "string" ? update.$source.trim() : "";
16
+ if (!sourceLabel || !sourceRef || sourceLabel === "signalk-edge-link") {
17
+ return false;
18
+ }
19
+ return (sourceRef === "signalk-edge-link" ||
20
+ sourceRef.startsWith("signalk-edge-link.") ||
21
+ sourceRef.startsWith("signalk-edge-link:"));
22
+ }
23
+ function normalizeUpdateSourceRef(update) {
24
+ if (!hasStaleEdgeLinkSourceRef(update)) {
25
+ return update;
26
+ }
27
+ const cloned = { ...update };
28
+ delete cloned.$source;
29
+ return cloned;
30
+ }
31
+ function cloneUpdate(update) {
32
+ const normalized = normalizeUpdateSourceRef(update);
33
+ const cloned = {
34
+ ...normalized,
35
+ source: isRecord(normalized.source)
36
+ ? { ...normalized.source }
37
+ : normalized.source,
38
+ values: Array.isArray(normalized.values)
39
+ ? normalized.values.map((value) => ({ ...value }))
40
+ : normalized.values,
41
+ meta: Array.isArray(normalized.meta)
42
+ ? normalized.meta.map((entry) => ({ ...entry }))
43
+ : normalized.meta
44
+ };
45
+ return cloned;
46
+ }
47
+ function normalizeDeltaSourceRefs(delta) {
48
+ if (!delta || !Array.isArray(delta.updates)) {
49
+ return delta;
50
+ }
51
+ let changed = false;
52
+ const updates = delta.updates.map((update) => {
53
+ const normalized = normalizeUpdateSourceRef(update);
54
+ if (normalized !== update) {
55
+ changed = true;
56
+ }
57
+ return normalized;
58
+ });
59
+ return changed ? { ...delta, updates } : delta;
60
+ }
61
+ /**
62
+ * Signal K's app.handleMessage(providerId, delta) rewrites update.source.label
63
+ * to providerId before applying the delta. Remote updates can contain several
64
+ * original source labels, so dispatch them under their original label. Stale
65
+ * edge-link `$source` values are removed separately before dispatch so Signal K
66
+ * can recompute them from the structured source object.
67
+ */
68
+ function handleMessageBySource(app, delta) {
69
+ if (!delta || !Array.isArray(delta.updates) || delta.updates.length === 0) {
70
+ return;
71
+ }
72
+ const grouped = new Map();
73
+ let hasOriginalSourceLabel = false;
74
+ for (const update of delta.updates) {
75
+ const sourceLabel = getSourceLabel(update);
76
+ if (sourceLabel) {
77
+ hasOriginalSourceLabel = true;
78
+ }
79
+ const providerId = sourceLabel || "";
80
+ const updates = grouped.get(providerId);
81
+ if (updates) {
82
+ updates.push(update);
83
+ }
84
+ else {
85
+ grouped.set(providerId, [update]);
86
+ }
87
+ }
88
+ if (!hasOriginalSourceLabel) {
89
+ app.handleMessage("", delta);
90
+ return;
91
+ }
92
+ for (const [providerId, updates] of grouped) {
93
+ app.handleMessage(providerId, {
94
+ ...delta,
95
+ updates: updates.map(cloneUpdate)
96
+ });
97
+ }
98
+ }