@camstack/server 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/addon/addon-package.service.js +191 -11
- package/dist/launcher.js +20 -0
- package/package.json +1 -1
|
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.AddonPackageService = exports.SYSTEM_PACKAGE = void 0;
|
|
37
37
|
exports.isFrameworkPackage = isFrameworkPackage;
|
|
38
|
+
exports.sweepStaleFrameworkBackups = sweepStaleFrameworkBackups;
|
|
39
|
+
exports.swapInFrameworkPackage = swapInFrameworkPackage;
|
|
38
40
|
const fs = __importStar(require("node:fs"));
|
|
39
41
|
const path = __importStar(require("node:path"));
|
|
40
42
|
const os = __importStar(require("node:os"));
|
|
@@ -916,6 +918,69 @@ class AddonPackageService {
|
|
|
916
918
|
* Returns BEFORE the exit fires so the admin UI receives `restartingAt`
|
|
917
919
|
* and can pivot to the reconnect overlay.
|
|
918
920
|
*/
|
|
921
|
+
/**
|
|
922
|
+
* Install the framework by SWAPPING each package's own directory, exactly
|
|
923
|
+
* like every addon is installed (download the single-package tarball →
|
|
924
|
+
* extract → atomic dir swap). We NEVER run `npm install --prefix <appRoot>`.
|
|
925
|
+
*
|
|
926
|
+
* Why: `npm install --prefix /data` manages the WHOLE node_modules tree — it
|
|
927
|
+
* prunes every package not in @camstack/system's closure (the addons +
|
|
928
|
+
* ui-library), deletes the stray manifest.json, and (via temp-install + merge
|
|
929
|
+
* variants) can clobber working native bindings. A per-package tarball swap
|
|
930
|
+
* touches only that package's folder; nothing else can be pruned, deleted, or
|
|
931
|
+
* corrupted.
|
|
932
|
+
*
|
|
933
|
+
* Swaps the @camstack framework packages versioned in lockstep with system:
|
|
934
|
+
* the pure-JS libs (types, sdk) AND @camstack/shm-ring. shm-ring is native
|
|
935
|
+
* but SAFE to swap because it ships its compiled `.node` prebuilds INSIDE the
|
|
936
|
+
* npm tarball (prebuildify + node-gyp-build resolves the matching one at
|
|
937
|
+
* require-time) — a bare extract is sufficient, no build or download.
|
|
938
|
+
*
|
|
939
|
+
* It does NOT swap download/compile-on-install natives such as `better-sqlite3`
|
|
940
|
+
* (prebuild-install fetches the binary from GitHub releases at install time —
|
|
941
|
+
* a bare extract would leave it without a binding). Those are third-party,
|
|
942
|
+
* hoisted, change only across majors, and are updated by an image redeploy.
|
|
943
|
+
* System's own deps stay exactly where they are, bindings intact.
|
|
944
|
+
*/
|
|
945
|
+
async installFrameworkPackages(packageName, toVersion, appRoot) {
|
|
946
|
+
const registry = process.env['CAMSTACK_NPM_REGISTRY'];
|
|
947
|
+
// @camstack/shm-ring is native but ships prebuilds in its tarball → safe.
|
|
948
|
+
const lockstepDeps = ['@camstack/types', '@camstack/sdk', '@camstack/shm-ring'];
|
|
949
|
+
const swapTargets = [packageName, ...lockstepDeps];
|
|
950
|
+
// Self-heal: a previous run killed mid-swap (e.g. process restart while a
|
|
951
|
+
// slow download was in flight) can leave a `<pkg>.fw-bak` backup behind.
|
|
952
|
+
// It's inert (not a valid package the loader picks up) but sweep it so the
|
|
953
|
+
// tree stays clean.
|
|
954
|
+
sweepStaleFrameworkBackups(appRoot);
|
|
955
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-fw-'));
|
|
956
|
+
try {
|
|
957
|
+
for (const pkg of swapTargets) {
|
|
958
|
+
const isPrimary = pkg === packageName;
|
|
959
|
+
try {
|
|
960
|
+
const tgzPath = await packTarball(pkg, toVersion, tmpRoot, registry);
|
|
961
|
+
const extractDir = fs.mkdtempSync(path.join(tmpRoot, 'x-'));
|
|
962
|
+
await execFileAsync('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 });
|
|
963
|
+
const result = swapInFrameworkPackage(path.join(extractDir, 'package'), appRoot);
|
|
964
|
+
this.logger.info('updateFrameworkPackage: package swapped', {
|
|
965
|
+
meta: { name: result.name, version: result.version },
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
if (isPrimary)
|
|
970
|
+
throw err;
|
|
971
|
+
// A lockstep dep may not be published at this exact version (or the
|
|
972
|
+
// download flaked) — keep the existing copy rather than fail the whole
|
|
973
|
+
// framework update. System is the one that MUST succeed.
|
|
974
|
+
this.logger.warn(`updateFrameworkPackage: lockstep dep ${pkg}@${toVersion} not swapped`, {
|
|
975
|
+
meta: { error: (0, types_1.errMsg)(err) },
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
919
984
|
async updateFrameworkPackage(input) {
|
|
920
985
|
const { packageName } = input;
|
|
921
986
|
if (packageName !== exports.SYSTEM_PACKAGE) {
|
|
@@ -928,20 +993,10 @@ class AddonPackageService {
|
|
|
928
993
|
: 'unknown';
|
|
929
994
|
const requestedVersion = input.version ?? 'latest';
|
|
930
995
|
const toVersion = await resolveNpmVersion(packageName, requestedVersion, process.env['CAMSTACK_NPM_REGISTRY']);
|
|
931
|
-
const spec = `${packageName}@${toVersion}`;
|
|
932
996
|
this.logger.info('updateFrameworkPackage: installing', {
|
|
933
997
|
meta: { packageName, fromVersion, toVersion, appRoot },
|
|
934
998
|
});
|
|
935
|
-
|
|
936
|
-
const args = [
|
|
937
|
-
'install',
|
|
938
|
-
'--prefix',
|
|
939
|
-
appRoot,
|
|
940
|
-
spec,
|
|
941
|
-
'--no-save',
|
|
942
|
-
...buildNpmRegistryArgs(registry),
|
|
943
|
-
];
|
|
944
|
-
await execFileAsync('npm', args, { timeout: 180_000 });
|
|
999
|
+
await this.installFrameworkPackages(packageName, toVersion, appRoot);
|
|
945
1000
|
if (input.deferRestart === true) {
|
|
946
1001
|
this.logger.info(`updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`);
|
|
947
1002
|
// Sentinel: 0 signals "no restart scheduled" to the caller
|
|
@@ -1451,6 +1506,131 @@ function buildNpmRegistryArgs(registry) {
|
|
|
1451
1506
|
return [];
|
|
1452
1507
|
return ['--registry', registry, `--@camstack:registry=${registry}`];
|
|
1453
1508
|
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Remove any leftover `<pkg>.fw-bak` backup dirs under `appRoot/node_modules/
|
|
1511
|
+
* @camstack` — debris from a framework swap that was interrupted before its
|
|
1512
|
+
* own cleanup ran. Best-effort; returns the swept names.
|
|
1513
|
+
*/
|
|
1514
|
+
function sweepStaleFrameworkBackups(appRoot) {
|
|
1515
|
+
const scopeDir = path.join(appRoot, 'node_modules', '@camstack');
|
|
1516
|
+
if (!fs.existsSync(scopeDir))
|
|
1517
|
+
return [];
|
|
1518
|
+
const swept = [];
|
|
1519
|
+
for (const entry of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
1520
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.fw-bak'))
|
|
1521
|
+
continue;
|
|
1522
|
+
try {
|
|
1523
|
+
fs.rmSync(path.join(scopeDir, entry.name), { recursive: true, force: true });
|
|
1524
|
+
swept.push(entry.name);
|
|
1525
|
+
}
|
|
1526
|
+
catch {
|
|
1527
|
+
// ignore — inert leftover, next sweep retries
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return swept;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Download the `.tgz` for an EXACT `pkg@version` into `destRoot`, returning its
|
|
1534
|
+
* path. Prefers a direct HTTPS GET (registry metadata → tarball URL) because
|
|
1535
|
+
* `fetch` + `AbortSignal.timeout` aborts RELIABLY on a slow/flaky network — an
|
|
1536
|
+
* `npm pack` child can stall for minutes past its `execFile` timeout (npm holds
|
|
1537
|
+
* its stdio pipes open, so the awaited promise never settles), which would wedge
|
|
1538
|
+
* the whole framework update. Falls back to `npm pack` (SIGKILL on timeout) only
|
|
1539
|
+
* when the HTTPS path fails (private registry quirks, auth, etc.).
|
|
1540
|
+
*/
|
|
1541
|
+
async function packTarball(pkg, version, destRoot, registry) {
|
|
1542
|
+
const dir = fs.mkdtempSync(path.join(destRoot, 'dl-'));
|
|
1543
|
+
try {
|
|
1544
|
+
return await httpsDownloadTarball(pkg, version, dir, registry);
|
|
1545
|
+
}
|
|
1546
|
+
catch (httpErr) {
|
|
1547
|
+
const args = [
|
|
1548
|
+
'pack',
|
|
1549
|
+
`${pkg}@${version}`,
|
|
1550
|
+
'--pack-destination',
|
|
1551
|
+
dir,
|
|
1552
|
+
...buildNpmRegistryArgs(registry),
|
|
1553
|
+
];
|
|
1554
|
+
// SIGKILL (not the default SIGTERM): npm can ignore SIGTERM while a
|
|
1555
|
+
// grandchild holds the pipes — SIGKILL guarantees the timeout fires.
|
|
1556
|
+
await execFileAsync('npm', args, { timeout: 60_000, killSignal: 'SIGKILL' });
|
|
1557
|
+
const tgz = fs.readdirSync(dir).find((f) => f.endsWith('.tgz'));
|
|
1558
|
+
if (tgz === undefined) {
|
|
1559
|
+
throw new Error(`download failed for ${pkg}@${version}: ${(0, types_1.errMsg)(httpErr)}`);
|
|
1560
|
+
}
|
|
1561
|
+
return path.join(dir, tgz);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Direct HTTPS download of an exact `pkg@version` tarball. Resolves the tarball
|
|
1566
|
+
* URL from the registry packument, then streams it to a file. Every network
|
|
1567
|
+
* call is bounded by an `AbortSignal.timeout` so a DNS/registry stall fails fast
|
|
1568
|
+
* instead of hanging the framework update.
|
|
1569
|
+
*/
|
|
1570
|
+
async function httpsDownloadTarball(pkg, version, dir, registry) {
|
|
1571
|
+
const reg = (registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
1572
|
+
const metaUrl = `${reg}/${encodeURIComponent(pkg).replace(/^%40/, '@')}`;
|
|
1573
|
+
const metaRes = await fetch(metaUrl, { signal: AbortSignal.timeout(20_000) });
|
|
1574
|
+
if (!metaRes.ok)
|
|
1575
|
+
throw new Error(`registry GET ${metaUrl} → ${metaRes.status}`);
|
|
1576
|
+
const meta = (await metaRes.json());
|
|
1577
|
+
const tarballUrl = meta.versions?.[version]?.dist?.tarball;
|
|
1578
|
+
if (typeof tarballUrl !== 'string') {
|
|
1579
|
+
throw new Error(`no tarball url for ${pkg}@${version}`);
|
|
1580
|
+
}
|
|
1581
|
+
const tarRes = await fetch(tarballUrl, { signal: AbortSignal.timeout(120_000) });
|
|
1582
|
+
if (!tarRes.ok)
|
|
1583
|
+
throw new Error(`tarball GET ${tarballUrl} → ${tarRes.status}`);
|
|
1584
|
+
const buf = Buffer.from(await tarRes.arrayBuffer());
|
|
1585
|
+
const outPath = path.join(dir, `${pkg.replace('@', '').replace('/', '-')}-${version}.tgz`);
|
|
1586
|
+
fs.writeFileSync(outPath, buf);
|
|
1587
|
+
return outPath;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Atomically replace one `@camstack/*` package directory in
|
|
1591
|
+
* `appRoot/node_modules` with the contents of an extracted npm package dir
|
|
1592
|
+
* (`stagedPackageDir` = the `package/` folder from a `.tgz`).
|
|
1593
|
+
*
|
|
1594
|
+
* This is the framework-update primitive: it touches ONLY the target package's
|
|
1595
|
+
* folder. The current copy is renamed to a sibling `.fw-bak` backup first and
|
|
1596
|
+
* restored if the copy fails, so a crash mid-swap can't leave a half-written
|
|
1597
|
+
* package. Refuses anything outside the `@camstack/` scope as a safety guard.
|
|
1598
|
+
* Returns the installed package name + version.
|
|
1599
|
+
*/
|
|
1600
|
+
function swapInFrameworkPackage(stagedPackageDir, appRoot) {
|
|
1601
|
+
const pkgJsonPath = path.join(stagedPackageDir, 'package.json');
|
|
1602
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
1603
|
+
throw new Error(`swapInFrameworkPackage: no package.json in ${stagedPackageDir}`);
|
|
1604
|
+
}
|
|
1605
|
+
const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
1606
|
+
const pkg = parsed;
|
|
1607
|
+
if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') {
|
|
1608
|
+
throw new Error(`swapInFrameworkPackage: invalid package.json in ${stagedPackageDir}`);
|
|
1609
|
+
}
|
|
1610
|
+
if (!pkg.name.startsWith('@camstack/')) {
|
|
1611
|
+
throw new Error(`swapInFrameworkPackage: refusing non-@camstack package ${pkg.name}`);
|
|
1612
|
+
}
|
|
1613
|
+
const targetDir = path.join(appRoot, 'node_modules', pkg.name);
|
|
1614
|
+
const backupDir = `${targetDir}.fw-bak`;
|
|
1615
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
1616
|
+
const hadExisting = fs.existsSync(targetDir);
|
|
1617
|
+
if (hadExisting)
|
|
1618
|
+
fs.renameSync(targetDir, backupDir);
|
|
1619
|
+
try {
|
|
1620
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
1621
|
+
fs.cpSync(stagedPackageDir, targetDir, { recursive: true });
|
|
1622
|
+
}
|
|
1623
|
+
catch (err) {
|
|
1624
|
+
// Roll back to the previous copy so a failed swap never leaves the
|
|
1625
|
+
// package missing or half-written.
|
|
1626
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
1627
|
+
if (hadExisting)
|
|
1628
|
+
fs.renameSync(backupDir, targetDir);
|
|
1629
|
+
throw err;
|
|
1630
|
+
}
|
|
1631
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
1632
|
+
return { name: pkg.name, version: pkg.version };
|
|
1633
|
+
}
|
|
1454
1634
|
/**
|
|
1455
1635
|
* Resolve the directory whose `node_modules/<pkg>/` holds the currently-
|
|
1456
1636
|
* installed copy of a framework package. `npm install --prefix <appRoot>`
|
package/dist/launcher.js
CHANGED
|
@@ -266,6 +266,26 @@ async function launch() {
|
|
|
266
266
|
else {
|
|
267
267
|
await installer.ensureRequiredPackages();
|
|
268
268
|
}
|
|
269
|
+
// Reconcile the install manifest with what is on disk. Addons baked
|
|
270
|
+
// into the image are copied straight into addonsDir by the container
|
|
271
|
+
// entrypoint and never pass through an install codepath, so they are
|
|
272
|
+
// absent from manifest.json — which makes runtime "Update" reject them
|
|
273
|
+
// with "not currently tracked in manifest". Idempotent; safe every boot.
|
|
274
|
+
//
|
|
275
|
+
// Guarded with a typeof check: the backend and @camstack/system normally
|
|
276
|
+
// ship together in one image, but a system-only framework update can
|
|
277
|
+
// swap in a build that predates `reconcileManifest`. A missing method
|
|
278
|
+
// must not crash boot — the manifest is already persisted from a prior
|
|
279
|
+
// boot, so skipping reconcile is harmless.
|
|
280
|
+
if (typeof installer.reconcileManifest === 'function') {
|
|
281
|
+
const reconciled = installer.reconcileManifest();
|
|
282
|
+
if (reconciled > 0) {
|
|
283
|
+
console.log(`[launcher] Manifest reconciled — ${reconciled} seeded addon(s) registered`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
console.warn('[launcher] installer.reconcileManifest unavailable — skipping manifest reconcile');
|
|
288
|
+
}
|
|
269
289
|
// Self-contained addon bundles (build preset `self-contained`) inline
|
|
270
290
|
// @camstack/types + zod + @camstack/sdk into each addon's dist. The
|
|
271
291
|
// hub no longer plants peer-dep symlinks under
|