underpost 3.2.8 → 3.2.9

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/src/cli/run.js CHANGED
@@ -110,6 +110,10 @@ const logger = loggerFactory(import.meta);
110
110
  * @property {string|Array<{ip: string, hostnames: string[]}>} hostAliases - Adds entries to the Pod /etc/hosts via Kubernetes hostAliases.
111
111
  * As a string (CLI): semicolon-separated entries of "ip=hostname1,hostname2" (e.g., "127.0.0.1=foo.local,bar.local;10.1.2.3=foo.remote").
112
112
  * As an array (programmatic): objects with `ip` and `hostnames` fields (e.g., [{ ip: "127.0.0.1", hostnames: ["foo.local"] }]).
113
+ * @property {boolean} gitClean - Whether to perform a `git clean` before running.
114
+ * @property {boolean} copy - Whether to copy the command to the clipboard instead of executing it.
115
+ * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
116
+ * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
113
117
  * @memberof UnderpostRun
114
118
  */
115
119
  const DEFAULT_OPTION = {
@@ -174,6 +178,8 @@ const DEFAULT_OPTION = {
174
178
  hostAliases: '',
175
179
  gitClean: false,
176
180
  copy: false,
181
+ skipFullBuild: false,
182
+ pullBundle: false,
177
183
  };
178
184
 
179
185
  /**
@@ -435,6 +441,15 @@ class UnderpostRun {
435
441
  deployType = 'init';
436
442
  }
437
443
 
444
+ // If --build is set and path is a sync-engine-* target, push the pre-built client bundle
445
+ // to Cloudinary so the remote container can pull it instead of rebuilding from source.
446
+ if (options.build && deployConfId && deployConfId.startsWith('engine-')) {
447
+ const confName = deployConfId.replace(/^engine-/, '');
448
+ const pushDeployId = options.deployId || `dd-${confName}`;
449
+ logger.info(`[template-deploy] Running push-bundle for deployId: ${pushDeployId}`);
450
+ shellExec(`${baseCommand} run push-bundle --deploy-id ${pushDeployId}`);
451
+ }
452
+
438
453
  // Dispatch npmpkg CI workflow — this builds pwa-microservices-template first.
439
454
  // If deployConfId is set, npmpkg.ci.yml will dispatch the engine-<conf-id> CI
440
455
  // with sync=true after template build completes. The engine CI then dispatches
@@ -683,12 +698,15 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
683
698
  : '';
684
699
  const gitCleanFlag = options.gitClean ? ' --git-clean' : '';
685
700
 
701
+ const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
702
+ const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
703
+
686
704
  shellExec(
687
705
  `${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
688
706
  image ? ` --image ${image}` : ''
689
707
  }${versions ? ` --versions ${versions}` : ''}${
690
708
  options.namespace ? ` --namespace ${options.namespace}` : ''
691
- }${timeoutFlags}${cmdString}${gitCleanFlag} ${deployId} ${env}`,
709
+ }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag} ${deployId} ${env}`,
692
710
  );
693
711
 
694
712
  if (isDeployRunnerContext(path, options)) {
@@ -1288,6 +1306,44 @@ EOF
1288
1306
  shellExec(`${options.underpostRoot}/scripts/rocky-setup.sh${options.dev ? ' --install-dev' : ``}`);
1289
1307
  },
1290
1308
 
1309
+ /**
1310
+ * @method install-crio
1311
+ * @description Installs and configures CRI-O as the container runtime for kubeadm clusters.
1312
+ * Adds the stable v1.33 CRI-O yum repository, installs the `cri-o` package, configures
1313
+ * the systemd cgroup driver, enables the `crio` service, and writes `/etc/crictl.yaml`
1314
+ * so that `crictl` targets the CRI-O socket by default.
1315
+ * @param {string} path - Unused.
1316
+ * @param {Object} options - The default underpost runner options for customizing workflow.
1317
+ * @memberof UnderpostRun
1318
+ */
1319
+ 'install-crio': (path, options = DEFAULT_OPTION) => {
1320
+ logger.info('Installing CRI-O...');
1321
+ shellExec(`cat <<EOF | sudo tee /etc/yum.repos.d/cri-o.repo
1322
+ [cri-o]
1323
+ name=CRI-O
1324
+ baseurl=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/
1325
+ enabled=1
1326
+ gpgcheck=1
1327
+ gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
1328
+ EOF`);
1329
+ shellExec(`sudo dnf -y install cri-o`);
1330
+ // crictl is in the kubernetes repo but excluded by default — install it explicitly
1331
+ shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
1332
+ // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1333
+ shellExec(
1334
+ `sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf 2>/dev/null || true`,
1335
+ );
1336
+ shellExec(`sudo systemctl enable --now crio`);
1337
+ logger.info('CRI-O installed and started.');
1338
+ // Write crictl config so all crictl calls default to the CRI-O socket
1339
+ shellExec(`cat <<EOF | sudo tee /etc/crictl.yaml
1340
+ runtime-endpoint: unix:///var/run/crio/crio.sock
1341
+ image-endpoint: unix:///var/run/crio/crio.sock
1342
+ timeout: 10
1343
+ debug: false
1344
+ EOF`);
1345
+ },
1346
+
1291
1347
  /**
1292
1348
  * @method dd-container
1293
1349
  * @description Deploys a development or debug container tasks jobs, setting up necessary volumes and images, and running specified commands within the container.
@@ -1430,6 +1486,9 @@ EOF
1430
1486
  /**
1431
1487
  * @method promote
1432
1488
  * @description Switches traffic between blue/green deployments for a specified deployment ID(s) (uses `dd.router` for 'dd', or a specific ID).
1489
+ * When `--tls` is set, rebuilds the proxy manifest with `--cert` so the HTTPProxy includes
1490
+ * TLS config, deletes stale Certificate resources, then reapplies the proxy and secret.yaml
1491
+ * (cert-manager Certificate resources) for each affected deployment.
1433
1492
  * @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string: `deployId,env,replicas`).
1434
1493
  * @param {Object} options - The default underpost runner options for customizing workflow
1435
1494
  * @memberof UnderpostRun
@@ -1438,11 +1497,34 @@ EOF
1438
1497
  let [inputDeployId, inputEnv, inputReplicas] = path.split(',');
1439
1498
  if (!inputEnv) inputEnv = 'production';
1440
1499
  if (!inputReplicas) inputReplicas = 1;
1500
+ // TODO: normalize: --tls maps to --cert for deploy.js isValidTLSContext compatibility
1501
+ if (options.tls) options.cert = true;
1502
+
1503
+ const applyCerts = (deployId, targetTraffic) => {
1504
+ if (!options.tls) return;
1505
+ // Rebuild proxy.yaml with --cert so the HTTPProxy includes TLS virtualhost config
1506
+ shellExec(
1507
+ `node bin deploy --build-manifest --cert --traffic ${targetTraffic} --replicas ${inputReplicas} --namespace ${options.namespace} ${deployId} ${inputEnv}`,
1508
+ );
1509
+ // Delete stale Certificate resources before reapplying
1510
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
1511
+ if (fs.existsSync(confServerPath)) {
1512
+ for (const host of Object.keys(JSON.parse(fs.readFileSync(confServerPath, 'utf8'))))
1513
+ shellExec(`sudo kubectl delete Certificate ${host} -n ${options.namespace} --ignore-not-found`);
1514
+ }
1515
+ shellExec(
1516
+ `sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${inputEnv}/proxy.yaml -n ${options.namespace}`,
1517
+ );
1518
+ const secretPath = `./engine-private/conf/${deployId}/build/${inputEnv}/secret.yaml`;
1519
+ if (fs.existsSync(secretPath)) shellExec(`kubectl apply -f ${secretPath} -n ${options.namespace}`);
1520
+ };
1521
+
1441
1522
  if (inputDeployId === 'dd') {
1442
1523
  for (const deployId of fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(',')) {
1443
1524
  const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
1444
1525
  const targetTraffic = currentTraffic === 'blue' ? 'green' : 'blue';
1445
1526
  Underpost.deploy.switchTraffic(deployId, inputEnv, targetTraffic, inputReplicas, options.namespace, options);
1527
+ applyCerts(deployId, targetTraffic);
1446
1528
  }
1447
1529
  } else {
1448
1530
  const currentTraffic = Underpost.deploy.getCurrentTraffic(inputDeployId, { namespace: options.namespace });
@@ -1455,6 +1537,7 @@ EOF
1455
1537
  options.namespace,
1456
1538
  options,
1457
1539
  );
1540
+ applyCerts(inputDeployId, targetTraffic);
1458
1541
  }
1459
1542
  },
1460
1543
  /**
@@ -2371,9 +2454,11 @@ EOF`;
2371
2454
  /**
2372
2455
  * @method pull-bundle
2373
2456
  * @description Downloads split zip parts from file storage, merges and extracts them, and moves the result into the public directory.
2374
- * Steps: set cron env, download parts (omit-unzip), merge zip, unzip, remove zip, move to public/<host>.
2375
- * @param {string} path - Optional host name(s) used to locate zip(s) and as public destination(s) (e.g. 'underpost.net' or 'a.com,b.com').
2376
- * If omitted, hosts are loaded from `engine-private/conf/<deployId>/conf.server.json`.
2457
+ * Steps: set env, download parts (omit-unzip), merge zip, unzip, remove zip + parts, move to public/<host>[/path].
2458
+ * Iterates over every non-singleReplica, non-redirect, non-disabledRebuild route in conf.server.json
2459
+ * so that multi-path deployments are handled correctly.
2460
+ * @param {string} path - Optional comma-separated host name(s) to restrict processing (e.g. 'underpost.net' or 'a.com,b.com').
2461
+ * If omitted, all hosts from `engine-private/conf/<deployId>/conf.server.json` are used.
2377
2462
  * @param {Object} options - The default underpost runner options for customizing workflow.
2378
2463
  * @param {string} [options.deployId] - Deploy ID for storage lookup (defaults to 'dd-default').
2379
2464
  * @param {boolean} [options.dev] - Use development environment; defaults to production.
@@ -2384,21 +2469,16 @@ EOF`;
2384
2469
  const env = options.dev ? 'development' : 'production';
2385
2470
  const deployId = options.deployId || 'dd-default';
2386
2471
  const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
2387
- const hosts = path
2472
+ const confServer = fs.existsSync(confServerPath) ? loadConfServerJson(confServerPath) : {};
2473
+ const hostsArg = path
2388
2474
  ? path
2389
2475
  .split(',')
2390
2476
  .map((h) => h.trim())
2391
2477
  .filter(Boolean)
2392
- : fs.existsSync(confServerPath)
2393
- ? Object.keys(loadConfServerJson(confServerPath))
2394
- : [];
2478
+ : Object.keys(confServer);
2395
2479
 
2396
- if (hosts.length === 0) {
2397
- logger.error('pull-bundle: no hosts resolved', {
2398
- deployId,
2399
- path,
2400
- confServerPath,
2401
- });
2480
+ if (hostsArg.length === 0) {
2481
+ logger.error('pull-bundle: no hosts resolved', { deployId, path, confServerPath });
2402
2482
  return;
2403
2483
  }
2404
2484
 
@@ -2407,28 +2487,62 @@ EOF`;
2407
2487
  shellExec(
2408
2488
  `${baseCommand} fs build --recursive --deploy-id ${deployId} --storage-file-path engine-private/conf/${deployId}/storage.bundle.json --pull --omit-unzip`,
2409
2489
  );
2410
- for (const host of hosts) {
2411
- const zipPath = `build/${host}-.zip`;
2412
- const hasZip = fs.existsSync(zipPath);
2413
- const hasParts =
2414
- fs.existsSync('./build') &&
2415
- fs
2416
- .readdirSync('./build')
2417
- .some((name) => name.startsWith(`${host}-.zip.part`) || name.startsWith(`${host}-.zip-part`));
2418
-
2419
- if (!hasZip && !hasParts) {
2420
- logger.warn(`Bundle not found for host '${host}'. Skipping host.`, {
2421
- zipPath,
2422
- deployId,
2423
- });
2424
- continue;
2425
- }
2426
2490
 
2427
- if (hasParts) shellExec(`${baseCommand} client --merge-zip ${zipPath}`);
2428
- shellExec(`${baseCommand} client --unzip ${zipPath}`);
2429
- shellExec(`sudo rm -rf ${zipPath}`);
2430
- if (fs.existsSync(`public/${host}`)) shellExec(`sudo rm -rf public/${host}`);
2431
- shellExec(`sudo mv build/${host} public/${host}`);
2491
+ for (const host of hostsArg) {
2492
+ // Gather all routes for this host; fall back to root '/' when host is not in confServer
2493
+ // (e.g. when hosts were provided explicitly via the path argument).
2494
+ const routePaths = confServer[host] ? Object.keys(confServer[host]) : ['/'];
2495
+
2496
+ for (const routePath of routePaths) {
2497
+ const routeConf = confServer[host] ? confServer[host][routePath] || {} : {};
2498
+ // Skip routes that are not built by buildClient (mirrors buildClient skip conditions)
2499
+ if (routeConf.singleReplica || routeConf.redirect || routeConf.disabledRebuild) continue;
2500
+
2501
+ // buildClient names the zip as "<host>-<path-no-slashes>.zip"
2502
+ // e.g. host="underpost.net", path="/" → buildId="underpost.net-", zip="build/underpost.net-.zip"
2503
+ // e.g. host="app.net", path="/admin" → buildId="app.net-admin", zip="build/app.net-admin.zip"
2504
+ const buildId = `${host}-${routePath.replaceAll('/', '')}`;
2505
+ const zipPath = `build/${buildId}.zip`;
2506
+ const buildDir = './build';
2507
+ const hasZip = fs.existsSync(zipPath);
2508
+ const hasParts =
2509
+ fs.existsSync(buildDir) &&
2510
+ fs
2511
+ .readdirSync(buildDir)
2512
+ .some((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`));
2513
+
2514
+ if (!hasZip && !hasParts) {
2515
+ logger.warn(`Bundle not found for '${host}${routePath}'. Skipping.`, { zipPath, deployId });
2516
+ continue;
2517
+ }
2518
+
2519
+ if (hasParts) shellExec(`${baseCommand} client --merge-zip ${zipPath}`);
2520
+ shellExec(`${baseCommand} client --unzip ${zipPath}`);
2521
+ shellExec(`sudo rm -rf ${zipPath}`);
2522
+
2523
+ // Clean up downloaded part wrapper zips left by --omit-unzip pull
2524
+ if (fs.existsSync(buildDir)) {
2525
+ fs.readdirSync(buildDir)
2526
+ .filter((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`))
2527
+ .forEach((partFile) => shellExec(`sudo rm -rf ${buildDir}/${partFile}`));
2528
+ }
2529
+
2530
+ // unzipClientBuild extracts to buildId with trailing '-' stripped
2531
+ // e.g. "build/underpost.net-" → "build/underpost.net"
2532
+ // e.g. "build/app.net-admin" → "build/app.net-admin" (no trailing dash, no change)
2533
+ const extractedDir = `build/${buildId.replace(/-$/, '')}`;
2534
+ if (!fs.existsSync(extractedDir)) {
2535
+ logger.warn(`Extracted build dir not found: ${extractedDir}. Skipping move for '${host}${routePath}'.`);
2536
+ continue;
2537
+ }
2538
+
2539
+ // Destination mirrors the public directory layout used by the server
2540
+ const publicDestPath = routePath === '/' ? `public/${host}` : `public/${host}${routePath}`;
2541
+ if (fs.existsSync(publicDestPath)) shellExec(`sudo rm -rf ${publicDestPath}`);
2542
+ // Ensure parent directory exists for sub-paths
2543
+ if (routePath !== '/') shellExec(`sudo mkdir -p public/${host}`);
2544
+ shellExec(`sudo mv ${extractedDir} ${publicDestPath}`);
2545
+ }
2432
2546
  }
2433
2547
  },
2434
2548
  };
@@ -255,9 +255,11 @@ class Auth {
255
255
  * @returns {Promise<object>} A promise resolving to the newly created guest session data.
256
256
  */
257
257
  async sessionOut() {
258
- // 1. End User Session
258
+ // 1. End User Session — skip the network call when there is no active user session
259
+ // (avoids a wasted DELETE round-trip for guests / new visitors)
260
+ const hasUserSession = !!GuestService.getUserToken();
259
261
  try {
260
- const result = await UserService.delete({ id: 'logout' });
262
+ const result = hasUserSession ? await UserService.delete({ id: 'logout' }) : null;
261
263
  SearchBox.RecentResults.clear();
262
264
  this.deleteToken();
263
265
  if (this.#refreshTimeout) {
@@ -267,7 +269,7 @@ class Auth {
267
269
  this.renderGuestUi();
268
270
  // Reset user data in the LogIn state/model
269
271
  LogIn.Scope.user.main.model.user = {};
270
- await LogOut.Trigger(result);
272
+ if (result) await LogOut.Trigger(result);
271
273
  } catch (error) {
272
274
  logger.error('Error during user logout:', error);
273
275
  }
@@ -279,8 +281,16 @@ class Auth {
279
281
 
280
282
  if (result.status === 'success' && result.data.token) {
281
283
  this.setGuestToken(result.data.token);
282
- // Recursively call sessionIn to complete the guest login process (UI update, etc.)
283
- return await this.sessionIn();
284
+ // Use the POST response data directly avoids a redundant GET /user/auth
285
+ // round-trip that would otherwise re-verify the token we just received.
286
+ await GuestService.setMeta('guest-session', {
287
+ role: result.data.user?.role,
288
+ userId: result.data.user?._id,
289
+ });
290
+ LogIn.Scope.user.main.model.user = {};
291
+ await LogIn.Trigger(result.data);
292
+ await Account.updateForm(result.data.user);
293
+ return result.data;
284
294
  } else {
285
295
  logger.error('Failed to get a new guest token.');
286
296
  return { user: UserMock.default };
@@ -1,9 +1,7 @@
1
- import { Badge } from './Badge.js';
2
- import { BtnIcon } from './BtnIcon.js';
3
- import { Css, darkTheme, renderCssAttr, simpleIconsRender, ThemeEvents, Themes } from './Css.js';
4
- import { buildBadgeToolTipMenuOption, Modal, renderViewTitle } from './Modal.js';
1
+ import { Css, darkTheme, simpleIconsRender, ThemeEvents, Themes } from './Css.js';
2
+ import { Modal, renderViewTitle } from './Modal.js';
5
3
  import { listenQueryPathInstance, setQueryPath, closeModalRouteChangeEvent, getProxyPath } from './Router.js';
6
- import { htmls, s, sIframe } from './VanillaJs.js';
4
+ import { s, sIframe } from './VanillaJs.js';
7
5
  // https://mintlify.com/docs/quickstart
8
6
  class Docs {
9
7
  static async RenderModal(type) {
@@ -289,41 +287,15 @@ class Docs {
289
287
  },
290
288
  });
291
289
  });
292
- let docMenuRender = '';
290
+ // Register theme events for items that have them (Docs-specific concern)
293
291
  for (const docData of Docs.Data) {
294
292
  if (docData.themeEvent) {
295
293
  ThemeEvents[`doc-icon-${docData.type}`] = docData.themeEvent;
296
294
  setTimeout(ThemeEvents[`doc-icon-${docData.type}`]);
297
295
  }
298
- let tabHref, style, labelStyle;
299
- switch (docData.type) {
300
- case 'repo':
301
- case 'coverage-link':
302
- style = renderCssAttr({ style: { height: '45px' } });
303
- labelStyle = renderCssAttr({ style: { top: '8px', left: '9px' } });
304
- break;
305
- default:
306
- break;
307
- }
308
- tabHref = docData.url();
309
- const subMenuIcon =
310
- options.subMenuIcon && typeof options.subMenuIcon === 'function'
311
- ? options.subMenuIcon(docData.type)
312
- : docData.icon;
313
- docMenuRender += html`
314
- ${await BtnIcon.instance({
315
- class: `in wfa main-btn-menu submenu-btn btn-docs btn-docs-${docData.type}`,
316
- label: html`<span class="inl menu-btn-icon">${subMenuIcon}</span
317
- ><span class="menu-label-text menu-label-text-docs"> ${docData.text} </span>`,
318
- tabHref,
319
- tooltipHtml: await Badge.instance(buildBadgeToolTipMenuOption(docData.text, 'right')),
320
- useMenuBtn: true,
321
- })}
322
- `;
323
296
  }
324
- // s(`.menu-btn-container-children`).classList.remove('hide');
325
- // htmls(`.nav-path-display-${'modal-menu'}`, location.pathname);
326
- htmls('.menu-btn-container-children-docs', docMenuRender);
297
+ // Build submenu items and populate — submenu system is owned by Modal
298
+ Modal.subMenuPopulate('docs', await Modal.buildSubMenuItemsHtml('docs', Docs.Data, options));
327
299
  {
328
300
  const docsData = [
329
301
  {
@@ -469,12 +469,12 @@ class FileExplorer {
469
469
  async init(params) {
470
470
  console.log('LoadFileActionsRenderer created', params);
471
471
  // params.data._id
472
- FileExplorer.eGui = document.createElement('div');
472
+ this.eGui = document.createElement('div');
473
473
  const isPublic = params.data.isPublic;
474
474
  const toggleId = `toggle-public-${params.data._id}`;
475
475
  const hasGenericFile = !!params.data.hasGenericFile;
476
476
  const hasMdFile = !!params.data.hasMdFile;
477
- FileExplorer.eGui.innerHTML = html`
477
+ this.eGui.innerHTML = html`
478
478
  <div class="fl">
479
479
  ${await BtnIcon.instance({
480
480
  class: `in fll management-table-btn-mini btn-file-download-${params.data._id}${!hasGenericFile ? ' btn-disabled' : ''}`,
@@ -981,7 +981,7 @@ class FileExplorer {
981
981
  });
982
982
  }
983
983
  getGui() {
984
- return FileExplorer.eGui;
984
+ return this.eGui;
985
985
  }
986
986
  refresh(params) {
987
987
  console.log('LoadFileActionsRenderer refreshed', params);
@@ -994,8 +994,8 @@ class FileExplorer {
994
994
  console.log('LoadFolderActionsRenderer created', params);
995
995
  // params.data._id
996
996
  const id = params.data.locationId;
997
- FileExplorer.eGui = document.createElement('div');
998
- FileExplorer.eGui.innerHTML = html`
997
+ this.eGui = document.createElement('div');
998
+ this.eGui.innerHTML = html`
999
999
  <div class="fl">
1000
1000
  ${await BtnIcon.instance({
1001
1001
  class: `in fll management-table-btn-mini btn-folder-delete-${id}`,
@@ -1058,7 +1058,7 @@ class FileExplorer {
1058
1058
  });
1059
1059
  }
1060
1060
  getGui() {
1061
- return FileExplorer.eGui;
1061
+ return this.eGui;
1062
1062
  }
1063
1063
  refresh(params) {
1064
1064
  console.log('LoadFolderActionsRenderer refreshed', params);
@@ -1789,6 +1789,12 @@ class Modal {
1789
1789
  if (options.onCollapseMenu) options.onCollapseMenu();
1790
1790
  s(`.sub-menu-title-container-${'modal-menu'}`).classList.add('hide');
1791
1791
  s(`.nav-path-container-${'modal-menu'}`).classList.add('hide');
1792
+ // Shrink any already-open submenu containers to icon-only width
1793
+ Object.keys(Modal.subMenuBtnClass).forEach((subMenuId) => {
1794
+ const container = s(`.menu-btn-container-children-${subMenuId}`);
1795
+ if (container && container.style.height && container.style.height !== '0px')
1796
+ container.style.width = `${collapseSlideMenuWidth}px`;
1797
+ });
1792
1798
  Object.keys(this.Data[idModal].onCollapseMenuListener).map((keyListener) =>
1793
1799
  this.Data[idModal].onCollapseMenuListener[keyListener](),
1794
1800
  );
@@ -1806,6 +1812,12 @@ class Modal {
1806
1812
  if (options.onExtendMenu) options.onExtendMenu();
1807
1813
  s(`.sub-menu-title-container-${'modal-menu'}`).classList.remove('hide');
1808
1814
  s(`.nav-path-container-${'modal-menu'}`).classList.remove('hide');
1815
+ // Expand any already-open submenu containers back to full width
1816
+ Object.keys(Modal.subMenuBtnClass).forEach((subMenuId) => {
1817
+ const container = s(`.menu-btn-container-children-${subMenuId}`);
1818
+ if (container && container.style.height && container.style.height !== '0px')
1819
+ container.style.width = `${originSlideMenuWidth}px`;
1820
+ });
1809
1821
  Object.keys(this.Data[idModal].onExtendMenuListener).map((keyListener) =>
1810
1822
  this.Data[idModal].onExtendMenuListener[keyListener](),
1811
1823
  );
@@ -2464,6 +2476,52 @@ class Modal {
2464
2476
  }
2465
2477
  };
2466
2478
 
2479
+ /**
2480
+ * Builds submenu item HTML using the canonical BtnIcon pattern.
2481
+ * Centralises the item-rendering contract so individual submenu owners (e.g. Docs)
2482
+ * only need to supply data — not layout concerns.
2483
+ * @param {string} subMenuId - Submenu identifier (e.g. 'docs')
2484
+ * @param {Array<{type:string, icon:string, text:string, url:function|string}>} items
2485
+ * @param {Object} [options]
2486
+ * @param {function} [options.subMenuIcon] - Optional icon override per item type
2487
+ * @returns {Promise<string>} Rendered HTML string
2488
+ */
2489
+ static buildSubMenuItemsHtml = async (subMenuId, items = [], options = {}) => {
2490
+ let result = '';
2491
+ const _menuMode = Modal.Data['modal-menu']?.options?.mode;
2492
+ const _tooltipSide = options.tooltipSide || (_menuMode === 'slide-menu-right' ? 'right' : 'left');
2493
+ for (const item of items) {
2494
+ const tabHref = typeof item.url === 'function' ? item.url() : item.url || '';
2495
+ const icon =
2496
+ options.subMenuIcon && typeof options.subMenuIcon === 'function' ? options.subMenuIcon(item.type) : item.icon;
2497
+ result += html`${await BtnIcon.instance({
2498
+ class: `in wfa main-btn-menu submenu-btn btn-${subMenuId} btn-${subMenuId}-${item.type}`,
2499
+ label: html`<span class="inl menu-btn-icon">${icon}</span
2500
+ ><span class="menu-label-text menu-label-text-${subMenuId}"> ${item.text} </span>`,
2501
+ tabHref,
2502
+ tooltipHtml: await Badge.instance(buildBadgeToolTipMenuOption(item.text, _tooltipSide)),
2503
+ useMenuBtn: true,
2504
+ })} `;
2505
+ }
2506
+ return result;
2507
+ };
2508
+
2509
+ /**
2510
+ * Injects pre-built submenu item HTML into the submenu container and syncs
2511
+ * the collapse state so labels are hidden when the menu is icon-only.
2512
+ * @param {string} subMenuId
2513
+ * @param {string} itemsHtml
2514
+ */
2515
+ static subMenuPopulate = (subMenuId, itemsHtml) => {
2516
+ htmls(`.menu-btn-container-children-${subMenuId}`, itemsHtml);
2517
+ const _menuMode = Modal.Data['modal-menu']?.options?.mode;
2518
+ const _collapseIndicatorClass =
2519
+ _menuMode === 'slide-menu-right' ? '.btn-icon-menu-mode-left' : '.btn-icon-menu-mode-right';
2520
+ if (s(_collapseIndicatorClass) && !s(_collapseIndicatorClass).classList.contains('hide')) {
2521
+ sa(`.menu-label-text-${subMenuId}`).forEach((el) => el.classList.add('hide'));
2522
+ }
2523
+ };
2524
+
2467
2525
  // Move modal title element into the bar's render container so it aligns with control buttons
2468
2526
  /**
2469
2527
  * Position a modal relative to an anchor element.
@@ -2769,9 +2827,14 @@ const subMenuRender = async (subMenuId) => {
2769
2827
  setTimeout(() => {
2770
2828
  Modal.menuTextLabelAnimation('modal-menu', subMenuId);
2771
2829
  });
2772
- // Open animation
2830
+ // Open animation — match the current menu width (collapsed = 50px, extended = 320px)
2831
+ const _menuMode = Modal.Data['modal-menu']?.options?.mode;
2832
+ const _collapseIndicatorClass =
2833
+ _menuMode === 'slide-menu-right' ? '.btn-icon-menu-mode-left' : '.btn-icon-menu-mode-right';
2834
+ const _isMenuCollapsed = s(_collapseIndicatorClass) && !s(_collapseIndicatorClass).classList.contains('hide');
2835
+ const _menuContainerWidth = _isMenuCollapsed ? 50 : 320;
2773
2836
  setTimeout(top, 360);
2774
- menuContainer.style.width = '320px';
2837
+ menuContainer.style.width = `${_menuContainerWidth}px`;
2775
2838
  menuContainer.style.overflow = null;
2776
2839
  menuContainer.style.height = '0px';
2777
2840
  menuContainer.style.height = `${_hBtn * 6}px`;
@@ -37,7 +37,7 @@ class Recover {
37
37
  rules: [{ type: 'isEmpty' }, { type: 'isLength', options: { min: 2, max: 20 } }],
38
38
  show: () => false,
39
39
  disable: function () {
40
- return !Recover.show();
40
+ return !this.show();
41
41
  },
42
42
  },
43
43
  'recover-email': {
@@ -46,7 +46,7 @@ class Recover {
46
46
  rules: [{ type: 'isEmpty' }, { type: 'isEmail' }],
47
47
  show: () => mode === 'recover-verify-email',
48
48
  disable: function () {
49
- return !Recover.show();
49
+ return !this.show();
50
50
  },
51
51
  },
52
52
  'recover-password': {
@@ -55,7 +55,7 @@ class Recover {
55
55
  rules: [{ type: 'isStrongPassword' }],
56
56
  show: () => mode === 'change-password',
57
57
  disable: function () {
58
- return !Recover.show();
58
+ return !this.show();
59
59
  },
60
60
  },
61
61
  'recover-repeat-password': {
@@ -63,7 +63,7 @@ class Recover {
63
63
  rules: [{ type: 'isEmpty' }, { type: 'passwordMismatch', options: `recover-password` }],
64
64
  show: () => mode === 'change-password',
65
65
  disable: function () {
66
- return !Recover.show();
66
+ return !this.show();
67
67
  },
68
68
  },
69
69
  };
@@ -161,36 +161,33 @@ class PwaWorker {
161
161
 
162
162
  this.RouterInstance = typeof router?.instance === 'function' ? router.instance() : router();
163
163
  if (this.RouterInstance?.Routes) registerRoutes(this.RouterInstance.Routes);
164
- const isInstall = await this.status();
165
- if (!isInstall) await this.install();
164
+ // Defer SW registration entirely out of the render critical path.
165
+ // Firefox IPC for SW registration is notably slower than Chromium;
166
+ // scheduling after 'load' means the first meaningful paint is not blocked.
167
+ this.registerServiceWorkerDeferred();
166
168
 
167
169
  // ── declarative bootstrap path ──────────────────────────────────────────
168
- if (typeof render !== 'function' || render.instance) {
169
- // shared core inits
170
- if (themes) await Css.loadThemes(themes);
171
- await this.runComponentInit(TranslateCore);
172
- await this.runComponentInit(translate);
173
- await this.runComponentInit(Responsive);
174
- // app shell render
175
- if (render && typeof render.instance === 'function') {
176
- const htmlMainBody = typeof template === 'function' ? template : undefined;
177
- await this.runComponentInit(render, htmlMainBody ? { htmlMainBody } : undefined);
178
- }
179
- // socket init
180
- const channels = appStore ? appStore.Data : (session && session.socket && session.socket.Data) || undefined;
181
- await this.runComponentInit(SocketIo, { channels, path: socketPath });
182
- if (session) {
183
- await this.runComponentInit(session.socket);
184
- await this.runComponentInit(session.login);
185
- await this.runComponentInit(session.logout || session.signout);
186
- await this.runComponentInit(session.signup);
187
- await this.runComponentInit(session.account);
188
- }
189
- await this.runComponentInit(Keyboard);
190
- } else {
191
- // ── legacy raw render callback (backward-compat) ─────────────────────
192
- await render();
170
+ // shared core inits
171
+ if (themes) await Css.loadThemes(themes);
172
+ await this.runComponentInit(TranslateCore);
173
+ await this.runComponentInit(translate);
174
+ await this.runComponentInit(Responsive);
175
+ // app shell render
176
+ if (render && typeof render.instance === 'function') {
177
+ const htmlMainBody = typeof template === 'function' ? template : undefined;
178
+ await this.runComponentInit(render, htmlMainBody ? { htmlMainBody } : undefined);
179
+ }
180
+ // socket init
181
+ const channels = appStore ? appStore.Data : (session && session.socket && session.socket.Data) || undefined;
182
+ await this.runComponentInit(SocketIo, { channels, path: socketPath });
183
+ if (session) {
184
+ await this.runComponentInit(session.socket);
185
+ await this.runComponentInit(session.login);
186
+ await this.runComponentInit(session.logout || session.signout);
187
+ await this.runComponentInit(session.signup);
188
+ await this.runComponentInit(session.account);
193
189
  }
190
+ await this.runComponentInit(Keyboard);
194
191
  // ────────────────────────────────────────────────────────────────────────
195
192
  await LoadRouter(this.RouterInstance);
196
193
  LoadingAnimation.removeSplashScreen();
@@ -316,6 +313,30 @@ class PwaWorker {
316
313
  }
317
314
  }
318
315
 
316
+ /**
317
+ * Schedules SW registration to run after the page 'load' event (or immediately
318
+ * if the page is already loaded). This keeps SW work off the render critical path,
319
+ * which is the primary cause of Firefox being slower than Chromium on first load.
320
+ * @memberof PwaWorker
321
+ * @returns {void}
322
+ */
323
+ registerServiceWorkerDeferred() {
324
+ if (!('serviceWorker' in navigator)) {
325
+ logger.warn('Service Worker Disabled in browser');
326
+ return;
327
+ }
328
+ const register = () => {
329
+ this.status().then((isInstall) => {
330
+ if (!isInstall) this.install();
331
+ });
332
+ };
333
+ if (document.readyState === 'complete') {
334
+ register();
335
+ } else {
336
+ window.addEventListener('load', register, { once: true });
337
+ }
338
+ }
339
+
319
340
  /**
320
341
  * Checks the current status of all service worker registrations and sets the
321
342
  * `updateServiceWorker` function reference if an active worker is found.