@camstack/server 1.1.37 → 1.1.39

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,344 @@
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.swapDist = swapDist;
40
+ exports.syncFrameworkDist = syncFrameworkDist;
41
+ /**
42
+ * Framework live-sync — make a `camstack deploy` of a framework package
43
+ * (`@camstack/system`, …) actually reach the code the running hub loads.
44
+ *
45
+ * WHY THIS EXISTS
46
+ * ---------------
47
+ * `installFromTgz` writes the freshly-built framework tree to
48
+ * `CAMSTACK_ADDONS_DIR/<pkg>` (`/data/addons/@camstack/system`). NO runtime
49
+ * consumer loads from there:
50
+ * • the hub main process resolves `@camstack/system` from its OWN
51
+ * `node_modules` closure (Node's `node_modules` walk-up from
52
+ * `@camstack/server` finds the co-located copy BEFORE `NODE_PATH` is ever
53
+ * consulted — so the `/data/framework` overlay is a no-op for it);
54
+ * • the forked addon runners resolve it from `CAMSTACK_FRAMEWORK_DIR`
55
+ * (`/data/framework/node_modules/<pkg>`) via the ESM resolver hook.
56
+ * So a framework deploy landed on disk in a dead location and the subsequent
57
+ * hub restart reloaded the SAME unchanged code.
58
+ *
59
+ * This module mirrors the freshly-built `dist/` into BOTH live locations with
60
+ * an atomic rename-aside swap (mirrors `AddonInstaller.evictInstallDir`), so the
61
+ * scheduled restart actually loads the new framework code in the hub main AND
62
+ * the addon runners. Only `dist/` is swapped — native prebuilds and sibling
63
+ * packages in each closure are left untouched.
64
+ *
65
+ * All failures are surfaced (logged + returned) but never thrown: a failed
66
+ * mirror must not brick the restart, and the rename-aside guarantees a live
67
+ * `dist/` is never left half-written (the incoming copy is staged first, and
68
+ * the previous `dist/` is restored if the final swap fails).
69
+ */
70
+ const fs = __importStar(require("node:fs"));
71
+ const path = __importStar(require("node:path"));
72
+ const node_crypto_1 = require("node:crypto");
73
+ const node_module_1 = require("node:module");
74
+ // Resolve framework packages the same way the running hub does — from THIS
75
+ // module's location, whose walk-up lands on the hub's own `node_modules`
76
+ // closure (identical to the hub main's `@camstack/system` resolution).
77
+ const requireFromHere = (0, node_module_1.createRequire)(__filename);
78
+ // ---------------------------------------------------------------------------
79
+ // Tiny cast-free JSON helper (mirror of addon-package.service.readJsonObject).
80
+ // ---------------------------------------------------------------------------
81
+ function readJsonObjectSafe(filePath) {
82
+ try {
83
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
84
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
85
+ return { ...parsed };
86
+ }
87
+ }
88
+ catch {
89
+ /* missing / malformed — treat as absent */
90
+ }
91
+ return null;
92
+ }
93
+ function packageNameOf(pkgJsonPath) {
94
+ const obj = readJsonObjectSafe(pkgJsonPath);
95
+ const name = obj?.['name'];
96
+ return typeof name === 'string' ? name : null;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Target resolution
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * The package directory the HUB MAIN process loads `<packageName>` from — its
103
+ * own `node_modules` closure. `require.resolve('<pkg>/package.json')` is the
104
+ * happy path; packages whose `exports` map omits that subpath fall back to
105
+ * resolving the main entry and walking up to the matching `package.json`.
106
+ * Returns null when the package is not resolvable.
107
+ */
108
+ function resolveHubClosurePackageDir(packageName) {
109
+ try {
110
+ return path.dirname(requireFromHere.resolve(`${packageName}/package.json`));
111
+ }
112
+ catch {
113
+ // `exports` map blocks the package.json subpath — fall through.
114
+ }
115
+ try {
116
+ let dir = path.dirname(requireFromHere.resolve(packageName));
117
+ while (dir !== path.dirname(dir)) {
118
+ const candidate = path.join(dir, 'package.json');
119
+ if (fs.existsSync(candidate) && packageNameOf(candidate) === packageName) {
120
+ return dir;
121
+ }
122
+ dir = path.dirname(dir);
123
+ }
124
+ }
125
+ catch {
126
+ // package itself not resolvable — treat as not installed.
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * The package directory the forked ADDON RUNNERS load `<packageName>` from —
132
+ * the `CAMSTACK_FRAMEWORK_DIR` overlay. Returns null when the env is unset
133
+ * (dev / no overlay) or the dir does not exist.
134
+ */
135
+ function resolveOverlayPackageDir(packageName) {
136
+ const frameworkDir = process.env['CAMSTACK_FRAMEWORK_DIR'];
137
+ if (frameworkDir === undefined || frameworkDir.length === 0)
138
+ return null;
139
+ const dir = path.join(frameworkDir, 'node_modules', packageName);
140
+ return fs.existsSync(dir) ? dir : null;
141
+ }
142
+ function resolveLiveTargets(packageName) {
143
+ const out = [];
144
+ const hub = resolveHubClosurePackageDir(packageName);
145
+ if (hub !== null)
146
+ out.push({ role: 'hub-closure', packageDir: hub });
147
+ const overlay = resolveOverlayPackageDir(packageName);
148
+ if (overlay !== null)
149
+ out.push({ role: 'overlay', packageDir: overlay });
150
+ return out;
151
+ }
152
+ function safeRealpath(p) {
153
+ try {
154
+ return fs.realpathSync(p);
155
+ }
156
+ catch {
157
+ return path.resolve(p);
158
+ }
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // Build-id (content hash of a `dist/` tree)
162
+ // ---------------------------------------------------------------------------
163
+ function collectJsFiles(root, dir, out) {
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(dir, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ const full = path.join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ collectJsFiles(root, full, out);
175
+ }
176
+ else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
177
+ out.push(path.relative(root, full));
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Content build-id of a `dist/` tree — md5 over every `.js`/`.mjs` file's
183
+ * relative path + bytes (sorted for determinism). Analogous to the Docker
184
+ * image's `seed-version` fingerprint. Returns null when the dir is missing or
185
+ * empty. Used to surface "same semver, new code" instead of trusting `version`.
186
+ */
187
+ function computeDistBuildId(distDir) {
188
+ if (!fs.existsSync(distDir))
189
+ return null;
190
+ const files = [];
191
+ collectJsFiles(distDir, distDir, files);
192
+ if (files.length === 0)
193
+ return null;
194
+ files.sort();
195
+ const hash = (0, node_crypto_1.createHash)('md5');
196
+ for (const rel of files) {
197
+ hash.update(rel);
198
+ hash.update('\0');
199
+ hash.update(fs.readFileSync(path.join(distDir, rel)));
200
+ }
201
+ return hash.digest('hex');
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Atomic dist swap (rename-aside)
205
+ // ---------------------------------------------------------------------------
206
+ function isCrossDeviceError(err) {
207
+ return err instanceof Error && err.code === 'EXDEV';
208
+ }
209
+ /**
210
+ * Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`.
211
+ *
212
+ * 1. COPY `sourceDistDir` → a staging dir INSIDE the target dir. The live
213
+ * `dist/` is untouched, so a mid-copy failure cannot corrupt it.
214
+ * 2. Evict the current `dist/` aside (rename), then rename the staging dir into
215
+ * place. Both renames are metadata ops WITHIN the target fs (atomic). If the
216
+ * final swap fails, the previous `dist/` is restored.
217
+ *
218
+ * OVERLAYFS FALLBACK: the hub-closure target (`/opt/.../@camstack/system`) lives
219
+ * on the container's overlay rootfs. A `dist/` materialized from a LOWER image
220
+ * layer cannot be `rename()`d (overlayfs returns `EXDEV: cross-device link`),
221
+ * so the atomic aside/swap is impossible there. On EXDEV we fall back to a
222
+ * copy-aside restore point + in-place replace. This opens a brief non-atomic
223
+ * window, but it is safe here: the running process already holds the old
224
+ * modules in memory (nothing reads `dist/` until the scheduled restart), and
225
+ * the copy-aside is kept until the replace completes so a failure is
226
+ * recoverable.
227
+ */
228
+ async function swapDist(sourceDistDir, targetPkgDir) {
229
+ const targetDist = path.join(targetPkgDir, 'dist');
230
+ const stamp = `${process.pid}-${Date.now()}`;
231
+ const incoming = path.join(targetPkgDir, `.dist-incoming-${stamp}`);
232
+ const aside = path.join(targetPkgDir, `.dist-evicted-${stamp}`);
233
+ // Step 1 — stage the incoming dist (cross-device copy is fine here).
234
+ try {
235
+ await fs.promises.cp(sourceDistDir, incoming, { recursive: true, force: true });
236
+ }
237
+ catch (err) {
238
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
239
+ throw err;
240
+ }
241
+ const hadDist = fs.existsSync(targetDist);
242
+ // How the previous dist was preserved: 'rename' (moved aside, same fs),
243
+ // 'copy' (overlayfs EXDEV — copied aside then removed in place), or 'none'.
244
+ let asideKind = 'none';
245
+ const restorePreviousDist = async () => {
246
+ if (asideKind === 'none' || fs.existsSync(targetDist))
247
+ return;
248
+ if (asideKind === 'rename') {
249
+ await fs.promises.rename(aside, targetDist).catch(() => undefined);
250
+ }
251
+ else {
252
+ await fs.promises
253
+ .cp(aside, targetDist, { recursive: true, force: true })
254
+ .catch(() => undefined);
255
+ }
256
+ };
257
+ // Step 2 — evict the current dist. Prefer the atomic rename; fall back to
258
+ // copy-aside + in-place remove on overlayfs EXDEV.
259
+ if (hadDist) {
260
+ try {
261
+ await fs.promises.rename(targetDist, aside);
262
+ asideKind = 'rename';
263
+ }
264
+ catch (err) {
265
+ if (!isCrossDeviceError(err)) {
266
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
267
+ throw err;
268
+ }
269
+ await fs.promises.cp(targetDist, aside, { recursive: true, force: true });
270
+ await fs.promises.rm(targetDist, { recursive: true, force: true });
271
+ asideKind = 'copy';
272
+ }
273
+ }
274
+ // Step 3 — move the incoming dist into place; fall back to copy on EXDEV.
275
+ try {
276
+ await fs.promises.rename(incoming, targetDist);
277
+ }
278
+ catch (err) {
279
+ if (isCrossDeviceError(err)) {
280
+ try {
281
+ await fs.promises.cp(incoming, targetDist, { recursive: true, force: true });
282
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
283
+ }
284
+ catch (copyErr) {
285
+ await restorePreviousDist();
286
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
287
+ throw copyErr;
288
+ }
289
+ }
290
+ else {
291
+ await restorePreviousDist();
292
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
293
+ throw err;
294
+ }
295
+ }
296
+ // Step 4 — best-effort cleanup of the evicted copy.
297
+ if (asideKind !== 'none') {
298
+ await fs.promises.rm(aside, { recursive: true, force: true }).catch(() => undefined);
299
+ }
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // Public entry
303
+ // ---------------------------------------------------------------------------
304
+ /**
305
+ * Mirror a freshly-installed framework `dist/` into every live location the
306
+ * runtime actually loads it from (hub-main closure + addon-runner overlay).
307
+ * Never throws — returns a per-target outcome. Call BEFORE the scheduled hub
308
+ * restart so the bounce loads the new code.
309
+ */
310
+ async function syncFrameworkDist(args) {
311
+ const { packageName, sourceDistDir, logger } = args;
312
+ if (!fs.existsSync(sourceDistDir)) {
313
+ logger.warn('framework sync: source dist missing — nothing to mirror', {
314
+ meta: { packageName, sourceDistDir },
315
+ });
316
+ return { results: [] };
317
+ }
318
+ const sourcePkgReal = safeRealpath(path.dirname(sourceDistDir));
319
+ const seen = new Set();
320
+ const results = [];
321
+ for (const target of resolveLiveTargets(packageName)) {
322
+ const real = safeRealpath(target.packageDir);
323
+ // Dedupe (a dev symlink can point both roles at one dir) and never mirror
324
+ // a package dir onto its own source (would delete the code mid-swap).
325
+ if (seen.has(real) || real === sourcePkgReal)
326
+ continue;
327
+ seen.add(real);
328
+ try {
329
+ await swapDist(sourceDistDir, target.packageDir);
330
+ results.push({ role: target.role, packageDir: target.packageDir, ok: true });
331
+ logger.info('framework sync: dist swapped into live location', {
332
+ meta: { packageName, role: target.role, packageDir: target.packageDir },
333
+ });
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ results.push({ role: target.role, packageDir: target.packageDir, ok: false, error: message });
338
+ logger.error('framework sync: dist swap FAILED (live dir left intact)', {
339
+ meta: { packageName, role: target.role, packageDir: target.packageDir, error: message },
340
+ });
341
+ }
342
+ }
343
+ return { results };
344
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.1.37",
3
+ "version": "1.1.39",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",