@camstack/server 1.1.37 → 1.1.38

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.
@@ -43,6 +43,7 @@ const os = __importStar(require("node:os"));
43
43
  const node_child_process_1 = require("node:child_process");
44
44
  const scope_access_js_1 = require("./trpc/scope-access.js");
45
45
  const addon_package_service_js_1 = require("../core/addon/addon-package.service.js");
46
+ const framework_live_sync_js_1 = require("../core/addon/framework-live-sync.js");
46
47
  const deploy_stage_registry_js_1 = require("./deploy-stage-registry.js");
47
48
  const deployStageRegistry = new deploy_stage_registry_js_1.DeployStageRegistry();
48
49
  function getDeployStageRegistry() {
@@ -405,8 +406,26 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
405
406
  // and re-runs boot initialization. The 10s restart grace lets this response
406
407
  // flush before the process exits, so the CLI sees a clean confirmation.
407
408
  if ((0, addon_package_service_js_1.isFrameworkPackage)(result.name)) {
409
+ // `installFromTgz` wrote the built tree to `${addonsDir}/<pkg>` — a path
410
+ // NO runtime consumer loads: the hub main resolves the framework from its
411
+ // OWN node_modules closure (walk-up beats NODE_PATH) and forked addon
412
+ // runners resolve it from CAMSTACK_FRAMEWORK_DIR via the ESM hook. Mirror
413
+ // the freshly-built `dist/` into BOTH so the restart below actually loads
414
+ // the new code (otherwise the bounce reloads the identical old code).
415
+ const sourceDistDir = path.join(addonsDir, result.name, 'dist');
416
+ const frameworkSync = await (0, framework_live_sync_js_1.syncFrameworkDist)({
417
+ packageName: result.name,
418
+ sourceDistDir,
419
+ logger,
420
+ });
421
+ const syncFailed = frameworkSync.results.filter((r) => !r.ok);
408
422
  logger.info('framework package deployed — scheduling server restart', {
409
- meta: { packageName: result.name, packageVersion: result.version },
423
+ meta: {
424
+ packageName: result.name,
425
+ packageVersion: result.version,
426
+ syncedTargets: frameworkSync.results.filter((r) => r.ok).map((r) => r.role),
427
+ failedTargets: syncFailed.map((r) => r.role),
428
+ },
410
429
  });
411
430
  addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`);
412
431
  return reply.send({
@@ -415,7 +434,10 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
415
434
  version: result.version,
416
435
  requiresRestart: true,
417
436
  restarting: true,
418
- message: 'Framework package installed — server is restarting to load it',
437
+ frameworkSync: frameworkSync.results,
438
+ message: syncFailed.length > 0
439
+ ? 'Framework package installed but one or more live locations could not be updated — restarting anyway'
440
+ : 'Framework package installed — server is restarting to load it',
419
441
  });
420
442
  }
421
443
  // `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
@@ -44,6 +44,7 @@ const os = __importStar(require("node:os"));
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const node_util_1 = require("node:util");
46
46
  const node_crypto_1 = require("node:crypto");
47
+ const framework_live_sync_js_1 = require("./framework-live-sync.js");
47
48
  const types_1 = require("@camstack/types");
48
49
  const system_1 = require("@camstack/system");
49
50
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
@@ -981,6 +982,11 @@ class AddonPackageService {
981
982
  const description = manifest !== null && typeof manifest['description'] === 'string'
982
983
  ? manifest['description']
983
984
  : undefined;
985
+ // Content build-id of the code the hub ACTUALLY loaded — resolved from
986
+ // the same closure the running process uses. Surfaces "same semver,
987
+ // new code" (framework packages ship changes without always bumping).
988
+ const hubPkgDir = (0, framework_live_sync_js_1.resolveHubClosurePackageDir)(packageName);
989
+ const buildId = hubPkgDir !== null ? (0, framework_live_sync_js_1.computeDistBuildId)(path.join(hubPkgDir, 'dist')) : null;
984
990
  let latestVersion = null;
985
991
  try {
986
992
  const args = [
@@ -1004,6 +1010,7 @@ class AddonPackageService {
1004
1010
  currentVersion,
1005
1011
  latestVersion,
1006
1012
  hasUpdate,
1013
+ buildId,
1007
1014
  ...(description !== undefined ? { description } : {}),
1008
1015
  };
1009
1016
  }));
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveHubClosurePackageDir = resolveHubClosurePackageDir;
37
+ exports.resolveOverlayPackageDir = resolveOverlayPackageDir;
38
+ exports.computeDistBuildId = computeDistBuildId;
39
+ exports.syncFrameworkDist = syncFrameworkDist;
40
+ /**
41
+ * Framework live-sync — make a `camstack deploy` of a framework package
42
+ * (`@camstack/system`, …) actually reach the code the running hub loads.
43
+ *
44
+ * WHY THIS EXISTS
45
+ * ---------------
46
+ * `installFromTgz` writes the freshly-built framework tree to
47
+ * `CAMSTACK_ADDONS_DIR/<pkg>` (`/data/addons/@camstack/system`). NO runtime
48
+ * consumer loads from there:
49
+ * • the hub main process resolves `@camstack/system` from its OWN
50
+ * `node_modules` closure (Node's `node_modules` walk-up from
51
+ * `@camstack/server` finds the co-located copy BEFORE `NODE_PATH` is ever
52
+ * consulted — so the `/data/framework` overlay is a no-op for it);
53
+ * • the forked addon runners resolve it from `CAMSTACK_FRAMEWORK_DIR`
54
+ * (`/data/framework/node_modules/<pkg>`) via the ESM resolver hook.
55
+ * So a framework deploy landed on disk in a dead location and the subsequent
56
+ * hub restart reloaded the SAME unchanged code.
57
+ *
58
+ * This module mirrors the freshly-built `dist/` into BOTH live locations with
59
+ * an atomic rename-aside swap (mirrors `AddonInstaller.evictInstallDir`), so the
60
+ * scheduled restart actually loads the new framework code in the hub main AND
61
+ * the addon runners. Only `dist/` is swapped — native prebuilds and sibling
62
+ * packages in each closure are left untouched.
63
+ *
64
+ * All failures are surfaced (logged + returned) but never thrown: a failed
65
+ * mirror must not brick the restart, and the rename-aside guarantees a live
66
+ * `dist/` is never left half-written (the incoming copy is staged first, and
67
+ * the previous `dist/` is restored if the final swap fails).
68
+ */
69
+ const fs = __importStar(require("node:fs"));
70
+ const path = __importStar(require("node:path"));
71
+ const node_crypto_1 = require("node:crypto");
72
+ const node_module_1 = require("node:module");
73
+ // Resolve framework packages the same way the running hub does — from THIS
74
+ // module's location, whose walk-up lands on the hub's own `node_modules`
75
+ // closure (identical to the hub main's `@camstack/system` resolution).
76
+ const requireFromHere = (0, node_module_1.createRequire)(__filename);
77
+ // ---------------------------------------------------------------------------
78
+ // Tiny cast-free JSON helper (mirror of addon-package.service.readJsonObject).
79
+ // ---------------------------------------------------------------------------
80
+ function readJsonObjectSafe(filePath) {
81
+ try {
82
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
83
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
84
+ return { ...parsed };
85
+ }
86
+ }
87
+ catch {
88
+ /* missing / malformed — treat as absent */
89
+ }
90
+ return null;
91
+ }
92
+ function packageNameOf(pkgJsonPath) {
93
+ const obj = readJsonObjectSafe(pkgJsonPath);
94
+ const name = obj?.['name'];
95
+ return typeof name === 'string' ? name : null;
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Target resolution
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * The package directory the HUB MAIN process loads `<packageName>` from — its
102
+ * own `node_modules` closure. `require.resolve('<pkg>/package.json')` is the
103
+ * happy path; packages whose `exports` map omits that subpath fall back to
104
+ * resolving the main entry and walking up to the matching `package.json`.
105
+ * Returns null when the package is not resolvable.
106
+ */
107
+ function resolveHubClosurePackageDir(packageName) {
108
+ try {
109
+ return path.dirname(requireFromHere.resolve(`${packageName}/package.json`));
110
+ }
111
+ catch {
112
+ // `exports` map blocks the package.json subpath — fall through.
113
+ }
114
+ try {
115
+ let dir = path.dirname(requireFromHere.resolve(packageName));
116
+ while (dir !== path.dirname(dir)) {
117
+ const candidate = path.join(dir, 'package.json');
118
+ if (fs.existsSync(candidate) && packageNameOf(candidate) === packageName) {
119
+ return dir;
120
+ }
121
+ dir = path.dirname(dir);
122
+ }
123
+ }
124
+ catch {
125
+ // package itself not resolvable — treat as not installed.
126
+ }
127
+ return null;
128
+ }
129
+ /**
130
+ * The package directory the forked ADDON RUNNERS load `<packageName>` from —
131
+ * the `CAMSTACK_FRAMEWORK_DIR` overlay. Returns null when the env is unset
132
+ * (dev / no overlay) or the dir does not exist.
133
+ */
134
+ function resolveOverlayPackageDir(packageName) {
135
+ const frameworkDir = process.env['CAMSTACK_FRAMEWORK_DIR'];
136
+ if (frameworkDir === undefined || frameworkDir.length === 0)
137
+ return null;
138
+ const dir = path.join(frameworkDir, 'node_modules', packageName);
139
+ return fs.existsSync(dir) ? dir : null;
140
+ }
141
+ function resolveLiveTargets(packageName) {
142
+ const out = [];
143
+ const hub = resolveHubClosurePackageDir(packageName);
144
+ if (hub !== null)
145
+ out.push({ role: 'hub-closure', packageDir: hub });
146
+ const overlay = resolveOverlayPackageDir(packageName);
147
+ if (overlay !== null)
148
+ out.push({ role: 'overlay', packageDir: overlay });
149
+ return out;
150
+ }
151
+ function safeRealpath(p) {
152
+ try {
153
+ return fs.realpathSync(p);
154
+ }
155
+ catch {
156
+ return path.resolve(p);
157
+ }
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Build-id (content hash of a `dist/` tree)
161
+ // ---------------------------------------------------------------------------
162
+ function collectJsFiles(root, dir, out) {
163
+ let entries;
164
+ try {
165
+ entries = fs.readdirSync(dir, { withFileTypes: true });
166
+ }
167
+ catch {
168
+ return;
169
+ }
170
+ for (const entry of entries) {
171
+ const full = path.join(dir, entry.name);
172
+ if (entry.isDirectory()) {
173
+ collectJsFiles(root, full, out);
174
+ }
175
+ else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
176
+ out.push(path.relative(root, full));
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Content build-id of a `dist/` tree — md5 over every `.js`/`.mjs` file's
182
+ * relative path + bytes (sorted for determinism). Analogous to the Docker
183
+ * image's `seed-version` fingerprint. Returns null when the dir is missing or
184
+ * empty. Used to surface "same semver, new code" instead of trusting `version`.
185
+ */
186
+ function computeDistBuildId(distDir) {
187
+ if (!fs.existsSync(distDir))
188
+ return null;
189
+ const files = [];
190
+ collectJsFiles(distDir, distDir, files);
191
+ if (files.length === 0)
192
+ return null;
193
+ files.sort();
194
+ const hash = (0, node_crypto_1.createHash)('md5');
195
+ for (const rel of files) {
196
+ hash.update(rel);
197
+ hash.update('\0');
198
+ hash.update(fs.readFileSync(path.join(distDir, rel)));
199
+ }
200
+ return hash.digest('hex');
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Atomic dist swap (rename-aside)
204
+ // ---------------------------------------------------------------------------
205
+ /**
206
+ * Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`, atomically.
207
+ *
208
+ * 1. Cross-device COPY `sourceDistDir` → a staging dir INSIDE the target's
209
+ * filesystem. The live `dist/` is untouched, so a mid-copy failure cannot
210
+ * corrupt it.
211
+ * 2. Rename the current `dist/` aside, then rename the staging dir into place.
212
+ * Both renames are metadata ops WITHIN the target fs (atomic). If the final
213
+ * swap fails, the previous `dist/` is restored so the target is never left
214
+ * without one.
215
+ * 3. Best-effort delete of the evicted copy.
216
+ */
217
+ async function swapDist(sourceDistDir, targetPkgDir) {
218
+ const targetDist = path.join(targetPkgDir, 'dist');
219
+ const stamp = `${process.pid}-${Date.now()}`;
220
+ const incoming = path.join(targetPkgDir, `.dist-incoming-${stamp}`);
221
+ const aside = path.join(targetPkgDir, `.dist-evicted-${stamp}`);
222
+ // Step 1 — stage the incoming dist (cross-device copy is fine here).
223
+ try {
224
+ await fs.promises.cp(sourceDistDir, incoming, { recursive: true, force: true });
225
+ }
226
+ catch (err) {
227
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
228
+ throw err;
229
+ }
230
+ // Step 2 — atomic swap within the target fs.
231
+ const hadDist = fs.existsSync(targetDist);
232
+ if (hadDist) {
233
+ await fs.promises.rename(targetDist, aside);
234
+ }
235
+ try {
236
+ await fs.promises.rename(incoming, targetDist);
237
+ }
238
+ catch (swapErr) {
239
+ // Restore the previous dist so the target is never left without one.
240
+ if (hadDist) {
241
+ await fs.promises.rename(aside, targetDist).catch(() => undefined);
242
+ }
243
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
244
+ throw swapErr;
245
+ }
246
+ // Step 3 — best-effort cleanup of the evicted copy.
247
+ if (hadDist) {
248
+ await fs.promises.rm(aside, { recursive: true, force: true }).catch(() => undefined);
249
+ }
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // Public entry
253
+ // ---------------------------------------------------------------------------
254
+ /**
255
+ * Mirror a freshly-installed framework `dist/` into every live location the
256
+ * runtime actually loads it from (hub-main closure + addon-runner overlay).
257
+ * Never throws — returns a per-target outcome. Call BEFORE the scheduled hub
258
+ * restart so the bounce loads the new code.
259
+ */
260
+ async function syncFrameworkDist(args) {
261
+ const { packageName, sourceDistDir, logger } = args;
262
+ if (!fs.existsSync(sourceDistDir)) {
263
+ logger.warn('framework sync: source dist missing — nothing to mirror', {
264
+ meta: { packageName, sourceDistDir },
265
+ });
266
+ return { results: [] };
267
+ }
268
+ const sourcePkgReal = safeRealpath(path.dirname(sourceDistDir));
269
+ const seen = new Set();
270
+ const results = [];
271
+ for (const target of resolveLiveTargets(packageName)) {
272
+ const real = safeRealpath(target.packageDir);
273
+ // Dedupe (a dev symlink can point both roles at one dir) and never mirror
274
+ // a package dir onto its own source (would delete the code mid-swap).
275
+ if (seen.has(real) || real === sourcePkgReal)
276
+ continue;
277
+ seen.add(real);
278
+ try {
279
+ await swapDist(sourceDistDir, target.packageDir);
280
+ results.push({ role: target.role, packageDir: target.packageDir, ok: true });
281
+ logger.info('framework sync: dist swapped into live location', {
282
+ meta: { packageName, role: target.role, packageDir: target.packageDir },
283
+ });
284
+ }
285
+ catch (err) {
286
+ const message = err instanceof Error ? err.message : String(err);
287
+ results.push({ role: target.role, packageDir: target.packageDir, ok: false, error: message });
288
+ logger.error('framework sync: dist swap FAILED (live dir left intact)', {
289
+ meta: { packageName, role: target.role, packageDir: target.packageDir, error: message },
290
+ });
291
+ }
292
+ }
293
+ return { results };
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.37",
3
+ "version": "1.1.38",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",