emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
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
 
@@ -106,29 +107,9 @@ const bounceClassifier = require('@postalsys/bounce-classifier');
106
107
 
107
108
  const v8 = require('node:v8');
108
109
 
109
- // Initialize Bugsnag error tracking if API key is provided
110
- const Bugsnag = require('@bugsnag/js');
111
- if (readEnvValue('BUGSNAG_API_KEY')) {
112
- Bugsnag.start({
113
- apiKey: readEnvValue('BUGSNAG_API_KEY'),
114
- appVersion: packageData.version,
115
- logger: {
116
- debug(...args) {
117
- logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
118
- },
119
- info(...args) {
120
- logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
121
- },
122
- warn(...args) {
123
- logger.warn({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
124
- },
125
- error(...args) {
126
- logger.error({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
127
- }
128
- }
129
- });
130
- logger.notifyError = Bugsnag.notify.bind(Bugsnag);
131
- }
110
+ // Initialize Sentry error tracking if a DSN is provided
111
+ const { initSentry } = require('./lib/sentry');
112
+ initSentry('main');
132
113
 
133
114
  // Import additional dependencies
134
115
  const pathlib = require('path');
@@ -148,6 +129,8 @@ const { QueueEvents } = require('bullmq');
148
129
 
149
130
  const getSecret = require('./lib/get-secret');
150
131
 
132
+ const { rejectWorkerCalls } = require('./lib/reject-worker-calls');
133
+
151
134
  const msgpack = require('msgpack5')();
152
135
 
153
136
  // Initialize default configuration values if not set
@@ -220,6 +203,10 @@ config.dbs.redis = readEnvValue('EENGINE_REDIS') || readEnvValue('REDIS_URL') ||
220
203
  config.workers.imap = getWorkerCount(readEnvValue('EENGINE_WORKERS') || config.workers.imap) || 4;
221
204
  config.workers.webhooks = Number(readEnvValue('EENGINE_WORKERS_WEBHOOKS')) || config.workers.webhooks || 1;
222
205
  config.workers.submit = Number(readEnvValue('EENGINE_WORKERS_SUBMIT')) || config.workers.submit || 1;
206
+ // API worker count. Values >1 require SO_REUSEPORT (Linux); on unsupported platforms it falls back to 1 at startup.
207
+ // Uses getWorkerCount() for parity with EENGINE_WORKERS (supports "cpus"); Math.floor avoids a fractional
208
+ // count over-spawning, Math.max keeps at least one API worker.
209
+ config.workers.api = Math.max(1, Math.floor(getWorkerCount(readEnvValue('EENGINE_WORKERS_API') || config.workers.api)));
223
210
 
224
211
  config.api.port =
225
212
  (hasEnvValue('EENGINE_PORT') && Number(readEnvValue('EENGINE_PORT'))) || (hasEnvValue('PORT') && Number(readEnvValue('PORT'))) || config.api.port;
@@ -275,6 +262,12 @@ const NO_ACTIVE_HANDLER_RESP_ERR = new Error('No active handler for requested ac
275
262
  NO_ACTIVE_HANDLER_RESP_ERR.statusCode = 503;
276
263
  NO_ACTIVE_HANDLER_RESP_ERR.code = 'WorkerNotAvailable';
277
264
 
265
+ // Shared rejection for in-flight calls whose target worker terminated mid-request.
266
+ // Reused across concurrent rejections - never attach per-call fields to this instance.
267
+ const WORKER_DIED_RESP_ERR = new Error('Worker handling the request terminated before completion. Try again.');
268
+ WORKER_DIED_RESP_ERR.statusCode = 503;
269
+ WORKER_DIED_RESP_ERR.code = 'WorkerNotAvailable';
270
+
278
271
  // Update check intervals
279
272
  const UPGRADE_CHECK_TIMEOUT = 1 * 24 * 3600 * 1000; // 24 hours
280
273
  const LICENSE_CHECK_TIMEOUT = 20 * 60 * 1000; // 20 minutes
@@ -315,6 +308,7 @@ const THREAD_NAMES = {
315
308
  */
316
309
  const THREAD_CONFIG_VALUES = {
317
310
  imap: { key: 'EENGINE_WORKERS', value: config.workers.imap },
311
+ api: { key: 'EENGINE_WORKERS_API', value: config.workers.api },
318
312
  submit: { key: 'EENGINE_WORKERS_SUBMIT', value: config.workers.submit },
319
313
  webhooks: { key: 'EENGINE_WORKERS_WEBHOOKS', value: config.workers.webhooks },
320
314
  export: { key: 'EENGINE_WORKERS_EXPORT', value: config.workers.export || 1 }
@@ -326,6 +320,97 @@ const queueEvents = {};
326
320
  // Unique run index for this server instance
327
321
  let runIndex;
328
322
 
323
+ // Whether multiple API workers may share the listen port via SO_REUSEPORT.
324
+ // Determined once at startup by probeReusePort(); false means a single API worker.
325
+ let apiWorkerReusePort = false;
326
+
327
+ // Requested API worker count (EENGINE_WORKERS_API), surfaced on /admin/internals so operators can
328
+ // see when more workers were requested than could be started. Whether we fell back to a single
329
+ // worker is derived: `apiWorkersRequested > 1 && !apiWorkerReusePort`.
330
+ let apiWorkersRequested = 1;
331
+
332
+ // Why a multi-worker request fell back to a single API worker, surfaced on /admin/internals so the
333
+ // banner can state the real cause. Set during probeReusePort():
334
+ // - 'platform': SO_REUSEPORT is unsupported on this OS/Node build (Linux with Node < 23.1, macOS, Windows)
335
+ // - 'port': the probe could not test the port (transient conflict / permission), so support is unknown
336
+ // Stays null when SO_REUSEPORT is supported or only one worker was requested.
337
+ let apiWorkerReusePortFallbackReason = null;
338
+
339
+ /**
340
+ * Probe whether SO_REUSEPORT can load-balance across multiple sockets on this
341
+ * platform. Binds two listeners to the same host:port with reusePort enabled;
342
+ * both must succeed for load balancing to work. It is unsupported on macOS,
343
+ * Windows, and Node < 23.1, where either the first bind fails outright (e.g.
344
+ * ENOTSUP) or - when the reusePort option is silently ignored (Node < 23.1) -
345
+ * the first bind succeeds as a plain bind and the second fails with EADDRINUSE.
346
+ * The `stage` field tells the caller where the probe failed so it can separate a
347
+ * genuine platform/capability limitation from a transient port conflict:
348
+ * - 'probe': the first bind failed, so the port could not even be tested. An
349
+ * EADDRINUSE/EACCES code here means the port is unavailable (transient); any
350
+ * other code means the platform rejected reusePort.
351
+ * - 'reuse': the first bind succeeded but the second did not, so reusePort is
352
+ * not honored on this platform/Node - a capability limitation, even though
353
+ * the failing code is EADDRINUSE.
354
+ * - null: SO_REUSEPORT is supported.
355
+ * @param {string} host - Bind address
356
+ * @param {number} port - Listen port
357
+ * @returns {Promise<{supported: boolean, code: (string|null), stage: (string|null)}>}
358
+ * Whether SO_REUSEPORT load balancing works, the failing bind's error code (or
359
+ * null), and which bind failed ('probe', 'reuse', or null when supported)
360
+ */
361
+ async function probeReusePort(host, port) {
362
+ const net = require('net');
363
+
364
+ const open = () =>
365
+ new Promise(resolve => {
366
+ let srv = net.createServer();
367
+ srv.once('error', err => resolve({ srv: null, code: (err && err.code) || null }));
368
+ try {
369
+ srv.listen({ host, port, reusePort: true }, () => resolve({ srv, code: null }));
370
+ } catch (err) {
371
+ resolve({ srv: null, code: (err && err.code) || null });
372
+ }
373
+ });
374
+
375
+ const close = srv =>
376
+ new Promise(resolve => {
377
+ if (!srv) {
378
+ return resolve();
379
+ }
380
+ srv.close(() => resolve());
381
+ });
382
+
383
+ let first = await open();
384
+ if (!first.srv) {
385
+ // Could not even bind the first probe socket, so the port itself could not be tested.
386
+ return { supported: false, code: first.code, stage: 'probe' };
387
+ }
388
+
389
+ let second = await open();
390
+ await close(first.srv);
391
+ await close(second.srv);
392
+
393
+ if (second.srv) {
394
+ return { supported: true, code: null, stage: null };
395
+ }
396
+
397
+ // The first bind succeeded but a second listener on the same host:port did not, so the kernel is
398
+ // not load-balancing via SO_REUSEPORT - the option was accepted/ignored but not honored (e.g.
399
+ // Linux with Node < 23.1, where reusePort is an unrecognized listen() option). This is a platform
400
+ // limitation, not a port conflict, even though the failing code is EADDRINUSE.
401
+ return { supported: false, code: second.code, stage: 'reuse' };
402
+ }
403
+
404
+ /**
405
+ * Compute per-worker spawn options. API workers receive their index and whether to
406
+ * bind with SO_REUSEPORT; other worker types need no extra workerData. Keeps the
407
+ * startup and respawn paths deriving spawn options from a single place.
408
+ * @param {string} type - Worker type
409
+ * @param {number} workerIndex - Zero-based index within the worker type
410
+ * @returns {Object|undefined} Spawn options for spawnWorker(), or undefined
411
+ */
412
+ const workerSpawnOpts = (type, workerIndex) => (type === 'api' ? { workerData: { workerIndex, reusePort: apiWorkerReusePort } } : undefined);
413
+
329
414
  // Prepared configuration handling
330
415
  let preparedSettings = false;
331
416
  const preparedSettingsString = readEnvValue('EENGINE_SETTINGS') || config.settings;
@@ -682,7 +767,7 @@ let updateServerState = async (type, state, payload) => {
682
767
  for (let worker of workers.get('api')) {
683
768
  let callPayload = {
684
769
  cmd: 'change',
685
- type: '${type}ServerState',
770
+ type: `${type}ServerState`,
686
771
  key: state,
687
772
  payload: payload || null
688
773
  };
@@ -930,9 +1015,11 @@ async function sendWebhook(account, event, data) {
930
1015
  /**
931
1016
  * Spawn a new worker thread of the specified type
932
1017
  * @param {string} type - Worker type (imap, api, webhooks, submit, documents, smtp, imapProxy)
1018
+ * @param {Object} [opts] - Optional spawn options
1019
+ * @param {Object} [opts.workerData] - Data passed to the worker thread (e.g. API worker index)
933
1020
  * @returns {Promise<number|void>} Thread ID if successful
934
1021
  */
935
- let spawnWorker = async type => {
1022
+ let spawnWorker = async (type, opts) => {
936
1023
  // Don't spawn workers during shutdown
937
1024
  if (isClosing) {
938
1025
  return;
@@ -966,6 +1053,7 @@ let spawnWorker = async type => {
966
1053
  let worker = new WorkerThread(pathlib.join(__dirname, 'workers', `${type.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}.js`), {
967
1054
  argv,
968
1055
  env: SHARE_ENV,
1056
+ workerData: opts && opts.workerData,
969
1057
  trackUnmanagedFds: true
970
1058
  });
971
1059
  metrics.threadStarts.inc();
@@ -1013,6 +1101,13 @@ let spawnWorker = async type => {
1013
1101
  onlineWorkers.delete(worker);
1014
1102
  metrics.threadStops.inc();
1015
1103
 
1104
+ // Fail any in-flight calls routed to this worker right away, so callers
1105
+ // get a fast retryable error instead of hanging until their own timeout.
1106
+ let rejectedCalls = rejectWorkerCalls(callQueue, worker, WORKER_DIED_RESP_ERR);
1107
+ if (rejectedCalls) {
1108
+ logger.info({ msg: 'Rejected in-flight calls for exited worker', type, threadId: worker.threadId, rejectedCalls });
1109
+ }
1110
+
1016
1111
  workers.get(type).delete(worker);
1017
1112
 
1018
1113
  // Update server state for proxy servers
@@ -1117,9 +1212,9 @@ let spawnWorker = async type => {
1117
1212
  logger.error({ msg: 'Worker unexpectedly exited', exitCode, type });
1118
1213
  }
1119
1214
 
1120
- // Respawn worker after delay
1215
+ // Respawn worker after delay, preserving any spawn options (e.g. API worker index)
1121
1216
  await new Promise(r => setTimeout(r, 1000));
1122
- await spawnWorker(type);
1217
+ await spawnWorker(type, opts);
1123
1218
  };
1124
1219
 
1125
1220
  // Handle worker exit
@@ -1325,10 +1420,8 @@ let spawnWorker = async type => {
1325
1420
  }
1326
1421
 
1327
1422
  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
- }
1423
+ // Reload the HTTP proxy agent in the main thread when proxy settings change
1424
+ maybeReloadHttpProxyAgent(message.data);
1332
1425
 
1333
1426
  // Forward settings changes to all IMAP workers
1334
1427
  availableIMAPWorkers.forEach(worker => {
@@ -1339,8 +1432,8 @@ let spawnWorker = async type => {
1339
1432
  }
1340
1433
  });
1341
1434
 
1342
- // Forward settings changes to webhooks, submit, and export workers
1343
- for (let type of ['webhooks', 'submit', 'export']) {
1435
+ // Forward settings changes to API, webhooks, submit, and export workers
1436
+ for (let type of ['api', 'webhooks', 'submit', 'export']) {
1344
1437
  let typeWorkers = workers.get(type);
1345
1438
  if (typeWorkers) {
1346
1439
  typeWorkers.forEach(worker => {
@@ -1499,7 +1592,8 @@ async function call(worker, message, transferList) {
1499
1592
  reject(err);
1500
1593
  }, ttl);
1501
1594
 
1502
- // Store callback info
1595
+ // Store callback info. `worker` lets us reject this entry immediately if
1596
+ // the target worker terminates, instead of waiting out the timeout.
1503
1597
  callQueue.set(mid, {
1504
1598
  resolve: result => {
1505
1599
  clearTimeout(timer);
@@ -1509,7 +1603,8 @@ async function call(worker, message, transferList) {
1509
1603
  clearTimeout(timer);
1510
1604
  reject(err);
1511
1605
  },
1512
- timer
1606
+ timer,
1607
+ worker
1513
1608
  });
1514
1609
 
1515
1610
  try {
@@ -1850,11 +1945,16 @@ let licenseCheckHandler = async opts => {
1850
1945
  default:
1851
1946
  if (config.workers && config.workers[type]) {
1852
1947
  for (let i = 0; i < config.workers[type]; i++) {
1853
- await spawnWorker(type);
1948
+ await spawnWorker(type, workerSpawnOpts(type, i));
1854
1949
  }
1855
1950
  }
1856
1951
  }
1857
1952
  }
1953
+
1954
+ // Workers were respawned after license activation. Assign any accounts that
1955
+ // accumulated in the unassigned set while workers were suspended, as the
1956
+ // worker ready handler only reassigns after crashes (reassignmentPending)
1957
+ assignAccounts().catch(err => logger.error({ msg: 'Unable to assign accounts after license activation', err }));
1858
1958
  }
1859
1959
  } finally {
1860
1960
  checkingLicense = false;
@@ -2588,6 +2688,16 @@ async function onCommand(worker, message) {
2588
2688
  return await getThreadsInfo();
2589
2689
  }
2590
2690
 
2691
+ case 'apiWorkerScaling': {
2692
+ // Reported on /admin/internals so operators can see when EENGINE_WORKERS_API > 1
2693
+ // was requested but only one worker started, and why (reason: 'platform' | 'port').
2694
+ return {
2695
+ requested: apiWorkersRequested,
2696
+ fallback: apiWorkersRequested > 1 && !apiWorkerReusePort,
2697
+ reason: apiWorkerReusePortFallbackReason
2698
+ };
2699
+ }
2700
+
2591
2701
  case 'worker-accounts': {
2592
2702
  // Get accounts assigned to a specific worker thread
2593
2703
  const { threadId, page = 1, pageSize = 20 } = message;
@@ -3192,8 +3302,81 @@ const startApplication = async () => {
3192
3302
 
3193
3303
  // -- START WORKER THREADS
3194
3304
 
3195
- // Start API server first for health checks
3196
- await spawnWorker('api');
3305
+ // Start API server first for health checks.
3306
+ // Optionally run several API workers that share the listen port via SO_REUSEPORT.
3307
+ apiWorkersRequested = config.workers.api;
3308
+ // Effective count we actually start; drops to 1 if SO_REUSEPORT is unavailable. Kept as a local
3309
+ // so we don't mutate the parsed config object that is read elsewhere.
3310
+ let effectiveApiWorkers = config.workers.api;
3311
+ if (config.workers.api > 1) {
3312
+ const probe = await probeReusePort(config.api.host, config.api.port);
3313
+ apiWorkerReusePort = probe.supported;
3314
+ if (!apiWorkerReusePort) {
3315
+ // Only a first-bind EADDRINUSE/EACCES (stage 'probe') means we genuinely could not test
3316
+ // the port - a transient conflict or permission issue, which the single API worker we
3317
+ // start next will surface. A failed second bind (stage 'reuse') instead means reusePort
3318
+ // is accepted but not honored on this platform/Node (e.g. Node < 23.1), which is a
3319
+ // capability limitation rather than a port conflict, so it falls through to the
3320
+ // "not available on this platform" message below.
3321
+ if (probe.stage === 'probe' && (probe.code === 'EADDRINUSE' || probe.code === 'EACCES')) {
3322
+ apiWorkerReusePortFallbackReason = 'port';
3323
+ logger.warn({
3324
+ msg: 'Could not probe SO_REUSEPORT because the API port is unavailable; starting a single API worker',
3325
+ requested: apiWorkersRequested,
3326
+ code: probe.code,
3327
+ host: config.api.host,
3328
+ port: config.api.port
3329
+ });
3330
+ } else {
3331
+ apiWorkerReusePortFallbackReason = 'platform';
3332
+ logger.warn({
3333
+ msg: 'Multiple API workers requested but SO_REUSEPORT is not available on this platform; starting a single API worker',
3334
+ requested: apiWorkersRequested,
3335
+ code: probe.code
3336
+ });
3337
+ }
3338
+ effectiveApiWorkers = 1;
3339
+ // The /admin/internals warning banner (via the apiWorkerScaling command) tells the
3340
+ // operator only one worker started; the thread-config popover keeps showing the
3341
+ // configured EENGINE_WORKERS_API value rather than being rewritten here.
3342
+ } else {
3343
+ logger.info({ msg: 'SO_REUSEPORT is available; starting multiple API workers', workers: effectiveApiWorkers });
3344
+ }
3345
+ }
3346
+
3347
+ let apiPromises = [];
3348
+ for (let i = 0; i < effectiveApiWorkers; i++) {
3349
+ apiPromises.push(spawnWorker('api', workerSpawnOpts('api', i)));
3350
+ }
3351
+
3352
+ // Tolerate partial API-worker startup: proceed as long as at least one worker reached 'ready'
3353
+ // (the spawnWorker exit handler respawns any that failed). Only abort startup if EVERY API
3354
+ // worker failed, preserving the original single-worker "no API worker => exit" behavior.
3355
+ let apiResults = await Promise.allSettled(apiPromises);
3356
+ let apiReady = apiResults.filter(result => result.status === 'fulfilled').length;
3357
+ let apiFailed = apiResults.length - apiReady;
3358
+ if (apiFailed) {
3359
+ logger.error({
3360
+ msg: 'Some API workers failed to start; they will be respawned',
3361
+ requested: apiWorkersRequested,
3362
+ attempted: apiResults.length,
3363
+ ready: apiReady,
3364
+ failed: apiFailed,
3365
+ errors: apiResults
3366
+ .filter(result => result.status === 'rejected')
3367
+ .map(result => ({
3368
+ message: result.reason && result.reason.message,
3369
+ exitCode: result.reason && result.reason.exitCode,
3370
+ threadId: result.reason && result.reason.threadId
3371
+ }))
3372
+ });
3373
+ }
3374
+ if (!apiReady) {
3375
+ // No API worker came up at all - fatal, same as the original single-worker behavior. This
3376
+ // rejects startApplication(), which logs fatal and exits the process.
3377
+ throw new Error('No API worker thread could be started');
3378
+ }
3379
+ logger.info({ msg: 'API workers started', requested: apiWorkersRequested, ready: apiReady });
3197
3380
 
3198
3381
  // Small delay to allow API to start
3199
3382
  await new Promise(r => setTimeout(r, 100));