@camstack/server 1.1.61 → 1.1.63

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enrichInputWithUserAgent = enrichInputWithUserAgent;
4
+ exports.enrichInputWithRelayClass = enrichInputWithRelayClass;
4
5
  exports.wrapWebrtcSessionProviderWithRelay = wrapWebrtcSessionProviderWithRelay;
5
6
  exports.buildAppRouter = buildAppRouter;
6
7
  const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
@@ -39,30 +40,93 @@ function enrichInputWithUserAgent(input, userAgent) {
39
40
  };
40
41
  }
41
42
  /**
42
- * Relay-only forcing for remote viewers is DISABLED (2026-05-26).
43
+ * Operator gate for FORCING TURN-relay-only ICE on genuinely-remote viewers.
44
+ * DEFAULT OFF.
43
45
  *
44
- * It was meant to give CGNAT/4G viewers a clean relay↔relay path, but werift's
45
- * TURN media-forward is unreliable between two real TURN servers (relay↔relay
46
- * connects yet media never arrives → connected-but-black), and forcing relay
47
- * ALSO kills the direct LAN/Tailscale host pair which carries full native
48
- * quality with no relay. We now offer ALL candidates (host incl. the hub's
49
- * advertised Tailscale address, srflx, relay) and let ICE nominate the best
50
- * reachable pair: direct when possible, relay only as a fallback. The
51
- * `relayOnly` cap field + broker support remain for when relay media-forward
52
- * is fixed.
46
+ * The forced relay↔relay path is disabled by default because it was NEVER
47
+ * validated to deliver media. It was originally disabled on 2026-05-26 after a
48
+ * connected relay session produced a black picture ICE/DTLS completed but no
49
+ * decodable media was forwarded over the relay (a MEDIA-FORWARD reliability
50
+ * bug, not a signalling one). The later werift+0.22.9 patch
51
+ * (`patches/werift+0.22.9.patch`) fixed a DIFFERENT bug a host/srflx
52
+ * candidate LEAK in the forced-relay SDP and does nothing for relay
53
+ * media-forward, so the connected-but-black regression remains unproven-fixed.
53
54
  *
54
- * The wrapper additionally enriches the `createSession` / `handleOffer`
55
- * subscriber attribution with the originating client's User-Agent, read
56
- * from the tRPC request context (browser sessions). All OTHER methods
57
- * delegate straight through auth, the remote-proxy factory and every
58
- * signaling behaviour are untouched.
55
+ * Until an operator confirms that ONE real remote relay session (4G /
56
+ * Cloudflare-tunnel) actually delivers media, remote viewers stay on the same
57
+ * `relayOnly:false` path as everyone else. Set `CAMSTACK_FORCE_RELAY_REMOTE=1`
58
+ * (or `true`) to re-enable remote→relay once validated; the broker's pairClass
59
+ * logging is the instrument for that validation.
60
+ */
61
+ const FORCE_RELAY_REMOTE = process.env.CAMSTACK_FORCE_RELAY_REMOTE === '1' ||
62
+ process.env.CAMSTACK_FORCE_RELAY_REMOTE === 'true';
63
+ /**
64
+ * Inject the SERVER-computed `relayOnly` ICE override.
65
+ *
66
+ * The client's three-way classification (`lan | vpn | remote`, see
67
+ * `client-ip.ts`) is ALWAYS computed by the caller and passed here so the
68
+ * broker's pairClass logging can observe the viewer class for every session.
69
+ * Whether that class actually forces a relay is gated by
70
+ * `FORCE_RELAY_REMOTE` (default OFF — see above):
71
+ * - gate OFF → `relayOnly: false` for ALL classes (today's effective,
72
+ * validated behaviour: nobody is forced onto the unproven relay path).
73
+ * - gate ON → `deriveRelayOnly(clientClass)` — only genuinely-remote
74
+ * viewers (public / CGNAT-4G / Cloudflare-tunnel) get `relayOnly: true`;
75
+ * `lan` and `vpn` (Tailscale) keep the low-latency direct host/srflx path.
76
+ *
77
+ * When the class is `null` (mesh-originated call — no request context) the
78
+ * input passes through UNCHANGED so a trusted addon-set `relayOnly` (e.g.
79
+ * Alexa) is never clobbered. Any client-supplied `relayOnly` on a
80
+ * request-bound call IS overwritten — the hub trusts only the request context.
81
+ *
82
+ * Immutable — builds a NEW input object, never mutates the caller's.
83
+ */
84
+ function enrichInputWithRelayClass(input, clientClass) {
85
+ if (clientClass === null)
86
+ return input;
87
+ const relayOnly = FORCE_RELAY_REMOTE ? (0, client_ip_js_1.deriveRelayOnly)(clientClass) : false;
88
+ return { ...input, relayOnly };
89
+ }
90
+ /**
91
+ * Per-request wrapper around the resolved `webrtc-session` broker singleton.
92
+ *
93
+ * Two SERVER-side enrichments, both read from the tRPC request context (the
94
+ * broker is a forked addon that cannot see the HTTP request):
95
+ * 1. `relayOnly` — the ICE policy. The client is classified three ways
96
+ * (`lan | vpn | remote`, 2026-07-15): Tailscale (`vpn`) is distinguished
97
+ * from genuine internet (`remote`) and Cloudflare Tunnel's
98
+ * `CF-Connecting-IP` on the loopback connector path is honoured, closing
99
+ * the two blind spots the boolean classifier had (a tailnet client on the
100
+ * hub's own 100.64/10 address is `vpn`; a Cloudflare-tunneled viewer with
101
+ * a loopback socket peer is `remote`, not misread as LAN). The class is
102
+ * ALWAYS computed and threaded so the broker's pairClass logging can
103
+ * observe it — but FORCING remote viewers onto a relay↔relay path is
104
+ * GATED OFF by default (`CAMSTACK_FORCE_RELAY_REMOTE`, see
105
+ * `enrichInputWithRelayClass`).
106
+ *
107
+ * Why gated: the forced-relay MEDIA-FORWARD path was disabled on
108
+ * 2026-05-26 because a connected relay session delivered NO decodable
109
+ * media (ICE/DTLS up, picture black). The werift+0.22.9 patch
110
+ * (`patches/werift+0.22.9.patch`) fixed a DIFFERENT bug — the forced-relay
111
+ * SDP host/srflx candidate LEAK — and does NOT address relay
112
+ * media-forward, so the connected-but-black regression is still unproven-
113
+ * fixed. The flag lets the operator re-enable remote→relay only after
114
+ * validating that ONE real 4G / Cloudflare-tunnel relay session actually
115
+ * delivers media (the broker pairClass logging is the instrument). Until
116
+ * then every class gets `relayOnly:false`.
117
+ * 2. `consumerAttribution.userAgent` — the browser UA for the broker's
118
+ * client list.
119
+ *
120
+ * All OTHER methods delegate straight through — auth, the remote-proxy
121
+ * factory and every signaling behaviour are untouched.
59
122
  */
