@camstack/server 1.2.88 → 1.2.90
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/api/addons-custom.router.js +99 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +2 -2
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/static/spa-static.js +10 -1
- package/dist/boot/resume-framework-swap.js +119 -0
- package/dist/core/addon/addon-package.service.js +30 -3
- package/dist/core/addon/framework-live-sync.js +344 -0
- package/dist/core/server-update/server-update.service.js +25 -0
- package/dist/core/update-availability-emitter.js +57 -0
- package/dist/launcher-framework-swap.js +408 -0
- package/dist/main.js +154 -197
- package/dist/manual-boot.js +1 -0
- package/dist/request-framework-swap.js +41 -0
- package/dist/server-root/boot-plan.js +110 -0
- package/dist/server-root/semver-compare.js +45 -0
- package/dist/server-root/server-root-state.js +220 -0
- package/dist/server-root/workspace-detect.js +73 -0
- package/package.json +14 -14
package/dist/main.js
CHANGED
|
@@ -39,7 +39,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- pre-existing lint debt across this 800+ line bootstrap module. The flagged sites cross typed boundaries (Fastify request typing, AddonRouteRegistry, AuthService inherited methods) where the projectService context can't trace inheritance chains. Tracked separately; do not amend in unrelated edits. */
|
|
40
40
|
const fastify_1 = require("@trpc/server/adapters/fastify");
|
|
41
41
|
const ws_1 = require("@trpc/server/adapters/ws");
|
|
42
|
-
const static_1 = __importDefault(require("@fastify/static"));
|
|
43
42
|
const compress_1 = __importDefault(require("@fastify/compress"));
|
|
44
43
|
const cookie_1 = __importDefault(require("@fastify/cookie"));
|
|
45
44
|
const sse_no_compression_js_1 = require("./api/sse-no-compression.js");
|
|
@@ -879,6 +878,9 @@ async function bootstrap() {
|
|
|
879
878
|
}
|
|
880
879
|
const match = addonRouteRegistry.matchRoute(method, fullPath);
|
|
881
880
|
if (!match) {
|
|
881
|
+
if ((0, spa_static_1.isRetiredPublicPath)(fullPath)) {
|
|
882
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
883
|
+
}
|
|
882
884
|
if (method === 'GET' && spaIndexHtml) {
|
|
883
885
|
return reply.type('text/html').send(fs.createReadStream(spaIndexHtml));
|
|
884
886
|
}
|
|
@@ -1074,216 +1076,168 @@ async function bootstrap() {
|
|
|
1074
1076
|
await addonRegistry.setAppRouter(appRouter);
|
|
1075
1077
|
console.log('[bootstrap] AddonRegistry wired with tRPC direct caller');
|
|
1076
1078
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
// are spawning in parallel. The previous 10s window was racing
|
|
1108
|
-
// the admin-ui runner's boot — fastify-static didn't register and
|
|
1109
|
-
// every `GET /` returned 404. Bump to 60s; we only pay this
|
|
1110
|
-
// wait once at boot.
|
|
1111
|
-
const ADMIN_UI_WAIT_MS = 60_000;
|
|
1112
|
-
const POLL_MS = 200;
|
|
1113
|
-
const deadline = Date.now() + ADMIN_UI_WAIT_MS;
|
|
1114
|
-
while (!adminUI && Date.now() < deadline) {
|
|
1115
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1116
|
-
adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
|
|
1079
|
+
const adminUiState = { staticDir: null, indexPath: null };
|
|
1080
|
+
const viewerUiState = { staticDir: null, indexPath: null };
|
|
1081
|
+
const VIEWER_MOUNT = '/viewer/camstack';
|
|
1082
|
+
// Register UI routes before Fastify starts, but resolve their forked
|
|
1083
|
+
// providers after listen. During a cold boot the control plane is therefore
|
|
1084
|
+
// immediately reachable while the non-critical UI runners finish starting.
|
|
1085
|
+
fastify.get('/viewer', async (_request, reply) => reply.redirect(`${VIEWER_MOUNT}/`));
|
|
1086
|
+
fastify.get('/viewer/', async (_request, reply) => reply.redirect(`${VIEWER_MOUNT}/`));
|
|
1087
|
+
fastify.get('/webrtc-test.html', async (_request, reply) => {
|
|
1088
|
+
const webrtcTestPath = path.join(dataPath, 'webrtc-test.html');
|
|
1089
|
+
if (!fs.existsSync(webrtcTestPath))
|
|
1090
|
+
return reply.callNotFound();
|
|
1091
|
+
return reply.type('text/html').send(fs.createReadStream(webrtcTestPath));
|
|
1092
|
+
});
|
|
1093
|
+
fastify.get('/viewer/*', async (request, reply) => {
|
|
1094
|
+
const { staticDir, indexPath } = viewerUiState;
|
|
1095
|
+
if (!staticDir || !indexPath) {
|
|
1096
|
+
return reply.status(503).send({ error: 'Viewer UI is starting' });
|
|
1097
|
+
}
|
|
1098
|
+
const pathOnly = request.url.split('?')[0] ?? request.url;
|
|
1099
|
+
let rel = '';
|
|
1100
|
+
if (pathOnly.startsWith(`${VIEWER_MOUNT}/`)) {
|
|
1101
|
+
rel = pathOnly.slice(VIEWER_MOUNT.length + 1);
|
|
1102
|
+
}
|
|
1103
|
+
if (rel && /\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
1104
|
+
const abs = path.join(staticDir, rel);
|
|
1105
|
+
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
1106
|
+
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1107
|
+
reply.type((0, spa_static_1.contentTypeForPath)(rel));
|
|
1108
|
+
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1117
1109
|
}
|
|
1110
|
+
return reply.callNotFound();
|
|
1118
1111
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1112
|
+
reply.header('cache-control', 'no-cache, must-revalidate');
|
|
1113
|
+
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1114
|
+
});
|
|
1115
|
+
fastify.get('/*', async (request, reply) => {
|
|
1116
|
+
const url = request.url;
|
|
1117
|
+
if ((0, spa_static_1.isRetiredPublicPath)(url))
|
|
1118
|
+
return reply.callNotFound();
|
|
1119
|
+
if (url.startsWith('/trpc') ||
|
|
1120
|
+
url.startsWith('/api/') ||
|
|
1121
|
+
url.startsWith('/agent') ||
|
|
1122
|
+
url.startsWith('/health') ||
|
|
1123
|
+
url.startsWith('/viewer')) {
|
|
1124
|
+
return reply.callNotFound();
|
|
1125
|
+
}
|
|
1126
|
+
const { staticDir, indexPath } = adminUiState;
|
|
1127
|
+
if (!staticDir || !indexPath) {
|
|
1128
|
+
return reply.status(503).send({ error: 'Admin UI is starting' });
|
|
1129
|
+
}
|
|
1130
|
+
const pathOnly = url.split('?')[0] ?? url;
|
|
1131
|
+
if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
1132
|
+
const rel = pathOnly.replace(/^\/+/, '');
|
|
1133
|
+
const abs = path.join(staticDir, rel);
|
|
1134
|
+
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
1135
|
+
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1136
|
+
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1137
|
+
}
|
|
1138
|
+
return reply.callNotFound();
|
|
1139
|
+
}
|
|
1140
|
+
reply.header('cache-control', 'no-cache, must-revalidate');
|
|
1141
|
+
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1142
|
+
});
|
|
1143
|
+
const resolveAdminUi = async () => {
|
|
1144
|
+
try {
|
|
1145
|
+
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
1146
|
+
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
1147
|
+
// HUB-NODE-SCOPED resolution — never the cluster-global singleton.
|
|
1148
|
+
// Agent nodes register the same `admin-ui` cap through their own
|
|
1149
|
+
// agent-ui addon (placement: agent-only), and the cluster-elected
|
|
1150
|
+
// active provider can land on the AGENT's registration depending on
|
|
1151
|
+
// node boot order. Its `getStaticDir()` then answers with a path on
|
|
1152
|
+
// the agent's filesystem — nonexistent here — and the SPA catch-all
|
|
1153
|
+
// silently never registers (every `GET /` 404s until the next
|
|
1154
|
+
// restart re-rolls the election). `getSingletonForNode(cap, 'hub')`
|
|
1155
|
+
// only ever resolves providers hosted on THIS node.
|
|
1156
|
+
let adminUI = capRegistry?.getSingletonForNode('admin-ui', 'hub');
|
|
1157
|
+
// CAMSTACK_SKIP_ADMIN_UI_WAIT bypasses the background capability poll in
|
|
1158
|
+
// e2e runs that do not need the SPA.
|
|
1159
|
+
const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1';
|
|
1160
|
+
if (!adminUI && capRegistry && !skipAdminUIWait) {
|
|
1161
|
+
// Forked-addon spawn + Moleculer registration + tRPC hydration
|
|
1162
|
+
// can take ~15-20s on cold boot, especially when several runners
|
|
1163
|
+
// are spawning in parallel. The previous 10s window was racing
|
|
1164
|
+
// the admin-ui runner's boot — fastify-static didn't register and
|
|
1165
|
+
// every `GET /` returned 404. Bump to 60s; we only pay this
|
|
1166
|
+
// wait once at boot.
|
|
1167
|
+
const ADMIN_UI_WAIT_MS = 60_000;
|
|
1168
|
+
const POLL_MS = 200;
|
|
1169
|
+
const deadline = Date.now() + ADMIN_UI_WAIT_MS;
|
|
1170
|
+
while (!adminUI && Date.now() < deadline) {
|
|
1171
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1172
|
+
adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (adminUI) {
|
|
1176
|
+
const { staticDir } = await adminUI.getStaticDir();
|
|
1177
|
+
const indexPath = path.join(staticDir, 'index.html');
|
|
1178
|
+
if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
|
|
1179
|
+
spaIndexHtml = indexPath;
|
|
1180
|
+
adminUiState.staticDir = staticDir;
|
|
1181
|
+
adminUiState.indexPath = indexPath;
|
|
1182
|
+
(0, precompressed_asset_js_1.warmPrecompressedAssets)(staticDir, (msg, meta) => console.log(`[static] ${msg} ${JSON.stringify(meta)}`));
|
|
1183
|
+
const { version } = await adminUI.getVersion();
|
|
1184
|
+
console.log(`[bootstrap] Admin UI served from: ${staticDir} (v${version})`);
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
console.warn(`[bootstrap] Admin UI dist not found at: ${staticDir} — run 'npm run build' in addon-admin-ui`);
|
|
1145
1188
|
}
|
|
1146
|
-
// SPA fallback + live static serving: this single catch-all owns every
|
|
1147
|
-
// GET. Core API prefixes fall through to their own routers via
|
|
1148
|
-
// `callNotFound`. Uses a wildcard route instead of setNotFoundHandler.
|
|
1149
|
-
fastify.get('/*', async (request, reply) => {
|
|
1150
|
-
const url = request.url;
|
|
1151
|
-
if (url.startsWith('/trpc') ||
|
|
1152
|
-
url.startsWith('/api/') ||
|
|
1153
|
-
url.startsWith('/agent') ||
|
|
1154
|
-
url.startsWith('/health') ||
|
|
1155
|
-
// The viewer SPA owns /viewer/** via its own catch-all (registered
|
|
1156
|
-
// below). Fall through here so admin-ui never serves it — and so
|
|
1157
|
-
// /viewer 404s cleanly when the viewer-ui addon isn't registered.
|
|
1158
|
-
url.startsWith('/viewer')) {
|
|
1159
|
-
return reply.callNotFound();
|
|
1160
|
-
}
|
|
1161
|
-
// A request whose last path segment has a file extension is a static
|
|
1162
|
-
// asset: serve it LIVE from the current dist so a redeployed
|
|
1163
|
-
// admin-ui's new content-hashed files are picked up without a hub
|
|
1164
|
-
// restart. When the file is missing, 404 — never the SPA
|
|
1165
|
-
// `index.html`: serving HTML under a `.js`/`.css` URL makes upstream
|
|
1166
|
-
// caches (Cloudflare, the browser) pin `text/html`, which then fails
|
|
1167
|
-
// the module MIME check long after the file is actually available.
|
|
1168
|
-
const pathOnly = url.split('?')[0] ?? url;
|
|
1169
|
-
if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
1170
|
-
const rel = pathOnly.replace(/^\/+/, '');
|
|
1171
|
-
const abs = path.join(staticDir, rel);
|
|
1172
|
-
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
1173
|
-
// Cache policy that lets PWA updates actually propagate (the
|
|
1174
|
-
// stale-bundle bug): the service worker + registration + manifest
|
|
1175
|
-
// MUST be revalidated every load or a redeploy never reaches the
|
|
1176
|
-
// client (the SW keeps serving the old precache). Content-hashed
|
|
1177
|
-
// build assets (assets/index-<hash>.js) are immutable. Everything
|
|
1178
|
-
// else gets a short cache.
|
|
1179
|
-
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1180
|
-
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1181
|
-
}
|
|
1182
|
-
return reply.callNotFound();
|
|
1183
|
-
}
|
|
1184
|
-
// index.html (the SPA shell) must never be cached — it references the
|
|
1185
|
-
// content-hashed bundles, so a stale copy pins the old app forever.
|
|
1186
|
-
reply.header('cache-control', 'no-cache, must-revalidate');
|
|
1187
|
-
return reply.type('text/html').send(fs.createReadStream(spaIndexHtml));
|
|
1188
|
-
});
|
|
1189
|
-
// Compress the dist in the background so the first CLIENT does not pay
|
|
1190
|
-
// for it — a 5 MB bundle takes ~6.7s at quality 11, and that is a worse
|
|
1191
|
-
// first load than the per-request compression this replaced.
|
|
1192
|
-
(0, precompressed_asset_js_1.warmPrecompressedAssets)(staticDir, (msg, meta) => console.log(`[static] ${msg} ${JSON.stringify(meta)}`));
|
|
1193
|
-
const { version } = await adminUI.getVersion();
|
|
1194
|
-
console.log(`[bootstrap] Admin UI served from: ${staticDir} (v${version})`);
|
|
1195
1189
|
}
|
|
1196
1190
|
else {
|
|
1197
|
-
console.warn(
|
|
1191
|
+
console.warn('[bootstrap] admin-ui capability not registered — no static file serving');
|
|
1198
1192
|
}
|
|
1199
1193
|
}
|
|
1200
|
-
|
|
1201
|
-
console.
|
|
1194
|
+
catch (err) {
|
|
1195
|
+
console.error('[bootstrap] Failed to set up admin UI static serving:', err);
|
|
1202
1196
|
}
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
// viewer is non-critical and admin-ui already gated the slow cold boot).
|
|
1225
|
-
const VIEWER_UI_WAIT_MS = 30_000;
|
|
1226
|
-
const POLL_MS = 200;
|
|
1227
|
-
const deadline = Date.now() + VIEWER_UI_WAIT_MS;
|
|
1228
|
-
while (!viewerUI && Date.now() < deadline) {
|
|
1229
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1230
|
-
viewerUI = capRegistry.getSingletonForNode('viewer-ui', 'hub');
|
|
1197
|
+
};
|
|
1198
|
+
const resolveViewerUi = async () => {
|
|
1199
|
+
try {
|
|
1200
|
+
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
1201
|
+
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
1202
|
+
// Hub-node-scoped for the same reason as admin-ui above: a remote
|
|
1203
|
+
// node's registration must never win the election for the provider
|
|
1204
|
+
// whose staticDir this process reads from local disk.
|
|
1205
|
+
let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
|
|
1206
|
+
const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
|
|
1207
|
+
if (!viewerUI && capRegistry && !skipViewerUIWait) {
|
|
1208
|
+
// The viewer-ui addon runs in its own forked runner and registers a few
|
|
1209
|
+
// seconds after boot. Poll briefly (shorter than admin-ui's 60s — the
|
|
1210
|
+
// viewer is non-critical and admin-ui already gated the slow cold boot).
|
|
1211
|
+
const VIEWER_UI_WAIT_MS = 30_000;
|
|
1212
|
+
const POLL_MS = 200;
|
|
1213
|
+
const deadline = Date.now() + VIEWER_UI_WAIT_MS;
|
|
1214
|
+
while (!viewerUI && Date.now() < deadline) {
|
|
1215
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1216
|
+
viewerUI = capRegistry.getSingletonForNode('viewer-ui', 'hub');
|
|
1217
|
+
}
|
|
1231
1218
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const pathOnly = request.url.split('?')[0] ?? request.url;
|
|
1246
|
-
// Map the request path to a dist-relative path by stripping the mount
|
|
1247
|
-
// prefix. Anything not under the mount is treated as the SPA shell.
|
|
1248
|
-
let rel = '';
|
|
1249
|
-
if (pathOnly === VIEWER_MOUNT || pathOnly === `${VIEWER_MOUNT}/`) {
|
|
1250
|
-
rel = '';
|
|
1251
|
-
}
|
|
1252
|
-
else if (pathOnly.startsWith(`${VIEWER_MOUNT}/`)) {
|
|
1253
|
-
rel = pathOnly.slice(VIEWER_MOUNT.length + 1);
|
|
1254
|
-
}
|
|
1255
|
-
// A path whose last segment has a file extension is a static asset:
|
|
1256
|
-
// serve it, or 404 — NEVER the SPA shell (serving HTML under a
|
|
1257
|
-
// .js/.css URL pins text/html in caches and breaks the module MIME
|
|
1258
|
-
// check long after the file is actually available).
|
|
1259
|
-
if (rel && /\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
1260
|
-
const abs = path.join(staticDir, rel);
|
|
1261
|
-
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
1262
|
-
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1263
|
-
reply.type((0, spa_static_1.contentTypeForPath)(rel));
|
|
1264
|
-
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1265
|
-
}
|
|
1266
|
-
return reply.callNotFound();
|
|
1267
|
-
}
|
|
1268
|
-
// SPA shell — never cached (it references content-hashed bundles).
|
|
1269
|
-
reply.header('cache-control', 'no-cache, must-revalidate');
|
|
1270
|
-
return reply.type('text/html').send(fs.createReadStream(indexPath));
|
|
1271
|
-
});
|
|
1272
|
-
(0, precompressed_asset_js_1.warmPrecompressedAssets)(staticDir, (msg, meta) => console.log(`[static] ${msg} ${JSON.stringify(meta)}`));
|
|
1273
|
-
const { version } = await viewerUI.getVersion();
|
|
1274
|
-
console.log(`[bootstrap] Viewer UI served from: ${staticDir} at ${VIEWER_MOUNT} (v${version})`);
|
|
1219
|
+
if (viewerUI) {
|
|
1220
|
+
const { staticDir } = await viewerUI.getStaticDir();
|
|
1221
|
+
const indexPath = path.join(staticDir, 'index.html');
|
|
1222
|
+
if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
|
|
1223
|
+
viewerUiState.staticDir = staticDir;
|
|
1224
|
+
viewerUiState.indexPath = indexPath;
|
|
1225
|
+
(0, precompressed_asset_js_1.warmPrecompressedAssets)(staticDir, (msg, meta) => console.log(`[static] ${msg} ${JSON.stringify(meta)}`));
|
|
1226
|
+
const { version } = await viewerUI.getVersion();
|
|
1227
|
+
console.log(`[bootstrap] Viewer UI served from: ${staticDir} at ${VIEWER_MOUNT} (v${version})`);
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
console.warn(`[bootstrap] Viewer UI dist not found at: ${staticDir} — run 'npm run build' in addon-viewer-ui`);
|
|
1231
|
+
}
|
|
1275
1232
|
}
|
|
1276
1233
|
else {
|
|
1277
|
-
console.warn(
|
|
1234
|
+
console.warn('[bootstrap] viewer-ui capability not registered — viewer not served');
|
|
1278
1235
|
}
|
|
1279
1236
|
}
|
|
1280
|
-
|
|
1281
|
-
console.
|
|
1237
|
+
catch (err) {
|
|
1238
|
+
console.error('[bootstrap] Failed to set up viewer UI static serving:', err);
|
|
1282
1239
|
}
|
|
1283
|
-
}
|
|
1284
|
-
catch (err) {
|
|
1285
|
-
console.error('[bootstrap] Failed to set up viewer UI static serving:', err);
|
|
1286
|
-
}
|
|
1240
|
+
};
|
|
1287
1241
|
try {
|
|
1288
1242
|
await app.listen(port, host);
|
|
1289
1243
|
}
|
|
@@ -1300,6 +1254,9 @@ async function bootstrap() {
|
|
|
1300
1254
|
const logger = app.get(logging_service_1.LoggingService).createLogger('System');
|
|
1301
1255
|
const protocol = tlsOptions ? 'https' : 'http';
|
|
1302
1256
|
logger.info('CamStack server listening', { meta: { protocol, host, port, trpcRegistered } });
|
|
1257
|
+
// UI runners are not part of control-plane readiness. Their mutable route
|
|
1258
|
+
// state switches from 503 to static serving as soon as each capability is up.
|
|
1259
|
+
void Promise.all([resolveAdminUi(), resolveViewerUi()]);
|
|
1303
1260
|
// Post-boot: fork workers, register device streams, emit system.boot
|
|
1304
1261
|
const postBoot = app.get(post_boot_service_1.PostBootService);
|
|
1305
1262
|
await postBoot.run({ port, host, dataPath, trpcRegistered });
|
package/dist/manual-boot.js
CHANGED
|
@@ -220,6 +220,7 @@ async function bootManual(opts) {
|
|
|
220
220
|
const serverUpdateService = new server_update_service_1.ServerUpdateService({
|
|
221
221
|
logger: loggingService.createLogger('ServerUpdate'),
|
|
222
222
|
restartServer: (requestedBy) => addonPackageService.restartServer(requestedBy),
|
|
223
|
+
eventBus: eventBusService,
|
|
223
224
|
});
|
|
224
225
|
const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
|
|
225
226
|
const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requestFrameworkSwapAndRestart = requestFrameworkSwapAndRestart;
|
|
4
|
+
/**
|
|
5
|
+
* The single seam that joins the two halves of a framework-swap request:
|
|
6
|
+
* 1. write the durable `.pending-framework-swap.json` marker (consumed by the
|
|
7
|
+
* zero-dep launcher on the NEXT boot), and
|
|
8
|
+
* 2. schedule the self-restart that makes the launcher actually run.
|
|
9
|
+
*
|
|
10
|
+
* The launcher (`launcher-framework-swap.ts`) runs before `@camstack/system`
|
|
11
|
+
* is imported and is deliberately zero-dep, so it cannot own the restart — that
|
|
12
|
+
* lives in `@camstack/system`. This module is the ONLY owner of "stage a
|
|
13
|
+
* framework swap → reboot": every engine path that stages the framework (single
|
|
14
|
+
* `updateFrameworkPackage`, the "Update all" bulk job, and the auto-update
|
|
15
|
+
* scheduler) wires its `requestFrameworkSwap` dep to this helper and gets the
|
|
16
|
+
* reboot for free. The lifecycle engine assumes the process exits after
|
|
17
|
+
* `requestFrameworkSwap` returns; without the restart here the marker is written
|
|
18
|
+
* but never applied, silently stranding the update on disk.
|
|
19
|
+
*/
|
|
20
|
+
const system_1 = require("@camstack/system");
|
|
21
|
+
const launcher_framework_swap_js_1 = require("./launcher-framework-swap.js");
|
|
22
|
+
/** Delay before the hub exits — lets the in-flight startJob reply drain first. */
|
|
23
|
+
const FRAMEWORK_SWAP_RESTART_DELAY_MS = 500;
|
|
24
|
+
/**
|
|
25
|
+
* Write the pending-swap marker, then schedule the self-restart. The restart is
|
|
26
|
+
* UNCONDITIONAL: a staged framework swap is inert until the launcher applies it
|
|
27
|
+
* on reboot, so staging without restarting would silently lose the update.
|
|
28
|
+
*/
|
|
29
|
+
function requestFrameworkSwapAndRestart(dataDir, input, deps = {}) {
|
|
30
|
+
const writeMarker = deps.writeMarker ?? launcher_framework_swap_js_1.writePendingFrameworkSwap;
|
|
31
|
+
const scheduleRestart = deps.scheduleRestart ?? system_1.scheduleSelfRestart;
|
|
32
|
+
const now = deps.now ?? Date.now;
|
|
33
|
+
writeMarker(dataDir, {
|
|
34
|
+
jobId: input.jobId,
|
|
35
|
+
taskId: input.taskId,
|
|
36
|
+
packages: input.packages,
|
|
37
|
+
requestedAtMs: now(),
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
});
|
|
40
|
+
scheduleRestart({ delayMs: FRAMEWORK_SWAP_RESTART_DELAY_MS });
|
|
41
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.planBoot = planBoot;
|
|
4
|
+
/**
|
|
5
|
+
* Decide the boot target. Never throws; never mutates `state`.
|
|
6
|
+
* `stateToWrite` is non-null whenever the durable state must change
|
|
7
|
+
* (probation counter bump, rollback, invalid-version eviction).
|
|
8
|
+
*/
|
|
9
|
+
function planBoot(state, isValidVersion, now) {
|
|
10
|
+
if (state === null) {
|
|
11
|
+
return { kind: 'baked', reason: 'no server-root state', stateToWrite: null };
|
|
12
|
+
}
|
|
13
|
+
let next = state;
|
|
14
|
+
let changed = false;
|
|
15
|
+
// ── Pending (freshly-staged) version ──────────────────────────────────
|
|
16
|
+
if (next.pendingBoot !== null) {
|
|
17
|
+
const pending = next.pendingBoot;
|
|
18
|
+
if (pending.bootAttempts >= 1) {
|
|
19
|
+
// Previous probation boot never confirmed healthy — roll back.
|
|
20
|
+
next = {
|
|
21
|
+
...next,
|
|
22
|
+
pendingBoot: null,
|
|
23
|
+
rolledBack: {
|
|
24
|
+
fromVersion: pending.version,
|
|
25
|
+
toVersion: next.currentVersion,
|
|
26
|
+
atMs: now(),
|
|
27
|
+
reason: 'probation boot did not reach ready — rolled back',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
changed = true;
|
|
31
|
+
}
|
|
32
|
+
else if (!isValidVersion(pending.version)) {
|
|
33
|
+
next = {
|
|
34
|
+
...next,
|
|
35
|
+
pendingBoot: null,
|
|
36
|
+
rolledBack: {
|
|
37
|
+
fromVersion: pending.version,
|
|
38
|
+
toVersion: next.currentVersion,
|
|
39
|
+
atMs: now(),
|
|
40
|
+
reason: 'staged version failed validation',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
changed = true;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// Probation boot: bump the attempt counter durably, keep currentVersion
|
|
47
|
+
// untouched until the hub confirms the boot healthy.
|
|
48
|
+
return {
|
|
49
|
+
kind: 'data-root',
|
|
50
|
+
version: pending.version,
|
|
51
|
+
probation: true,
|
|
52
|
+
stateToWrite: {
|
|
53
|
+
...next,
|
|
54
|
+
pendingBoot: { ...pending, bootAttempts: pending.bootAttempts + 1 },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ── Confirmed current version ──────────────────────────────────────────
|
|
60
|
+
if (next.currentVersion !== null) {
|
|
61
|
+
if (isValidVersion(next.currentVersion)) {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'data-root',
|
|
64
|
+
version: next.currentVersion,
|
|
65
|
+
probation: false,
|
|
66
|
+
stateToWrite: changed ? next : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Current version dir is broken — try N-1, else baked.
|
|
70
|
+
const broken = next.currentVersion;
|
|
71
|
+
if (next.previousVersion !== null && isValidVersion(next.previousVersion)) {
|
|
72
|
+
const fallback = next.previousVersion;
|
|
73
|
+
return {
|
|
74
|
+
kind: 'data-root',
|
|
75
|
+
version: fallback,
|
|
76
|
+
probation: false,
|
|
77
|
+
stateToWrite: {
|
|
78
|
+
...next,
|
|
79
|
+
currentVersion: fallback,
|
|
80
|
+
previousVersion: null,
|
|
81
|
+
rolledBack: {
|
|
82
|
+
fromVersion: broken,
|
|
83
|
+
toVersion: fallback,
|
|
84
|
+
atMs: now(),
|
|
85
|
+
reason: 'active version failed validation',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kind: 'baked',
|
|
92
|
+
reason: `active version ${broken} failed validation and no valid previous version exists`,
|
|
93
|
+
stateToWrite: {
|
|
94
|
+
...next,
|
|
95
|
+
currentVersion: null,
|
|
96
|
+
rolledBack: {
|
|
97
|
+
fromVersion: broken,
|
|
98
|
+
toVersion: null,
|
|
99
|
+
atMs: now(),
|
|
100
|
+
reason: 'active version failed validation',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
kind: 'baked',
|
|
107
|
+
reason: 'no active data-root version',
|
|
108
|
+
stateToWrite: changed ? next : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal semver comparison — zero-dep (used by the starter, which must never
|
|
4
|
+
* import from node_modules). Handles `MAJOR.MINOR.PATCH[-prerelease]`:
|
|
5
|
+
* numeric segment compare, missing segments = 0, prerelease < its release,
|
|
6
|
+
* prereleases of the same base compare lexically. Malformed segments compare
|
|
7
|
+
* as 0 (best effort — never throws).
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.compareSemver = compareSemver;
|
|
11
|
+
function parse(version) {
|
|
12
|
+
const dashIdx = version.indexOf('-');
|
|
13
|
+
const base = dashIdx === -1 ? version : version.slice(0, dashIdx);
|
|
14
|
+
const prerelease = dashIdx === -1 ? null : version.slice(dashIdx + 1);
|
|
15
|
+
const nums = base.split('.').map((seg) => {
|
|
16
|
+
const n = Number.parseInt(seg, 10);
|
|
17
|
+
return Number.isNaN(n) ? 0 : n;
|
|
18
|
+
});
|
|
19
|
+
return { nums, prerelease };
|
|
20
|
+
}
|
|
21
|
+
/** Returns -1 when a < b, 0 when equal, 1 when a > b. */
|
|
22
|
+
function compareSemver(a, b) {
|
|
23
|
+
const pa = parse(a);
|
|
24
|
+
const pb = parse(b);
|
|
25
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
26
|
+
for (let i = 0; i < len; i++) {
|
|
27
|
+
const na = pa.nums[i] ?? 0;
|
|
28
|
+
const nb = pb.nums[i] ?? 0;
|
|
29
|
+
if (na < nb)
|
|
30
|
+
return -1;
|
|
31
|
+
if (na > nb)
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (pa.prerelease === null && pb.prerelease === null)
|
|
35
|
+
return 0;
|
|
36
|
+
if (pa.prerelease === null)
|
|
37
|
+
return 1;
|
|
38
|
+
if (pb.prerelease === null)
|
|
39
|
+
return -1;
|
|
40
|
+
if (pa.prerelease < pb.prerelease)
|
|
41
|
+
return -1;
|
|
42
|
+
if (pa.prerelease > pb.prerelease)
|
|
43
|
+
return 1;
|
|
44
|
+
return 0;
|
|
45
|
+
}
|