emailengine-app 2.68.0 → 2.69.0

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.
Files changed (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
package/server.js CHANGED
@@ -72,7 +72,8 @@ const {
72
72
  getRedisStats,
73
73
  threadStats,
74
74
  httpAgent,
75
- reloadHttpProxyAgent
75
+ reloadHttpProxyAgent,
76
+ maybeReloadHttpProxyAgent
76
77
  } = require('./lib/tools');
77
78
  const MetricsCollector = require('./lib/metrics-collector');
78
79
 
@@ -148,6 +149,8 @@ const { QueueEvents } = require('bullmq');
148
149
 
149
150
  const getSecret = require('./lib/get-secret');
150
151
 
152
+ const { rejectWorkerCalls } = require('./lib/reject-worker-calls');
153
+
151
154
  const msgpack = require('msgpack5')();
152
155
 
153
156
  // Initialize default configuration values if not set
@@ -220,6 +223,10 @@ config.dbs.redis = readEnvValue('EENGINE_REDIS') || readEnvValue('REDIS_URL') ||
220
223
  config.workers.imap = getWorkerCount(readEnvValue('EENGINE_WORKERS') || config.workers.imap) || 4;
221
224
  config.workers.webhooks = Number(readEnvValue('EENGINE_WORKERS_WEBHOOKS')) || config.workers.webhooks || 1;
222
225
  config.workers.submit = Number(readEnvValue('EENGINE_WORKERS_SUBMIT')) || config.workers.submit || 1;
226
+ // API worker count. Values >1 require SO_REUSEPORT (Linux); on unsupported platforms it falls back to 1 at startup.
227
+ // Uses getWorkerCount() for parity with EENGINE_WORKERS (supports "cpus"); Math.floor avoids a fractional
228
+ // count over-spawning, Math.max keeps at least one API worker.
229
+ config.workers.api = Math.max(1, Math.floor(getWorkerCount(readEnvValue('EENGINE_WORKERS_API') || config.workers.api)));
223
230
 
224
231
  config.api.port =
225
232
  (hasEnvValue('EENGINE_PORT') && Number(readEnvValue('EENGINE_PORT'))) || (hasEnvValue('PORT') && Number(readEnvValue('PORT'))) || config.api.port;
@@ -275,6 +282,12 @@ const NO_ACTIVE_HANDLER_RESP_ERR = new Error('No active handler for requested ac
275
282
  NO_ACTIVE_HANDLER_RESP_ERR.statusCode = 503;
276
283
  NO_ACTIVE_HANDLER_RESP_ERR.code = 'WorkerNotAvailable';
277
284
 
285
+ // Shared rejection for in-flight calls whose target worker terminated mid-request.
286
+ // Reused across concurrent rejections - never attach per-call fields to this instance.
287
+ const WORKER_DIED_RESP_ERR = new Error('Worker handling the request terminated before completion. Try again.');
288
+ WORKER_DIED_RESP_ERR.statusCode = 503;
289
+ WORKER_DIED_RESP_ERR.code = 'WorkerNotAvailable';
290
+
278
291
  // Update check intervals
279
292
  const UPGRADE_CHECK_TIMEOUT = 1 * 24 * 3600 * 1000; // 24 hours
280
293
  const LICENSE_CHECK_TIMEOUT = 20 * 60 * 1000; // 20 minutes
@@ -315,6 +328,7 @@ const THREAD_NAMES = {
315
328
  */
316
329
  const THREAD_CONFIG_VALUES = {
317
330
  imap: { key: 'EENGINE_WORKERS', value: config.workers.imap },
331
+ api: { key: 'EENGINE_WORKERS_API', value: config.workers.api },
318
332
  submit: { key: 'EENGINE_WORKERS_SUBMIT', value: config.workers.submit },
319
333
  webhooks: { key: 'EENGINE_WORKERS_WEBHOOKS', value: config.workers.webhooks },
320
334
  export: { key: 'EENGINE_WORKERS_EXPORT', value: config.workers.export || 1 }
@@ -326,6 +340,97 @@ const queueEvents = {};
326
340
  // Unique run index for this server instance
327
341
  let runIndex;
328
342
 
343
+ // Whether multiple API workers may share the listen port via SO_REUSEPORT.
344
+ // Determined once at startup by probeReusePort(); false means a single API worker.
345
+ let apiWorkerReusePort = false;
346
+
347
+ // Requested API worker count (EENGINE_WORKERS_API), surfaced on /admin/internals so operators can
348
+ // see when more workers were requested than could be started. Whether we fell back to a single
349
+ // worker is derived: `apiWorkersRequested > 1 && !apiWorkerReusePort`.
350
+ let apiWorkersRequested = 1;
351
+
352
+ // Why a multi-worker request fell back to a single API worker, surfaced on /admin/internals so the
353
+ // banner can state the real cause. Set during probeReusePort():
354
+ // - 'platform': SO_REUSEPORT is unsupported on this OS/Node build (Linux with Node < 23.1, macOS, Windows)
355
+ // - 'port': the probe could not test the port (transient conflict / permission), so support is unknown
356
+ // Stays null when SO_REUSEPORT is supported or only one worker was requested.
357
+ let apiWorkerReusePortFallbackReason = null;
358
+
359
+ /**
360
+ * Probe whether SO_REUSEPORT can load-balance across multiple sockets on this
361
+ * platform. Binds two listeners to the same host:port with reusePort enabled;
362
+ * both must succeed for load balancing to work. It is unsupported on macOS,
363
+ * Windows, and Node < 23.1, where either the first bind fails outright (e.g.
364
+ * ENOTSUP) or - when the reusePort option is silently ignored (Node < 23.1) -
365
+ * the first bind succeeds as a plain bind and the second fails with EADDRINUSE.
366
+ * The `stage` field tells the caller where the probe failed so it can separate a
367
+ * genuine platform/capability limitation from a transient port conflict:
368
+ * - 'probe': the first bind failed, so the port could not even be tested. An
369
+ * EADDRINUSE/EACCES code here means the port is unavailable (transient); any
370
+ * other code means the platform rejected reusePort.
371
+ * - 'reuse': the first bind succeeded but the second did not, so reusePort is
372
+ * not honored on this platform/Node - a capability limitation, even though
373
+ * the failing code is EADDRINUSE.
374
+ * - null: SO_REUSEPORT is supported.
375
+ * @param {string} host - Bind address
376
+ * @param {number} port - Listen port
377
+ * @returns {Promise<{supported: boolean, code: (string|null), stage: (string|null)}>}
378
+ * Whether SO_REUSEPORT load balancing works, the failing bind's error code (or
379
+ * null), and which bind failed ('probe', 'reuse', or null when supported)
380
+ */
381
+ async function probeReusePort(host, port) {
382
+ const net = require('net');
383
+
384
+ const open = () =>
385
+ new Promise(resolve => {
386
+ let srv = net.createServer();
387
+ srv.once('error', err => resolve({ srv: null, code: (err && err.code) || null }));
388
+ try {
389
+ srv.listen({ host, port, reusePort: true }, () => resolve({ srv, code: null }));
390
+ } catch (err) {
391
+ resolve({ srv: null, code: (err && err.code) || null });
392
+ }
393
+ });
394
+
395
+ const close = srv =>
396
+ new Promise(resolve => {
397
+ if (!srv) {
398
+ return resolve();
399
+ }
400
+ srv.close(() => resolve());
401
+ });
402
+
403
+ let first = await open();
404
+ if (!first.srv) {
405
+ // Could not even bind the first probe socket, so the port itself could not be tested.
406
+ return { supported: false, code: first.code, stage: 'probe' };
407
+ }
408
+
409
+ let second = await open();
410
+ await close(first.srv);
411
+ await close(second.srv);
412
+
413
+ if (second.srv) {
414
+ return { supported: true, code: null, stage: null };
415
+ }
416
+
417
+ // The first bind succeeded but a second listener on the same host:port did not, so the kernel is
418
+ // not load-balancing via SO_REUSEPORT - the option was accepted/ignored but not honored (e.g.
419
+ // Linux with Node < 23.1, where reusePort is an unrecognized listen() option). This is a platform
420
+ // limitation, not a port conflict, even though the failing code is EADDRINUSE.
421
+ return { supported: false, code: second.code, stage: 'reuse' };
422
+ }
423
+
424
+ /**
425
+ * Compute per-worker spawn options. API workers receive their index and whether to
426
+ * bind with SO_REUSEPORT; other worker types need no extra workerData. Keeps the
427
+ * startup and respawn paths deriving spawn options from a single place.
428
+ * @param {string} type - Worker type
429
+ * @param {number} workerIndex - Zero-based index within the worker type
430
+ * @returns {Object|undefined} Spawn options for spawnWorker(), or undefined
431
+ */
432
+ const workerSpawnOpts = (type, workerIndex) => (type === 'api' ? { workerData: { workerIndex, reusePort: apiWorkerReusePort } } : undefined);
433
+
329
434
  // Prepared configuration handling
330
435
  let preparedSettings = false;
331
436
  const preparedSettingsString = readEnvValue('EENGINE_SETTINGS') || config.settings;
@@ -374,7 +479,7 @@ if (preparedPasswordString) {
374
479
  throw new Error('Password format is invalid');
375
480
  }
376
481
  } catch (err) {
377
- logger.error({ msg: 'Invalid password hash provided', input: preparedPasswordString, err });
482
+ logger.error({ msg: 'Invalid password hash provided', err });
378
483
  logger.flush(() => process.exit(1));
379
484
  }
380
485
  }
@@ -682,7 +787,7 @@ let updateServerState = async (type, state, payload) => {
682
787
  for (let worker of workers.get('api')) {
683
788
  let callPayload = {
684
789
  cmd: 'change',
685
- type: '${type}ServerState',
790
+ type: `${type}ServerState`,
686
791
  key: state,
687
792
  payload: payload || null
688
793
  };
@@ -930,9 +1035,11 @@ async function sendWebhook(account, event, data) {
930
1035
  /**
931
1036
  * Spawn a new worker thread of the specified type
932
1037
  * @param {string} type - Worker type (imap, api, webhooks, submit, documents, smtp, imapProxy)
1038
+ * @param {Object} [opts] - Optional spawn options
1039
+ * @param {Object} [opts.workerData] - Data passed to the worker thread (e.g. API worker index)
933
1040
  * @returns {Promise<number|void>} Thread ID if successful
934
1041
  */
935
- let spawnWorker = async type => {
1042
+ let spawnWorker = async (type, opts) => {
936
1043
  // Don't spawn workers during shutdown
937
1044
  if (isClosing) {
938
1045
  return;
@@ -966,6 +1073,7 @@ let spawnWorker = async type => {
966
1073
  let worker = new WorkerThread(pathlib.join(__dirname, 'workers', `${type.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.js`), {
967
1074
  argv,
968
1075
  env: SHARE_ENV,
1076
+ workerData: opts && opts.workerData,
969
1077
  trackUnmanagedFds: true
970
1078
  });
971
1079
  metrics.threadStarts.inc();
@@ -1013,6 +1121,13 @@ let spawnWorker = async type => {
1013
1121
  onlineWorkers.delete(worker);
1014
1122
  metrics.threadStops.inc();
1015
1123
 
1124
+ // Fail any in-flight calls routed to this worker right away, so callers
1125
+ // get a fast retryable error instead of hanging until their own timeout.
1126
+ let rejectedCalls = rejectWorkerCalls(callQueue, worker, WORKER_DIED_RESP_ERR);
1127
+ if (rejectedCalls) {
1128
+ logger.info({ msg: 'Rejected in-flight calls for exited worker', type, threadId: worker.threadId, rejectedCalls });
1129
+ }
1130
+
1016
1131
  workers.get(type).delete(worker);
1017
1132
 
1018
1133
  // Update server state for proxy servers
@@ -1117,9 +1232,9 @@ let spawnWorker = async type => {
1117
1232
  logger.error({ msg: 'Worker unexpectedly exited', exitCode, type });
1118
1233
  }
1119
1234
 
1120
- // Respawn worker after delay
1235
+ // Respawn worker after delay, preserving any spawn options (e.g. API worker index)
1121
1236
  await new Promise(r => setTimeout(r, 1000));
1122
- await spawnWorker(type);
1237
+ await spawnWorker(type, opts);
1123
1238
  };
1124
1239
 
1125
1240
  // Handle worker exit
@@ -1325,10 +1440,8 @@ let spawnWorker = async type => {
1325
1440
  }
1326
1441
 
1327
1442
  case 'settings':
1328
- // Reload HTTP proxy agent in the main thread
1329
- if (message.data && ('httpProxyEnabled' in message.data || 'httpProxyUrl' in message.data)) {
1330
- reloadHttpProxyAgent().catch(err => logger.error({ msg: 'Failed to reload HTTP proxy agent', err }));
1331
- }
1443
+ // Reload the HTTP proxy agent in the main thread when proxy settings change
1444
+ maybeReloadHttpProxyAgent(message.data);
1332
1445
 
1333
1446
  // Forward settings changes to all IMAP workers
1334
1447
  availableIMAPWorkers.forEach(worker => {
@@ -1339,8 +1452,8 @@ let spawnWorker = async type => {
1339
1452
  }
1340
1453
  });
1341
1454
 
1342
- // Forward settings changes to webhooks, submit, and export workers
1343
- for (let type of ['webhooks', 'submit', 'export']) {
1455
+ // Forward settings changes to API, webhooks, submit, and export workers
1456
+ for (let type of ['api', 'webhooks', 'submit', 'export']) {
1344
1457
  let typeWorkers = workers.get(type);
1345
1458
  if (typeWorkers) {
1346
1459
  typeWorkers.forEach(worker => {
@@ -1499,7 +1612,8 @@ async function call(worker, message, transferList) {
1499
1612
  reject(err);
1500
1613
  }, ttl);
1501
1614
 
1502
- // Store callback info
1615
+ // Store callback info. `worker` lets us reject this entry immediately if
1616
+ // the target worker terminates, instead of waiting out the timeout.
1503
1617
  callQueue.set(mid, {
1504
1618
  resolve: result => {
1505
1619
  clearTimeout(timer);
@@ -1509,7 +1623,8 @@ async function call(worker, message, transferList) {
1509
1623
  clearTimeout(timer);
1510
1624
  reject(err);
1511
1625
  },
1512
- timer
1626
+ timer,
1627
+ worker
1513
1628
  });
1514
1629
 
1515
1630
  try {
@@ -1850,7 +1965,7 @@ let licenseCheckHandler = async opts => {
1850
1965
  default:
1851
1966
  if (config.workers && config.workers[type]) {
1852
1967
  for (let i = 0; i < config.workers[type]; i++) {
1853
- await spawnWorker(type);
1968
+ await spawnWorker(type, workerSpawnOpts(type, i));
1854
1969
  }
1855
1970
  }
1856
1971
  }
@@ -2588,6 +2703,16 @@ async function onCommand(worker, message) {
2588
2703
  return await getThreadsInfo();
2589
2704
  }
2590
2705
 
2706
+ case 'apiWorkerScaling': {
2707
+ // Reported on /admin/internals so operators can see when EENGINE_WORKERS_API > 1
2708
+ // was requested but only one worker started, and why (reason: 'platform' | 'port').
2709
+ return {
2710
+ requested: apiWorkersRequested,
2711
+ fallback: apiWorkersRequested > 1 && !apiWorkerReusePort,
2712
+ reason: apiWorkerReusePortFallbackReason
2713
+ };
2714
+ }
2715
+
2591
2716
  case 'worker-accounts': {
2592
2717
  // Get accounts assigned to a specific worker thread
2593
2718
  const { threadId, page = 1, pageSize = 20 } = message;
@@ -3167,9 +3292,9 @@ const startApplication = async () => {
3167
3292
  authData.passwordVersion = Date.now();
3168
3293
 
3169
3294
  await settings.set('authData', authData);
3170
- logger.debug({ msg: 'Password imported', hash: preparedPassword });
3295
+ logger.debug({ msg: 'Password imported' });
3171
3296
  } catch (err) {
3172
- logger.error({ msg: 'Password import failed', hash: preparedPassword });
3297
+ logger.error({ msg: 'Password import failed', err });
3173
3298
  }
3174
3299
  }
3175
3300
 
@@ -3192,8 +3317,81 @@ const startApplication = async () => {
3192
3317
 
3193
3318
  // -- START WORKER THREADS
3194
3319
 
3195
- // Start API server first for health checks
3196
- await spawnWorker('api');
3320
+ // Start API server first for health checks.
3321
+ // Optionally run several API workers that share the listen port via SO_REUSEPORT.
3322
+ apiWorkersRequested = config.workers.api;
3323
+ // Effective count we actually start; drops to 1 if SO_REUSEPORT is unavailable. Kept as a local
3324
+ // so we don't mutate the parsed config object that is read elsewhere.
3325
+ let effectiveApiWorkers = config.workers.api;
3326
+ if (config.workers.api > 1) {
3327
+ const probe = await probeReusePort(config.api.host, config.api.port);
3328
+ apiWorkerReusePort = probe.supported;
3329
+ if (!apiWorkerReusePort) {
3330
+ // Only a first-bind EADDRINUSE/EACCES (stage 'probe') means we genuinely could not test
3331
+ // the port - a transient conflict or permission issue, which the single API worker we
3332
+ // start next will surface. A failed second bind (stage 'reuse') instead means reusePort
3333
+ // is accepted but not honored on this platform/Node (e.g. Node < 23.1), which is a
3334
+ // capability limitation rather than a port conflict, so it falls through to the
3335
+ // "not available on this platform" message below.
3336
+ if (probe.stage === 'probe' && (probe.code === 'EADDRINUSE' || probe.code === 'EACCES')) {
3337
+ apiWorkerReusePortFallbackReason = 'port';
3338
+ logger.warn({
3339
+ msg: 'Could not probe SO_REUSEPORT because the API port is unavailable; starting a single API worker',
3340
+ requested: apiWorkersRequested,
3341
+ code: probe.code,
3342
+ host: config.api.host,
3343
+ port: config.api.port
3344
+ });
3345
+ } else {
3346
+ apiWorkerReusePortFallbackReason = 'platform';
3347
+ logger.warn({
3348
+ msg: 'Multiple API workers requested but SO_REUSEPORT is not available on this platform; starting a single API worker',
3349
+ requested: apiWorkersRequested,
3350
+ code: probe.code
3351
+ });
3352
+ }
3353
+ effectiveApiWorkers = 1;
3354
+ // The /admin/internals warning banner (via the apiWorkerScaling command) tells the
3355
+ // operator only one worker started; the thread-config popover keeps showing the
3356
+ // configured EENGINE_WORKERS_API value rather than being rewritten here.
3357
+ } else {
3358
+ logger.info({ msg: 'SO_REUSEPORT is available; starting multiple API workers', workers: effectiveApiWorkers });
3359
+ }
3360
+ }
3361
+
3362
+ let apiPromises = [];
3363
+ for (let i = 0; i < effectiveApiWorkers; i++) {
3364
+ apiPromises.push(spawnWorker('api', workerSpawnOpts('api', i)));
3365
+ }
3366
+
3367
+ // Tolerate partial API-worker startup: proceed as long as at least one worker reached 'ready'
3368
+ // (the spawnWorker exit handler respawns any that failed). Only abort startup if EVERY API
3369
+ // worker failed, preserving the original single-worker "no API worker => exit" behavior.
3370
+ let apiResults = await Promise.allSettled(apiPromises);
3371
+ let apiReady = apiResults.filter(result => result.status === 'fulfilled').length;
3372
+ let apiFailed = apiResults.length - apiReady;
3373
+ if (apiFailed) {
3374
+ logger.error({
3375
+ msg: 'Some API workers failed to start; they will be respawned',
3376
+ requested: apiWorkersRequested,
3377
+ attempted: apiResults.length,
3378
+ ready: apiReady,
3379
+ failed: apiFailed,
3380
+ errors: apiResults
3381
+ .filter(result => result.status === 'rejected')
3382
+ .map(result => ({
3383
+ message: result.reason && result.reason.message,
3384
+ exitCode: result.reason && result.reason.exitCode,
3385
+ threadId: result.reason && result.reason.threadId
3386
+ }))
3387
+ });
3388
+ }
3389
+ if (!apiReady) {
3390
+ // No API worker came up at all - fatal, same as the original single-worker behavior. This
3391
+ // rejects startApplication(), which logs fatal and exits the process.
3392
+ throw new Error('No API worker thread could be started');
3393
+ }
3394
+ logger.info({ msg: 'API workers started', requested: apiWorkersRequested, ready: apiReady });
3197
3395
 
3198
3396
  // Small delay to allow API to start
3199
3397
  await new Promise(r => setTimeout(r, 100));