60
123
  function wrapWebrtcSessionProviderWithRelay(provider, ctx) {
61
124
  const userAgent = (0, client_ip_js_1.extractUserAgent)(ctx.req);
125
+ const clientClass = (0, client_ip_js_1.classifyClientRequest)(ctx.req);
62
126
  return {
63
127
  ...provider,
64
- createSession: (input) => provider.createSession(enrichInputWithUserAgent(input, userAgent)),
65
- handleOffer: (input) => provider.handleOffer(enrichInputWithUserAgent(input, userAgent)),
128
+ createSession: (input) => provider.createSession(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass)),
129
+ handleOffer: (input) => provider.handleOffer(enrichInputWithRelayClass(enrichInputWithUserAgent(input, userAgent), clientClass)),
66
130
  };
67
131
  }
68
132
  /**
@@ -38,6 +38,7 @@ exports.shouldEmitProviderRegisteredReady = shouldEmitProviderRegisteredReady;
38
38
  /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
39
39
  const os = __importStar(require("node:os"));
40
40
  const addon_row_manifest_1 = require("./addon-row-manifest");
41
+ const runner_convergence_1 = require("./runner-convergence");
41
42
  const types_1 = require("@camstack/types");
42
43
  const system_1 = require("@camstack/system");
43
44
  const system_2 = require("@camstack/system");
@@ -84,6 +85,15 @@ function isSettingsStore(backend) {
84
85
  function isAddonRoutesInvoker(provider) {
85
86
  return typeof Reflect.get(provider, 'invoke') === 'function';
86
87
  }
88
+ /**
89
+ * Window within which a second `restart:true` respawn of the SAME group runner
90
+ * is coalesced to a no-op (a package reload loops `restartAddon` over every
91
+ * group member — see `recentGroupRespawns`). Chosen comfortably longer than a
92
+ * single group respawn (child fork + init) so all members of one reload pass
93
+ * fall inside it, and short enough that a genuinely independent later restart
94
+ * still respawns.
95
+ */
96
+ const GROUP_RESPAWN_COALESCE_MS = 10_000;
87
97
  const ROUTE_METHODS = [
88
98
  'GET',
89
99
  'POST',
@@ -197,6 +207,17 @@ class AddonRegistryService {
197
207
  * `restartAddon` completes (success or failure) or by a 90s safety timer.
198
208
  */
199
209
  restartingAddons = new Map();
210
+ /**
211
+ * `runnerId → last-respawn epoch-ms`, used ONLY to coalesce redundant group
212
+ * respawns. A package reload loops `restartAddon` over EVERY addon in the
213
+ * package (`tryReloadPackage`); for a co-location group that maps N members
214
+ * onto ONE runner, the first member's restart already respawns the whole
215
+ * group (all members reboot from fresh on-disk code), so the 2nd..Nth
216
+ * restarts must NOT respawn again — that churns the runner N times and risks
217
+ * tripping the crash circuit-breaker. Consulted by `ensureForkedRunner`; only
218
+ * groups (roster > 1) are coalesced, so solo-addon restart stays byte-identical.
219
+ */
220
+ recentGroupRespawns = new Map();
200
221
  logger;
201
222
  addonLoader;
202
223
  healthMonitor;
@@ -1096,17 +1117,22 @@ class AddonRegistryService {
1096
1117
  // `@camstack/system` builtins reach the in-process path below) it
1097
1118
  // boots in-process on the hub.
1098
1119
  if (this.isForkedAddonEntry(entry)) {
1099
- // D5: one-addon-one-process. A forked addon ALWAYS boots in its
1100
- // own runner — there is NO in-process-on-the-hub fallback. If the
1101
- // runner spawn fails, the addon is `failed`, surfaced as
1102
- // `addon.error` + recorded on the health monitor; it does not
1103
- // silently run on the hub. (Task 7's circuit breaker governs the
1104
- // retry policy on top of this.)
1120
+ // D5 + co-location: a forked addon ALWAYS boots in a runner — there is
1121
+ // NO in-process-on-the-hub fallback. `ensureForkedRunner` resolves the
1122
+ // addon's runner id (`resolveRunnerId`) and spawns/joins the FULL
1123
+ // co-located roster: a groupless addon gets its own runner (one addon,
1124
+ // one process unchanged), while a `execution.group` member co-locates
1125
+ // with its siblings so cross-addon cap calls resolve in-process (zero
1126
+ // copy). Idempotent when a sibling already brought the group up. If the
1127
+ // spawn fails, the addon is `failed`, surfaced as `addon.error` +
1128
+ // recorded on the health monitor; it does not silently run on the hub.
1129
+ // (Task 7's circuit breaker governs the retry policy on top of this.)
1130
+ //
1131
+ // Provider registration for forkable addons is delegated to the
1132
+ // `CapabilityBridge` (see `MoleculerService.onProviderConnected`);
1133
+ // custom actions are (re-)registered inside `ensureForkedRunner`.
1105
1134
  try {
1106
- await this.broker.call('$process.spawnRunner', {
1107
- runnerId: id,
1108
- addons: [{ addonId: id, addonDir: entry.addonDir }],
1109
- });
1135
+ await this.ensureForkedRunner(id, { restart: false });
1110
1136
  }
1111
1137
  catch (err) {
1112
1138
  const msg = (0, types_1.errMsg)(err);
@@ -1121,23 +1147,6 @@ class AddonRegistryService {
1121
1147
  this.healthMonitor.recordFailure(entry.packageName, err, id);
1122
1148
  throw new Error(`Failed to spawn runner for addon "${id}": ${msg}`, { cause: err });
1123
1149
  }
1124
- // Provider registration for forkable addons is delegated to the
1125
- // `CapabilityBridge` (see `MoleculerService.onProviderConnected`).
1126
- // When the spawned child announces its Moleculer service via
1127
- // NODE_INFO, the bridge builds a proxy from the capability
1128
- // definition (`{ id, nodeId, ...methods }`) and registers it.
1129
- //
1130
- // Custom actions: read the catalog fresh from the on-disk bundle
1131
- // and register it against the hub-side `CustomActionRegistry`.
1132
- // `registerForkedAddonCustomActions` re-`import()`s the entry
1133
- // (cache-busted), so it covers the hot-load path — where the addon
1134
- // was registered via `freshLoader` and `this.addonLoader`'s cached
1135
- // `module` namespace is stale or absent. Dispatch routes through
1136
- // `broker.call('<addonId>.custom.<action>')`, the only divergence
1137
- // vs in-process being the transport, exactly like cap methods.
1138
- await this.registerForkedAddonCustomActions(id,
1139
- // `isForkedAddonEntry` narrowed `entry.declaration` to non-null.
1140
- (0, types_1.resolveRunnerId)(entry.declaration, id));
1141
1150
  entry.initialized = true;
1142
1151
  this.logger.info('Addon spawned as isolated process', { tags: { addonId: id } });
1143
1152
  this.emitAddonLifecycleEvent('addon.started', id);
@@ -1357,23 +1366,40 @@ class AddonRegistryService {
1357
1366
  // restarts. The Moleculer `$node.disconnected` handler skips entries
1358
1367
  // present in `restartingAddons`; a 90s safety timer clears the flag
1359
1368
  // even if the restart path throws before the finally block runs.
1360
- const existingTimer = this.restartingAddons.get(addonId);
1361
- if (existingTimer)
1362
- clearTimeout(existingTimer);
1363
- const safetyTimer = setTimeout(() => {
1364
- this.restartingAddons.delete(addonId);
1365
- }, 90_000);
1366
- this.restartingAddons.set(addonId, safetyTimer);
1369
+ // Suppress the banner for the restarted addon AND — for a co-location
1370
+ // group — every SIBLING sharing its runner. `ensureForkedRunner` respawns
1371
+ // the WHOLE group process, so all members briefly `$node.disconnect`; without
1372
+ // suppressing the siblings too, each would flash a spurious "Failed to load".
1373
+ const suppressed = new Set([addonId]);
1374
+ if (this.isForkedAddonEntry(entry)) {
1375
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
1376
+ for (const { addonId: siblingId } of this.buildAddonGroupPlan([
1377
+ ...this.addonEntries.keys(),
1378
+ ]).get(runnerId) ?? []) {
1379
+ suppressed.add(siblingId);
1380
+ }
1381
+ }
1382
+ for (const id of suppressed) {
1383
+ const prior = this.restartingAddons.get(id);
1384
+ if (prior)
1385
+ clearTimeout(prior);
1386
+ const timer = setTimeout(() => {
1387
+ this.restartingAddons.delete(id);
1388
+ }, 90_000);
1389
+ this.restartingAddons.set(id, timer);
1390
+ }
1367
1391
  try {
1368
- // Group-runner-hosted addon — delegate to $process.restart for the group
1392
+ // Group-runner-hosted addon — converge the runner topology onto the
1393
+ // declared group, then respawn it. `ensureForkedRunner({ restart: true })`
1394
+ // both (a) adopts a NEW roster if `execution.group` changed since the
1395
+ // runner last spawned (stop stale solo runners → spawn the group; a plain
1396
+ // `$process.restart` would respawn the OLD roster forever), and (b) on the
1397
+ // common path respawns the group in place so the child reboots the WHOLE
1398
+ // roster from the freshly-swapped on-disk bundle. Updating ONE member of a
1399
+ // group therefore respawns the ENTIRE group — they share a process.
1369
1400
  if (this.isForkedAddonEntry(entry)) {
1370
- const result = (await this.broker.call('$process.restart', {
1371
- name: addonId,
1372
- }));
1373
- if (!result.success) {
1374
- throw new Error(`Process restart failed: ${result.reason ?? 'unknown'}`);
1375
- }
1376
- // $process.restart resolves as soon as the child is respawned, not when its
1401
+ await this.ensureForkedRunner(addonId, { restart: true });
1402
+ // The respawn resolves as soon as the child is up, not when its
1377
1403
  // capabilities are re-registered. Callers (integrations.create, UI forms) may
1378
1404
  // immediately try to route to the provider and hit a transient null. Block here
1379
1405
  // until every declared capability is back on the registry so the restart is
@@ -1413,16 +1439,10 @@ class AddonRegistryService {
1413
1439
  `(re-register asynchronously — e.g. device-scoped caps awaiting devices): ${missing.join(', ')}`, { tags: { addonId } });
1414
1440
  }
1415
1441
  }
1416
- // Re-register the addon's custom-action catalog. `$process.restart`
1417
- // respawns the group child and `CapabilityBridge` re-registers cap
1418
- // providers but custom actions live in the hub-side
1419
- // `CustomActionRegistry`, which the restart path never touched.
1420
- // Without this, every hot-update of a group-hosted addon silently
1421
- // drops its custom actions (the catalog is only registered once,
1422
- // at boot, in `initializeAddonGroup`). Reads a fresh catalog from
1423
- // the just-updated on-disk bundle.
1424
- const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
1425
- await this.registerForkedAddonCustomActions(addonId, runnerId);
1442
+ // Custom actions are re-registered inside `ensureForkedRunner` for
1443
+ // every roster member (custom actions live in the hub-side
1444
+ // `CustomActionRegistry`, which the process spawn never touches a
1445
+ // hot-update would otherwise silently drop them).
1426
1446
  this.logAddonLifecycle('restarted', addonId, 'isolated');
1427
1447
  this.emitAddonLifecycleEvent('addon.restarted', addonId);
1428
1448
  return { success: true };
@@ -1483,13 +1503,16 @@ class AddonRegistryService {
1483
1503
  return { success: false, error: msg };
1484
1504
  }
1485
1505
  finally {
1486
- // Clear the suppression flag regardless of success/failure if the
1487
- // restart failed, the operator will see the real error via the
1488
- // mutation result rather than a misleading transient health blip.
1489
- const timer = this.restartingAddons.get(addonId);
1490
- if (timer)
1491
- clearTimeout(timer);
1492
- this.restartingAddons.delete(addonId);
1506
+ // Clear the suppression flags (restarted addon + any group siblings)
1507
+ // regardless of success/failure if the restart failed, the operator
1508
+ // sees the real error via the mutation result rather than a misleading
1509
+ // transient health blip.
1510
+ for (const id of suppressed) {
1511
+ const timer = this.restartingAddons.get(id);
1512
+ if (timer)
1513
+ clearTimeout(timer);
1514
+ this.restartingAddons.delete(id);
1515
+ }
1493
1516
  }
1494
1517
  }
1495
1518
  getAddon(id) {
@@ -2705,6 +2728,107 @@ class AddonRegistryService {
2705
2728
  meta: { runnerId, addonCount: addons.length, addonIds: addons.map((a) => a.addonId) },
2706
2729
  });
2707
2730
  }
2731
+ /**
2732
+ * Snapshot of the live runner subprocesses (`$process.list`), reduced to the
2733
+ * `{ runnerId, addonIds }` shape the convergence planner needs. Returns `[]`
2734
+ * on any broker error so the caller degrades to a fresh spawn rather than
2735
+ * throwing — a missing list can only mean "nothing is running".
2736
+ */
2737
+ async listRunnerProcesses() {
2738
+ try {
2739
+ // `$process.*` is an untyped Moleculer infra boundary (returns `unknown`);
2740
+ // the process-service owns the `ProcessInfo` shape. Narrow to the two
2741
+ // fields the planner reads.
2742
+ const procs = (await this.broker.call('$process.list'));
2743
+ return procs.map((p) => ({ name: p.name, addonIds: p.addonIds }));
2744
+ }
2745
+ catch {
2746
+ return [];
2747
+ }
2748
+ }
2749
+ /**
2750
+ * Group-aware, convergent forked-runner spawn — the SINGLE authority for
2751
+ * bringing an addon's runner up OUTSIDE the boot plan (hot-install, retry,
2752
+ * operator restart, deploy hot-reload). The boot plan
2753
+ * (`buildAddonGroupPlan` → `initializeAddonGroup`) already groups; this makes
2754
+ * every OTHER lifecycle path honour `execution.group` too, so co-location is
2755
+ * not silently lost the moment an addon is (re)spawned post-boot.
2756
+ *
2757
+ * Resolves the runner id via `resolveRunnerId` and the FULL co-located roster
2758
+ * via `buildAddonGroupPlan` (the same authority the boot plan uses), then
2759
+ * converges the live process topology onto that roster (see
2760
+ * `planRunnerConvergence`): stop stale/mismatched runners, then spawn or
2761
+ * restart the group runner. Idempotent — safe to call once per roster member
2762
+ * (the 2nd..Nth calls see the correct topology and no-op). A groupless addon
2763
+ * keys to a size-1 roster whose runner id is its own id, so its behaviour is
2764
+ * byte-identical to the pre-group solo path.
2765
+ *
2766
+ * `opts.restart` respawns the group in place even when the roster already
2767
+ * matches — the deploy hot-reload path, so the child reboots the WHOLE roster
2768
+ * from the freshly-swapped on-disk bundle. This is why updating ONE member of
2769
+ * a group respawns the ENTIRE group (they share a process): the operator's
2770
+ * "update one → respawn the whole group" invariant.
2771
+ */
2772
+ async ensureForkedRunner(addonId, opts) {
2773
+ const entry = this.addonEntries.get(addonId);
2774
+ if (!entry?.declaration || !entry.addonDir) {
2775
+ throw new Error(`ensureForkedRunner("${addonId}") requires an on-disk forked addon`);
2776
+ }
2777
+ const runnerId = (0, types_1.resolveRunnerId)(entry.declaration, addonId);
2778
+ const roster = this.buildAddonGroupPlan([...this.addonEntries.keys()]).get(runnerId) ?? [
2779
+ { addonId, addonDir: entry.addonDir },
2780
+ ];
2781
+ // Coalesce redundant group respawns: a package reload loops `restartAddon`
2782
+ // over every member of a co-location group, but the FIRST member's restart
2783
+ // already respawned the whole runner (every member rebooted from fresh disk
2784
+ // code). For a GROUP (roster > 1) whose runner was respawned inside the
2785
+ // coalesce window, downgrade a restart request to a plain topology check so
2786
+ // the 2nd..Nth members don't churn the runner. Solo addons (roster == 1) are
2787
+ // never coalesced — their behaviour stays byte-identical.
2788
+ const effectiveRestart = opts.restart &&
2789
+ roster.length > 1 &&
2790
+ Date.now() - (this.recentGroupRespawns.get(runnerId) ?? 0) < GROUP_RESPAWN_COALESCE_MS
2791
+ ? false
2792
+ : opts.restart;
2793
+ const running = await this.listRunnerProcesses();
2794
+ const plan = (0, runner_convergence_1.planRunnerConvergence)(runnerId, roster.map((r) => r.addonId), running, { restart: effectiveRestart });
2795
+ // 1. Tear down every stale/mismatched runner that hosts a roster member.
2796
+ for (const name of plan.stop) {
2797
+ await this.broker.call('$process.stop', { name }).catch((err) => {
2798
+ this.logger.warn('ensureForkedRunner: failed to stop stale runner (continuing)', {
2799
+ meta: { runnerId, stale: name, error: (0, types_1.errMsg)(err) },
2800
+ });
2801
+ });
2802
+ }
2803
+ // 2. Bring the target runner to the correct roster.
2804
+ if (plan.action === 'spawn') {
2805
+ await this.broker.call('$process.spawnRunner', { runnerId, addons: roster }).catch((err) => {
2806
+ // A concurrent path may have won the spawn race — treat "already
2807
+ // running" as success (idempotent), rethrow anything else.
2808
+ if (!/already running/i.test((0, types_1.errMsg)(err)))
2809
+ throw err;
2810
+ });
2811
+ this.recentGroupRespawns.set(runnerId, Date.now());
2812
+ }
2813
+ else if (plan.action === 'restart') {
2814
+ const res = (await this.broker.call('$process.restart', { name: runnerId }));
2815
+ if (!res.success) {
2816
+ throw new Error(`Process restart failed: ${res.reason ?? 'unknown'}`);
2817
+ }
2818
+ this.recentGroupRespawns.set(runnerId, Date.now());
2819
+ }
2820
+ // 3. Bookkeeping for EVERY roster member. Custom actions live in the
2821
+ // hub-side registry the process spawn never touches; a hot-update would
2822
+ // otherwise silently drop them (the boot plan is the only other place
2823
+ // they register). Mark each member initialized so the in-process
2824
+ // core-builtin boot passes skip them.
2825
+ for (const { addonId: memberId } of roster) {
2826
+ await this.registerForkedAddonCustomActions(memberId, runnerId);
2827
+ const memberEntry = this.addonEntries.get(memberId);
2828
+ if (memberEntry)
2829
+ memberEntry.initialized = true;
2830
+ }
2831
+ }
2708
2832
  /**
2709
2833
  * (Re-)register the custom-action catalog for a forked / group-hosted
2710
2834
  * addon against the shared `CustomActionRegistry`.
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ /**
3
+ * Group-runner convergence planner (pure).
4
+ *
5
+ * Group membership is decided ONCE, at a runner's FIRST spawn — the `addons`
6
+ * array handed to `$process.spawnRunner`. Every later lifecycle op preserves
7
+ * it: `$process.restart` respawns a runner from its CAPTURED `runnerAddons`,
8
+ * never from a freshly-declared roster. That means the boot plan
9
+ * (`buildAddonGroupPlan` → `initializeAddonGroup`) is not the only place that
10
+ * has to be group-aware: any path that (re)spawns an addon's runner outside
11
+ * boot (hot-install, retry, operator restart, deploy hot-reload) must converge
12
+ * the LIVE process topology onto the addon's DECLARED runner id
13
+ * (`resolveRunnerId`) and its full co-located roster.
14
+ *
15
+ * This module holds the decision as a pure function so it is unit-testable
16
+ * without the heavyweight `AddonRegistryService`. The caller
17
+ * (`ensureForkedRunner`) executes the plan against `$process.*`.
18
+ *
19
+ * Why not just `$process.restart` the addon? Because restart respawns the
20
+ * runner's OLD roster. A runner named `detection` that was spawned solo (with
21
+ * only `pipeline-runner`, before the group manifest shipped) would be respawned
22
+ * solo forever. To adopt a NEW roster the stale runner(s) must be STOPPED and
23
+ * the group spawned fresh — the convergence this planner encodes.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.planRunnerConvergence = planRunnerConvergence;
27
+ function sameRoster(addonIds, rosterSet) {
28
+ return addonIds.length === rosterSet.size && addonIds.every((a) => rosterSet.has(a));
29
+ }
30
+ /**
31
+ * Decide how to converge the live runner topology onto the declared group.
32
+ *
33
+ * @param runnerId the addon's resolved runner id (`resolveRunnerId`).
34
+ * @param rosterAddonIds the FULL co-located roster (every eligible addon that
35
+ * resolves to `runnerId`). Always includes the addon
36
+ * itself; size-1 for a groupless addon.
37
+ * @param running current runner subprocesses (`$process.list`).
38
+ * @param opts.restart true when the caller wants an in-place respawn even if
39
+ * the roster already matches (deploy hot-reload path).
40
+ */
41
+ function planRunnerConvergence(runnerId, rosterAddonIds, running, opts) {
42
+ const rosterSet = new Set(rosterAddonIds);
43
+ const target = running.find((p) => p.name === runnerId);
44
+ const targetIsCorrect = target !== undefined && sameRoster(target.addonIds, rosterSet);
45
+ if (targetIsCorrect) {
46
+ // The exact group runner is already up. Only stragglers to stop are OTHER
47
+ // runners that (wrongly) host a roster member.
48
+ const stop = running
49
+ .filter((p) => p.name !== runnerId && p.addonIds.some((a) => rosterSet.has(a)))
50
+ .map((p) => p.name);
51
+ return { stop, action: opts.restart ? 'restart' : 'none' };
52
+ }
53
+ // No correct runner: either nothing hosts the roster, or a same-id runner
54
+ // carries the WRONG roster, or roster members are scattered across stale
55
+ // solo runners. Stop every runner that hosts a roster member (INCLUDING a
56
+ // wrong-roster same-id runner — restart would just re-spawn its stale
57
+ // roster), then spawn the group fresh.
58
+ const stop = running
59
+ .filter((p) => p.name === runnerId || p.addonIds.some((a) => rosterSet.has(a)))
60
+ .map((p) => p.name);
61
+ return { stop, action: 'spawn' };
62
+ }
package/dist/main.js CHANGED
@@ -721,7 +721,14 @@ async function bootstrap() {
721
721
  // Addon HTTP API route catch-all: /addon/:addonId/*
722
722
  // Only handles non-GET or routes that actually exist in the addon route registry.
723
723
  // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
724
- fastify.all('/addon/:addonId/*', async (request, reply) => {
724
+ //
725
+ // `compress: false`: the bridged reply body of a FORKED addon crosses the
726
+ // UDS route bridge as opaque bytes; piping those through the hub's global
727
+ // @fastify/compress emits an EMPTY brotli stream for any compressible body
728
+ // >1KiB (live-diagnosed 2026-07-16 — broke the notifier SVG icons; same
729
+ // class addon-upload.ts already dodges). Addons that care about coding own
730
+ // it end-to-end (see the notifier icon routes).
731
+ fastify.all('/addon/:addonId/*', { compress: false }, async (request, reply) => {
725
732
  const { addonId } = request.params;
726
733
  const subPath = request.params['*'] ?? '';
727
734
  const method = request.method;