@camstack/server 1.1.62 → 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.
|
@@ -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
|
|
1100
|
-
//
|
|
1101
|
-
// runner
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
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.
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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 —
|
|
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
|
-
|
|
1371
|
-
|
|
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
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
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
|
|
1487
|
-
//
|
|
1488
|
-
// mutation result rather than a misleading
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -98,6 +98,7 @@ var require_dist = __commonJS({
|
|
|
98
98
|
planBoot: /* @__PURE__ */ __name(() => planBoot, "planBoot"),
|
|
99
99
|
readDevUploadManifest: /* @__PURE__ */ __name(() => readDevUploadManifest, "readDevUploadManifest"),
|
|
100
100
|
readRestartIntentMarker: /* @__PURE__ */ __name(() => readRestartIntentMarker, "readRestartIntentMarker"),
|
|
101
|
+
readSeedVersion: /* @__PURE__ */ __name(() => readSeedVersion, "readSeedVersion"),
|
|
101
102
|
readServerRootState: /* @__PURE__ */ __name(() => readServerRootState, "readServerRootState"),
|
|
102
103
|
registerActiveRootResolver: /* @__PURE__ */ __name(() => registerActiveRootResolver, "registerActiveRootResolver"),
|
|
103
104
|
restartIntentMarkerPath: /* @__PURE__ */ __name(() => restartIntentMarkerPath, "restartIntentMarkerPath"),
|
|
@@ -351,7 +352,39 @@ var require_dist = __commonJS({
|
|
|
351
352
|
return null;
|
|
352
353
|
}
|
|
353
354
|
__name(validateVersionDir, "validateVersionDir");
|
|
354
|
-
function
|
|
355
|
+
function parse(version) {
|
|
356
|
+
const dashIdx = version.indexOf("-");
|
|
357
|
+
const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
|
|
358
|
+
const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
|
|
359
|
+
const nums = base.split(".").map((seg) => {
|
|
360
|
+
const n = Number.parseInt(seg, 10);
|
|
361
|
+
return Number.isNaN(n) ? 0 : n;
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
nums,
|
|
365
|
+
prerelease
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
__name(parse, "parse");
|
|
369
|
+
function compareSemver(a, b) {
|
|
370
|
+
const pa = parse(a);
|
|
371
|
+
const pb = parse(b);
|
|
372
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
373
|
+
for (let i = 0; i < len; i++) {
|
|
374
|
+
const na = pa.nums[i] ?? 0;
|
|
375
|
+
const nb = pb.nums[i] ?? 0;
|
|
376
|
+
if (na < nb) return -1;
|
|
377
|
+
if (na > nb) return 1;
|
|
378
|
+
}
|
|
379
|
+
if (pa.prerelease === null && pb.prerelease === null) return 0;
|
|
380
|
+
if (pa.prerelease === null) return 1;
|
|
381
|
+
if (pb.prerelease === null) return -1;
|
|
382
|
+
if (pa.prerelease < pb.prerelease) return -1;
|
|
383
|
+
if (pa.prerelease > pb.prerelease) return 1;
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
__name(compareSemver, "compareSemver");
|
|
387
|
+
function planBoot(state, isValidVersion, now, seedVersion = null) {
|
|
355
388
|
if (state === null) {
|
|
356
389
|
return {
|
|
357
390
|
kind: "baked",
|
|
@@ -404,6 +437,19 @@ var require_dist = __commonJS({
|
|
|
404
437
|
}
|
|
405
438
|
if (next.currentVersion !== null) {
|
|
406
439
|
if (isValidVersion(next.currentVersion)) {
|
|
440
|
+
if (!changed && seedVersion !== null && compareSemver(seedVersion, next.currentVersion) > 0) {
|
|
441
|
+
return {
|
|
442
|
+
kind: "baked",
|
|
443
|
+
reason: `baked seed ${seedVersion} is newer than active data-root ${next.currentVersion} \u2014 adopting the image seed`,
|
|
444
|
+
stateToWrite: {
|
|
445
|
+
...next,
|
|
446
|
+
currentVersion: null,
|
|
447
|
+
previousVersion: null,
|
|
448
|
+
pendingBoot: null,
|
|
449
|
+
rolledBack: null
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
407
453
|
return {
|
|
408
454
|
kind: "data-root",
|
|
409
455
|
version: next.currentVersion,
|
|
@@ -453,38 +499,6 @@ var require_dist = __commonJS({
|
|
|
453
499
|
};
|
|
454
500
|
}
|
|
455
501
|
__name(planBoot, "planBoot");
|
|
456
|
-
function parse(version) {
|
|
457
|
-
const dashIdx = version.indexOf("-");
|
|
458
|
-
const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
|
|
459
|
-
const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
|
|
460
|
-
const nums = base.split(".").map((seg) => {
|
|
461
|
-
const n = Number.parseInt(seg, 10);
|
|
462
|
-
return Number.isNaN(n) ? 0 : n;
|
|
463
|
-
});
|
|
464
|
-
return {
|
|
465
|
-
nums,
|
|
466
|
-
prerelease
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
__name(parse, "parse");
|
|
470
|
-
function compareSemver(a, b) {
|
|
471
|
-
const pa = parse(a);
|
|
472
|
-
const pb = parse(b);
|
|
473
|
-
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
474
|
-
for (let i = 0; i < len; i++) {
|
|
475
|
-
const na = pa.nums[i] ?? 0;
|
|
476
|
-
const nb = pb.nums[i] ?? 0;
|
|
477
|
-
if (na < nb) return -1;
|
|
478
|
-
if (na > nb) return 1;
|
|
479
|
-
}
|
|
480
|
-
if (pa.prerelease === null && pb.prerelease === null) return 0;
|
|
481
|
-
if (pa.prerelease === null) return 1;
|
|
482
|
-
if (pb.prerelease === null) return -1;
|
|
483
|
-
if (pa.prerelease < pb.prerelease) return -1;
|
|
484
|
-
if (pa.prerelease > pb.prerelease) return 1;
|
|
485
|
-
return 0;
|
|
486
|
-
}
|
|
487
|
-
__name(compareSemver, "compareSemver");
|
|
488
502
|
var fs3 = __toESM2(require("fs"));
|
|
489
503
|
var path3 = __toESM2(require("path"));
|
|
490
504
|
function detectWorkspaceRoot(fromDir) {
|
|
@@ -504,6 +518,7 @@ var require_dist = __commonJS({
|
|
|
504
518
|
}
|
|
505
519
|
}
|
|
506
520
|
__name(detectWorkspaceRoot, "detectWorkspaceRoot");
|
|
521
|
+
var fs4 = __toESM2(require("fs"));
|
|
507
522
|
var path4 = __toESM2(require("path"));
|
|
508
523
|
var import_node_url = require("url");
|
|
509
524
|
var HOST_EXTERNAL_SPECIFIERS = [
|
|
@@ -546,6 +561,20 @@ var require_dist = __commonJS({
|
|
|
546
561
|
registerHooks(hooks);
|
|
547
562
|
}
|
|
548
563
|
__name(registerActiveRootResolver, "registerActiveRootResolver");
|
|
564
|
+
function readSeedVersion(seedEntry) {
|
|
565
|
+
try {
|
|
566
|
+
const pkgJsonPath = path4.join(path4.dirname(seedEntry), "..", "package.json");
|
|
567
|
+
const raw = JSON.parse(fs4.readFileSync(pkgJsonPath, "utf-8"));
|
|
568
|
+
if (typeof raw === "object" && raw !== null) {
|
|
569
|
+
const version = raw["version"];
|
|
570
|
+
if (typeof version === "string") return version;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
__name(readSeedVersion, "readSeedVersion");
|
|
549
578
|
function runNodeStarter(options) {
|
|
550
579
|
const env = options.env ?? process.env;
|
|
551
580
|
const now = options.now ?? Date.now;
|
|
@@ -572,7 +601,8 @@ var require_dist = __commonJS({
|
|
|
572
601
|
if (reason !== null) console.warn(`[starter] version ${version} invalid: ${reason}`);
|
|
573
602
|
return reason === null;
|
|
574
603
|
}, "isValidVersion");
|
|
575
|
-
const
|
|
604
|
+
const seedVersion = readSeedVersion(options.seedEntry);
|
|
605
|
+
const plan = planBoot(state, isValidVersion, now, seedVersion);
|
|
576
606
|
if (plan.stateToWrite !== null) {
|
|
577
607
|
try {
|
|
578
608
|
writeServerRootState(rootDir, plan.stateToWrite);
|
|
@@ -604,7 +634,7 @@ var require_dist = __commonJS({
|
|
|
604
634
|
}
|
|
605
635
|
__name(runNodeStarter, "runNodeStarter");
|
|
606
636
|
var import_node_child_process = require("child_process");
|
|
607
|
-
var
|
|
637
|
+
var fs5 = __toESM2(require("fs"));
|
|
608
638
|
var path5 = __toESM2(require("path"));
|
|
609
639
|
var import_node_util = require("util");
|
|
610
640
|
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
@@ -622,7 +652,7 @@ var require_dist = __commonJS({
|
|
|
622
652
|
var RESTART_REASON_PREFIX = "server-update";
|
|
623
653
|
function readPackageVersion(pkgJsonPath) {
|
|
624
654
|
try {
|
|
625
|
-
const raw = JSON.parse(
|
|
655
|
+
const raw = JSON.parse(fs5.readFileSync(pkgJsonPath, "utf-8"));
|
|
626
656
|
if (typeof raw === "object" && raw !== null) {
|
|
627
657
|
const version = raw["version"];
|
|
628
658
|
if (typeof version === "string") return version;
|
|
@@ -712,7 +742,7 @@ var require_dist = __commonJS({
|
|
|
712
742
|
};
|
|
713
743
|
return {
|
|
714
744
|
state: emptyServerRootState(),
|
|
715
|
-
corrupt:
|
|
745
|
+
corrupt: fs5.existsSync(stateFilePath(rootDir))
|
|
716
746
|
};
|
|
717
747
|
}
|
|
718
748
|
updateState(state) {
|
|
@@ -865,7 +895,7 @@ var require_dist = __commonJS({
|
|
|
865
895
|
}
|
|
866
896
|
if (isDevChannelVersion(target)) {
|
|
867
897
|
const uploadsDir = devUploadVersionDir(this.rootDir(), target);
|
|
868
|
-
if (!
|
|
898
|
+
if (!fs5.existsSync(uploadsDir)) {
|
|
869
899
|
return {
|
|
870
900
|
accepted: false,
|
|
871
901
|
targetVersion: target,
|
|
@@ -926,11 +956,11 @@ var require_dist = __commonJS({
|
|
|
926
956
|
async stageAndActivate(target) {
|
|
927
957
|
const rootDir = this.rootDir();
|
|
928
958
|
const vDir = versionsDir(rootDir);
|
|
929
|
-
|
|
959
|
+
fs5.mkdirSync(vDir, {
|
|
930
960
|
recursive: true
|
|
931
961
|
});
|
|
932
962
|
const stagingDir = path5.join(vDir, `.staging-${target}-${process.pid}-${this.now()}`);
|
|
933
|
-
|
|
963
|
+
fs5.mkdirSync(stagingDir, {
|
|
934
964
|
recursive: true
|
|
935
965
|
});
|
|
936
966
|
try {
|
|
@@ -943,8 +973,8 @@ var require_dist = __commonJS({
|
|
|
943
973
|
"--no-fund",
|
|
944
974
|
"--loglevel=error"
|
|
945
975
|
];
|
|
946
|
-
if (
|
|
947
|
-
|
|
976
|
+
if (fs5.existsSync(devDir)) {
|
|
977
|
+
fs5.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify(this.buildDevUploadsPackageJson(devDir, target), null, 2), "utf-8");
|
|
948
978
|
await this.execNpm([
|
|
949
979
|
...installArgs,
|
|
950
980
|
...buildNpmRegistryArgs(registry)
|
|
@@ -953,7 +983,7 @@ var require_dist = __commonJS({
|
|
|
953
983
|
timeoutMs: NPM_INSTALL_TIMEOUT_MS
|
|
954
984
|
});
|
|
955
985
|
} else {
|
|
956
|
-
|
|
986
|
+
fs5.writeFileSync(path5.join(stagingDir, "package.json"), JSON.stringify({
|
|
957
987
|
name: "camstack-node-root",
|
|
958
988
|
private: true
|
|
959
989
|
}, null, 2), "utf-8");
|
|
@@ -967,7 +997,7 @@ var require_dist = __commonJS({
|
|
|
967
997
|
});
|
|
968
998
|
}
|
|
969
999
|
const entry = rootEntryPath2(stagingDir, this.spec);
|
|
970
|
-
if (!
|
|
1000
|
+
if (!fs5.existsSync(entry)) {
|
|
971
1001
|
throw new Error(`staged closure is missing the root entry (${entry})`);
|
|
972
1002
|
}
|
|
973
1003
|
const stagedVersion = readPackageVersion(path5.join(rootPackageDir2(stagingDir, this.spec), "package.json"));
|
|
@@ -975,17 +1005,17 @@ var require_dist = __commonJS({
|
|
|
975
1005
|
throw new Error(`staged closure version mismatch: expected ${target}, got ${stagedVersion ?? "unknown"}`);
|
|
976
1006
|
}
|
|
977
1007
|
const dest = versionDir(rootDir, target);
|
|
978
|
-
if (
|
|
1008
|
+
if (fs5.existsSync(dest)) {
|
|
979
1009
|
const aside = `${dest}.evicted-${this.now()}`;
|
|
980
|
-
await
|
|
981
|
-
await
|
|
1010
|
+
await fs5.promises.rename(dest, aside);
|
|
1011
|
+
await fs5.promises.rm(aside, {
|
|
982
1012
|
recursive: true,
|
|
983
1013
|
force: true
|
|
984
1014
|
}).catch(() => void 0);
|
|
985
1015
|
}
|
|
986
|
-
await
|
|
1016
|
+
await fs5.promises.rename(stagingDir, dest);
|
|
987
1017
|
} catch (err) {
|
|
988
|
-
await
|
|
1018
|
+
await fs5.promises.rm(stagingDir, {
|
|
989
1019
|
recursive: true,
|
|
990
1020
|
force: true
|
|
991
1021
|
}).catch(() => void 0);
|
|
@@ -1015,7 +1045,7 @@ var require_dist = __commonJS({
|
|
|
1015
1045
|
}
|
|
1016
1046
|
const fileRef = /* @__PURE__ */ __name((filename) => {
|
|
1017
1047
|
const abs = path5.join(devDir, filename);
|
|
1018
|
-
if (!
|
|
1048
|
+
if (!fs5.existsSync(abs)) {
|
|
1019
1049
|
throw new Error(`dev-uploads tarball listed in the manifest is missing: ${abs}`);
|
|
1020
1050
|
}
|
|
1021
1051
|
return `file:${abs}`;
|
|
@@ -1194,7 +1224,7 @@ var require_dist = __commonJS({
|
|
|
1194
1224
|
const vDir = versionsDir(this.rootDir());
|
|
1195
1225
|
let entries;
|
|
1196
1226
|
try {
|
|
1197
|
-
entries =
|
|
1227
|
+
entries = fs5.readdirSync(vDir);
|
|
1198
1228
|
} catch {
|
|
1199
1229
|
return;
|
|
1200
1230
|
}
|
|
@@ -1203,14 +1233,14 @@ var require_dist = __commonJS({
|
|
|
1203
1233
|
const full = path5.join(vDir, entry);
|
|
1204
1234
|
if (entry.startsWith(".")) {
|
|
1205
1235
|
try {
|
|
1206
|
-
const ageMs = this.now() -
|
|
1236
|
+
const ageMs = this.now() - fs5.statSync(full).mtimeMs;
|
|
1207
1237
|
if (ageMs < _RootUpdateService.STALE_TRANSIENT_MS) continue;
|
|
1208
1238
|
} catch {
|
|
1209
1239
|
continue;
|
|
1210
1240
|
}
|
|
1211
1241
|
}
|
|
1212
1242
|
try {
|
|
1213
|
-
|
|
1243
|
+
fs5.rmSync(full, {
|
|
1214
1244
|
recursive: true,
|
|
1215
1245
|
force: true
|
|
1216
1246
|
});
|
|
@@ -1241,15 +1271,15 @@ var require_dist = __commonJS({
|
|
|
1241
1271
|
const dir = devUploadsDir(this.rootDir());
|
|
1242
1272
|
let entries;
|
|
1243
1273
|
try {
|
|
1244
|
-
entries =
|
|
1274
|
+
entries = fs5.readdirSync(dir);
|
|
1245
1275
|
} catch {
|
|
1246
1276
|
return;
|
|
1247
1277
|
}
|
|
1248
1278
|
const graveyard = path5.join(dir, ".sweeping");
|
|
1249
1279
|
try {
|
|
1250
|
-
for (const residue of
|
|
1280
|
+
for (const residue of fs5.readdirSync(graveyard)) {
|
|
1251
1281
|
try {
|
|
1252
|
-
|
|
1282
|
+
fs5.rmSync(path5.join(graveyard, residue), {
|
|
1253
1283
|
recursive: true,
|
|
1254
1284
|
force: true
|
|
1255
1285
|
});
|
|
@@ -1264,13 +1294,13 @@ var require_dist = __commonJS({
|
|
|
1264
1294
|
].sort((a, b) => (devChannelEpoch(b) ?? -1) - (devChannelEpoch(a) ?? -1));
|
|
1265
1295
|
const doomed = byNewestFirst.slice(keepCount);
|
|
1266
1296
|
if (doomed.length === 0) return;
|
|
1267
|
-
|
|
1297
|
+
fs5.mkdirSync(graveyard, {
|
|
1268
1298
|
recursive: true
|
|
1269
1299
|
});
|
|
1270
1300
|
for (const entry of doomed) {
|
|
1271
1301
|
const aside = path5.join(graveyard, `${entry}-${this.now()}`);
|
|
1272
1302
|
try {
|
|
1273
|
-
|
|
1303
|
+
fs5.renameSync(path5.join(dir, entry), aside);
|
|
1274
1304
|
} catch (err) {
|
|
1275
1305
|
this.logger.warn("failed to move dev-uploads entry aside for sweep", {
|
|
1276
1306
|
meta: {
|
|
@@ -1281,7 +1311,7 @@ var require_dist = __commonJS({
|
|
|
1281
1311
|
continue;
|
|
1282
1312
|
}
|
|
1283
1313
|
try {
|
|
1284
|
-
|
|
1314
|
+
fs5.rmSync(aside, {
|
|
1285
1315
|
recursive: true,
|
|
1286
1316
|
force: true
|
|
1287
1317
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.63",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -23,18 +23,18 @@
|
|
|
23
23
|
"test:watch": "vitest"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@camstack/addon-admin-ui": "1.1.
|
|
26
|
+
"@camstack/addon-admin-ui": "1.1.52",
|
|
27
27
|
"@camstack/addon-advanced-notifier": "1.1.24",
|
|
28
28
|
"@camstack/addon-auth": "1.1.9",
|
|
29
|
-
"@camstack/addon-decoder-nodeav": "1.1.
|
|
30
|
-
"@camstack/addon-notifiers": "1.1.
|
|
31
|
-
"@camstack/addon-pipeline": "1.1.
|
|
29
|
+
"@camstack/addon-decoder-nodeav": "1.1.12",
|
|
30
|
+
"@camstack/addon-notifiers": "1.1.24",
|
|
31
|
+
"@camstack/addon-pipeline": "1.1.57",
|
|
32
32
|
"@camstack/addon-pipeline-orchestrator": "1.1.46",
|
|
33
|
-
"@camstack/addon-post-analysis": "1.1.
|
|
33
|
+
"@camstack/addon-post-analysis": "1.1.29",
|
|
34
34
|
"@camstack/sdk": "1.1.24",
|
|
35
35
|
"@camstack/shm-ring": "1.0.23",
|
|
36
|
-
"@camstack/system": "1.1.
|
|
37
|
-
"@camstack/types": "1.1.
|
|
36
|
+
"@camstack/system": "1.1.50",
|
|
37
|
+
"@camstack/types": "1.1.45",
|
|
38
38
|
"@camstack/ui-library": "1.1.36",
|
|
39
39
|
"@fastify/compress": "^9.0.0",
|
|
40
40
|
"@fastify/cookie": "^11.0.2",
|