@camstack/server 1.0.3 → 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.
@@ -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
- const registry = process.env['CAMSTACK_NPM_REGISTRY'];
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
package/dist/main.js CHANGED
@@ -40,6 +40,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
40
40
  const fastify_1 = require("@trpc/server/adapters/fastify");
41
41
  const ws_1 = require("@trpc/server/adapters/ws");
42
42
  const static_1 = __importDefault(require("@fastify/static"));
43
+ const compress_1 = __importDefault(require("@fastify/compress"));
43
44
  const cookie_1 = __importDefault(require("@fastify/cookie"));
44
45
  const ws_2 = require("ws");
45
46
  const fs = __importStar(require("node:fs"));
@@ -192,6 +193,11 @@ async function bootstrap() {
192
193
  app.enableCors();
193
194
  const fastify = app.getHttpAdapter().getInstance();
194
195
  await fastify.register(cookie_1.default);
196
+ // Gzip/Brotli compression for all responses (including static JS/CSS).
197
+ // Registered before @fastify/static so the compress plugin wraps the
198
+ // static send path — hashed admin-ui chunks go from ~2MB to ~600KB on
199
+ // the wire. threshold:1024 skips compression for tiny payloads.
200
+ await fastify.register(compress_1.default, { global: true, threshold: 1024 });
195
201
  // Data-plane POST bodies: the addon reverse-proxy (`proxyToUpstream`) pipes
196
202
  // `request.raw` upstream, but Fastify's default application/json parser would
197
203
  // drain it first, so a POST body would reach the addon empty. Register a
@@ -931,6 +937,10 @@ async function bootstrap() {
931
937
  root: staticDir,
932
938
  serve: false,
933
939
  decorateReply: true,
940
+ // Disable @fastify/static's automatic Cache-Control injection so
941
+ // the per-file headers set by the route handler (immutable for
942
+ // hashed assets, no-cache for index.html / SW) survive sendFile().
943
+ cacheControl: false,
934
944
  });
935
945
  // Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
936
946
  const webrtcTestPath = path.join(dataPath, 'webrtc-test.html');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -27,11 +27,12 @@
27
27
  "@camstack/addon-pipeline": "*",
28
28
  "@camstack/addon-pipeline-orchestrator": "*",
29
29
  "@camstack/addon-post-analysis": "*",
30
- "@camstack/system": "*",
31
30
  "@camstack/sdk": "*",
32
31
  "@camstack/shm-ring": "*",
32
+ "@camstack/system": "*",
33
33
  "@camstack/types": "*",
34
34
  "@camstack/ui-library": "*",
35
+ "@fastify/compress": "^9.0.0",
35
36
  "@fastify/cookie": "^11.0.2",
36
37
  "@fastify/multipart": "^10.0.0",
37
38
  "@fastify/static": "^9.1.3",