@platformatic/runtime 3.56.0 → 3.58.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.
package/config.d.ts CHANGED
@@ -221,7 +221,7 @@ export type PlatformaticRuntimeConfig = {
221
221
  reuseTcpPorts?: boolean;
222
222
  startTimeout?: number;
223
223
  restartOnError?: boolean | number;
224
- exitOnUnhandledErrors?: boolean;
224
+ exitOnUnhandledErrors?: boolean | number;
225
225
  gracefulShutdown?: {
226
226
  runtime: number | string;
227
227
  application: number | string;
@@ -243,6 +243,7 @@ export type PlatformaticRuntimeConfig = {
243
243
  bufferPoolSize?: number | string;
244
244
  defaultHighWaterMark?: number | string;
245
245
  };
246
+ healthProbes?: boolean | string;
246
247
  undici?: {
247
248
  agentOptions?: {
248
249
  [k: string]: unknown;
@@ -333,6 +334,33 @@ export type PlatformaticRuntimeConfig = {
333
334
  enabled?: boolean | string;
334
335
  hostname?: string;
335
336
  endpoint?: string;
337
+ https?: {
338
+ allowHTTP1?: boolean;
339
+ key:
340
+ | string
341
+ | {
342
+ path?: string;
343
+ }
344
+ | (
345
+ | string
346
+ | {
347
+ path?: string;
348
+ }
349
+ )[];
350
+ cert:
351
+ | string
352
+ | {
353
+ path?: string;
354
+ }
355
+ | (
356
+ | string
357
+ | {
358
+ path?: string;
359
+ }
360
+ )[];
361
+ requestCert?: boolean;
362
+ rejectUnauthorized?: boolean;
363
+ };
336
364
  auth?: {
337
365
  username: string;
338
366
  password: string;
@@ -471,6 +499,14 @@ export type PlatformaticRuntimeConfig = {
471
499
  * The path to write the traces to. Only for file exporter.
472
500
  */
473
501
  path?: string;
502
+ /**
503
+ * The OTLP transport protocol to use. Only for the otlp exporter. Defaults to http.
504
+ */
505
+ protocol?: "http" | "grpc";
506
+ /**
507
+ * Alias for protocol. Only for the otlp exporter. Defaults to http.
508
+ */
509
+ transport?: "http" | "grpc";
474
510
  [k: string]: unknown;
475
511
  };
476
512
  additionalProperties?: never;
@@ -496,6 +532,14 @@ export type PlatformaticRuntimeConfig = {
496
532
  * The path to write the traces to. Only for file exporter.
497
533
  */
498
534
  path?: string;
535
+ /**
536
+ * The OTLP transport protocol to use. Only for the otlp exporter. Defaults to http.
537
+ */
538
+ protocol?: "http" | "grpc";
539
+ /**
540
+ * Alias for protocol. Only for the otlp exporter. Defaults to http.
541
+ */
542
+ transport?: "http" | "grpc";
499
543
  [k: string]: unknown;
500
544
  };
501
545
  additionalProperties?: never;
package/lib/generator.js CHANGED
@@ -268,8 +268,9 @@ export class RuntimeGenerator extends BaseGenerator {
268
268
  this.questions.push({
269
269
  type: 'input',
270
270
  name: 'port',
271
- default: 3042,
272
- message: 'What port do you want to use?'
271
+ default: '3042',
272
+ message: 'What port do you want to use?',
273
+ filter: Number
273
274
  })
274
275
  }
275
276
  }
package/lib/logger.js CHANGED
@@ -1,4 +1,7 @@
1
- import { buildPinoFormatters, buildPinoTimestamp } from '@platformatic/foundation'
1
+ import {
2
+ buildPinoFormatters,
3
+ buildPinoTimestamp,
4
+ } from '@platformatic/foundation'
2
5
  import { isatty } from 'node:tty'
3
6
  import pino from 'pino'
4
7
  import pretty from 'pino-pretty'
@@ -158,6 +161,12 @@ export async function createLogger (config) {
158
161
  let cliStream
159
162
 
160
163
  if (config.logger.transport) {
164
+ if (Array.isArray(config.logger.transport.targets)) {
165
+ for (const target of config.logger.transport.targets) {
166
+ target.level ??= config.logger.level
167
+ }
168
+ }
169
+
161
170
  cliStream = pino.transport(config.logger.transport)
162
171
  } else if ((process.env.FORCE_TTY || isatty(1)) && process.env.PLT_PRETTY_PRINT !== 'false') {
163
172
  cliStream = createPrettifier(context)
@@ -1,6 +1,6 @@
1
1
  import fastifyAccepts from '@fastify/accepts'
2
2
  import fastifyBasicAuth from '@fastify/basic-auth'
3
- import { loadModule } from '@platformatic/foundation'
3
+ import { loadModule, sanitizeHTTPSOptions } from '@platformatic/foundation'
4
4
  import fastify from 'fastify'
5
5
  import { createRequire } from 'node:module'
6
6
  import { resolve } from 'node:path'
@@ -76,16 +76,20 @@ async function checkLiveness (runtime) {
76
76
  return { response, status }
77
77
  }
78
78
 
79
- export async function startPrometheusServer (runtime, opts) {
80
- if (opts.enabled === false) {
79
+ export async function startPrometheusServer (runtime, opts, healthProbes) {
80
+ const metricsEnabled = opts.enabled !== false
81
+ const healthProbesEnabled = healthProbes !== false
82
+
83
+ if (!metricsEnabled && !healthProbesEnabled) {
81
84
  return
82
85
  }
83
86
  const host = opts.hostname ?? DEFAULT_HOSTNAME
84
87
  const port = opts.port ?? DEFAULT_PORT
85
88
  const metricsEndpoint = opts.endpoint ?? DEFAULT_METRICS_ENDPOINT
86
89
  const auth = opts.auth ?? null
90
+ const https = await sanitizeHTTPSOptions(opts.https)
87
91
 
88
- const promServer = fastify({ name: 'Prometheus server', loggerInstance: runtime.logger })
92
+ const promServer = fastify({ name: 'Prometheus server', loggerInstance: runtime.logger, https })
89
93
  promServer.register(fastifyAccepts)
90
94
 
91
95
  let onRequestHook
@@ -112,13 +116,17 @@ export async function startPrometheusServer (runtime, opts) {
112
116
  logLevel: 'warn',
113
117
  handler (req, reply) {
114
118
  reply.type('text/plain')
115
- let response = `Hello from Platformatic Prometheus Server!\nThe metrics are available at ${metricsEndpoint}.`
119
+ let response = 'Hello from Platformatic Prometheus Server!'
120
+
121
+ if (metricsEnabled) {
122
+ response += `\nThe metrics are available at ${metricsEndpoint}.`
123
+ }
116
124
 
117
- if (opts.readiness !== false) {
125
+ if (healthProbesEnabled && opts.readiness !== false) {
118
126
  response += `\nThe readiness endpoint is available at ${readinessEndpoint}.`
119
127
  }
120
128
 
121
- if (opts.liveness !== false) {
129
+ if (healthProbesEnabled && opts.liveness !== false) {
122
130
  response += `\nThe liveness endpoint is available at ${livenessEndpoint}.`
123
131
  }
124
132
 
@@ -126,22 +134,24 @@ export async function startPrometheusServer (runtime, opts) {
126
134
  }
127
135
  })
128
136
 
129
- promServer.route({
130
- url: metricsEndpoint,
131
- method: 'GET',
132
- logLevel: 'warn',
133
- onRequest: onRequestHook,
134
- handler: async (req, reply) => {
135
- const accepts = req.accepts()
136
- const reqType = !accepts.type('text/plain') && accepts.type('application/json') ? 'json' : 'text'
137
- if (reqType === 'text') {
138
- reply.type('text/plain')
137
+ if (metricsEnabled) {
138
+ promServer.route({
139
+ url: metricsEndpoint,
140
+ method: 'GET',
141
+ logLevel: 'warn',
142
+ onRequest: onRequestHook,
143
+ handler: async (req, reply) => {
144
+ const accepts = req.accepts()
145
+ const reqType = !accepts.type('text/plain') && accepts.type('application/json') ? 'json' : 'text'
146
+ if (reqType === 'text') {
147
+ reply.type('text/plain')
148
+ }
149
+ return (await runtime.getMetrics(reqType)).metrics
139
150
  }
140
- return (await runtime.getMetrics(reqType)).metrics
141
- }
142
- })
151
+ })
152
+ }
143
153
 
144
- if (opts.readiness !== false) {
154
+ if (healthProbesEnabled && opts.readiness !== false) {
145
155
  const successStatusCode = opts.readiness?.success?.statusCode ?? DEFAULT_READINESS_SUCCESS_STATUS_CODE
146
156
  const successBody = opts.readiness?.success?.body ?? DEFAULT_READINESS_SUCCESS_BODY
147
157
  const failStatusCode = opts.readiness?.fail?.statusCode ?? DEFAULT_READINESS_FAIL_STATUS_CODE
@@ -180,7 +190,7 @@ export async function startPrometheusServer (runtime, opts) {
180
190
  })
181
191
  }
182
192
 
183
- if (opts.liveness !== false) {
193
+ if (healthProbesEnabled && opts.liveness !== false) {
184
194
  const successStatusCode = opts.liveness?.success?.statusCode ?? DEFAULT_LIVENESS_SUCCESS_STATUS_CODE
185
195
  const successBody = opts.liveness?.success?.body ?? DEFAULT_LIVENESS_SUCCESS_BODY
186
196
  const failStatusCode = opts.liveness?.fail?.statusCode ?? DEFAULT_LIVENESS_FAIL_STATUS_CODE
package/lib/runtime.js CHANGED
@@ -143,6 +143,7 @@ export class Runtime extends EventEmitter {
143
143
  #restartingApplications
144
144
  #restartingWorkers
145
145
  #dynamicWorkersScaler
146
+ #nextWorkerIndex
146
147
 
147
148
  #sharedHttpCache
148
149
  #scheduler
@@ -178,6 +179,7 @@ export class Runtime extends EventEmitter {
178
179
  this.#status = undefined
179
180
  this.#restartingApplications = new Set()
180
181
  this.#restartingWorkers = new Map()
182
+ this.#nextWorkerIndex = new Map()
181
183
  this.#sharedHttpCache = null
182
184
  this.#applicationsConfigsPatches = new Map()
183
185
 
@@ -227,7 +229,7 @@ export class Runtime extends EventEmitter {
227
229
  if (config.metrics) {
228
230
  // Use the configured application label name for metrics (defaults to 'applicationId')
229
231
  this.#metricsLabelName = config.metrics.applicationLabel || 'applicationId'
230
- this.#prometheusServer = await startPrometheusServer(this, config.metrics)
232
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics, config.healthProbes)
231
233
  } else {
232
234
  // Default to applicationId if metrics are not configured
233
235
  this.#metricsLabelName = 'applicationId'
@@ -578,10 +580,10 @@ export class Runtime extends EventEmitter {
578
580
  }
579
581
 
580
582
  async startApplications (applications, silent = false) {
581
- // For each worker, get its dependencies from the first worker
583
+ // For each application, get its dependencies from any available worker.
582
584
  const dependencies = new Map()
583
585
  for (const applicationId of applications) {
584
- const worker = await this.#getWorkerByIdOrNext(applicationId, 0)
586
+ const worker = await this.#getWorkerByIdOrNext(applicationId)
585
587
 
586
588
  dependencies.set(applicationId, await sendViaITC(worker, 'getDependencies'))
587
589
  }
@@ -728,12 +730,13 @@ export class Runtime extends EventEmitter {
728
730
  for (let i = 0; i < workersCount; i++) {
729
731
  const workerId = workersIds[i]
730
732
  const worker = this.#workers.get(workerId)
733
+ const workerIndex = parseInt(workerId.split(':')[1], 10)
731
734
 
732
735
  if (i > 0 && config.workersRestartDelay > 0) {
733
736
  await sleep(config.workersRestartDelay)
734
737
  }
735
738
 
736
- await this.#replaceWorker(config, applicationConfig, workersCount, id, i, worker, true)
739
+ await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
737
740
  }
738
741
 
739
742
  this.#incrementApplicationRestartCount(id)
@@ -855,8 +858,8 @@ export class Runtime extends EventEmitter {
855
858
  this.#config.metrics = metricsConfig
856
859
  this.#metricsLabelName = metricsConfig?.applicationLabel || 'applicationId'
857
860
 
858
- if (metricsConfig.enabled !== false) {
859
- this.#prometheusServer = await startPrometheusServer(this, metricsConfig)
861
+ if (metricsConfig.enabled !== false || this.#config.healthProbes !== false) {
862
+ this.#prometheusServer = await startPrometheusServer(this, metricsConfig, this.#config.healthProbes)
860
863
  }
861
864
 
862
865
  const promises = []
@@ -1491,7 +1494,8 @@ export class Runtime extends EventEmitter {
1491
1494
 
1492
1495
  async getApplicationResourcesInfo (id) {
1493
1496
  const workersCount = this.#workers.getKeys(id).length
1494
- const worker = await this.#getWorkerByIdOrNext(id, 0, false, false)
1497
+ // Use round-robin to get any available worker instead of assuming index 0 exists
1498
+ const worker = await this.#getWorkerByIdOrNext(id, null, false, false)
1495
1499
  const health = worker[kConfig].health
1496
1500
 
1497
1501
  return { workers: workersCount, health }
@@ -1779,6 +1783,9 @@ export class Runtime extends EventEmitter {
1779
1783
 
1780
1784
  await executeInParallel(this.#setupWorker.bind(this), setupInvocations, this.#concurrency)
1781
1785
 
1786
+ // Initialize the next worker index counter (next index starts after initial workers)
1787
+ this.#nextWorkerIndex.set(id, workers)
1788
+
1782
1789
  await this.#dynamicWorkersScaler?.add(applicationConfig)
1783
1790
  this.emitAndNotify('application:init', id)
1784
1791
  }
@@ -2025,8 +2032,9 @@ export class Runtime extends EventEmitter {
2025
2032
  }
2026
2033
  })
2027
2034
 
2028
- // Only activate watch for the first instance
2029
- if (index === 0) {
2035
+ // Only activate watch for the first instance. Replacement workers get unique
2036
+ // indices, so preserve the listener when replacing a single-worker app.
2037
+ if (index === 0 || workersCount === 1) {
2030
2038
  // Handle applications changes
2031
2039
  // This is not purposely activated on when this.#config.watch === true
2032
2040
  // so that applications can eventually manually trigger a restart. This mechanism is current
@@ -2556,10 +2564,17 @@ export class Runtime extends EventEmitter {
2556
2564
  return `worker ${workerId} of the application "${applicationId}"`
2557
2565
  }
2558
2566
 
2559
- async #restartCrashedWorker (config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt) {
2560
- const workerId = `${id}:${index}`
2567
+ #getNextWorkerIndex (applicationId) {
2568
+ const index = this.#nextWorkerIndex.get(applicationId) ?? 0
2569
+ this.#nextWorkerIndex.set(applicationId, index + 1)
2570
+ return index
2571
+ }
2572
+
2573
+ async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt) {
2574
+ // Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
2575
+ const restartKey = `${id}:${oldIndex}`
2561
2576
 
2562
- let restartPromise = this.#restartingWorkers.get(workerId)
2577
+ let restartPromise = this.#restartingWorkers.get(restartKey)
2563
2578
  if (restartPromise) {
2564
2579
  await restartPromise
2565
2580
  return
@@ -2567,7 +2582,7 @@ export class Runtime extends EventEmitter {
2567
2582
 
2568
2583
  restartPromise = new Promise((resolve, reject) => {
2569
2584
  async function restart () {
2570
- this.#restartingWorkers.delete(workerId)
2585
+ this.#restartingWorkers.delete(restartKey)
2571
2586
 
2572
2587
  // If some processes were scheduled to restart
2573
2588
  // but the runtime is stopped, ignore it
@@ -2575,6 +2590,9 @@ export class Runtime extends EventEmitter {
2575
2590
  return
2576
2591
  }
2577
2592
 
2593
+ // Get a new unique index for the restarted worker
2594
+ const index = this.#getNextWorkerIndex(id)
2595
+
2578
2596
  try {
2579
2597
  await this.#setupWorker(config, applicationConfig, workersCount, id, index)
2580
2598
  await this.#startWorker(config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt)
@@ -2601,15 +2619,19 @@ export class Runtime extends EventEmitter {
2601
2619
  }
2602
2620
  })
2603
2621
 
2604
- this.#restartingWorkers.set(workerId, restartPromise)
2622
+ this.#restartingWorkers.set(restartKey, restartPromise)
2605
2623
  await restartPromise
2606
2624
  }
2607
2625
 
2608
- async #replaceWorker (config, applicationConfig, workersCount, applicationId, index, worker, silent) {
2609
- const workerId = `${applicationId}:${index}`
2610
- const label = this.#workerExtendedLabel(applicationId, index, workersCount)
2626
+ async #replaceWorker (config, applicationConfig, workersCount, applicationId, oldIndex, worker, silent) {
2627
+ const oldLabel = this.#workerExtendedLabel(applicationId, oldIndex, workersCount)
2611
2628
  let newWorker
2612
2629
 
2630
+ // Get a new unique index for the replacement worker
2631
+ const newIndex = this.#getNextWorkerIndex(applicationId)
2632
+ const newWorkerId = `${applicationId}:${newIndex}`
2633
+ const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
2634
+
2613
2635
  const stopBeforeStart =
2614
2636
  applicationConfig.entrypoint &&
2615
2637
  (config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
@@ -2625,16 +2647,16 @@ export class Runtime extends EventEmitter {
2625
2647
  }
2626
2648
 
2627
2649
  if (stopBeforeStart) {
2628
- await this.#removeWorker(workersCount, applicationId, index, worker, silent, label)
2650
+ await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
2629
2651
  }
2630
2652
 
2631
2653
  try {
2632
2654
  if (!silent) {
2633
- this.logger.debug(`Preparing to start a replacement for ${label} ...`)
2655
+ this.logger.debug(`Preparing to start ${newLabel} as replacement for ${oldLabel} ...`)
2634
2656
  }
2635
2657
 
2636
- // Create a new worker (use configForNewWorker which may have a pinned port for stopBeforeStart)
2637
- newWorker = await this.#setupWorker(configForNewWorker, applicationConfig, workersCount, applicationId, index, false)
2658
+ // Create a new worker with a new index, preserving any pinned port when stopBeforeStart is required.
2659
+ newWorker = await this.#setupWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false)
2638
2660
 
2639
2661
  // Make sure the runtime hasn't been stopped in the meanwhile
2640
2662
  if (this.#status !== 'started') {
@@ -2642,21 +2664,21 @@ export class Runtime extends EventEmitter {
2642
2664
  }
2643
2665
 
2644
2666
  // Add the worker to the mesh
2645
- await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, index, false, 0, newWorker, true)
2667
+ await this.#startWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false, 0, newWorker, true)
2646
2668
 
2647
2669
  // Make sure the runtime hasn't been stopped in the meanwhile
2648
2670
  if (this.#status !== 'started') {
2649
2671
  return this.#discardWorker(newWorker)
2650
2672
  }
2651
2673
 
2652
- this.#workers.set(workerId, newWorker)
2674
+ this.#workers.set(newWorkerId, newWorker)
2653
2675
  } catch (e) {
2654
2676
  newWorker?.terminate?.()
2655
2677
  throw e
2656
2678
  }
2657
2679
 
2658
2680
  if (!stopBeforeStart) {
2659
- await this.#removeWorker(workersCount, applicationId, index, worker, silent, label)
2681
+ await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
2660
2682
  }
2661
2683
  }
2662
2684
 
@@ -3145,13 +3167,16 @@ export class Runtime extends EventEmitter {
3145
3167
  await this.#updateApplicationConfigHealth(applicationId, health)
3146
3168
  }
3147
3169
 
3148
- for (let i = 0; i < currentWorkers; i++) {
3170
+ // Get actual worker keys to iterate over existing workers (snapshot to avoid mutation during iteration)
3171
+ const workerKeys = [...this.#workers.getKeys(applicationId)]
3172
+ for (const workerKey of workerKeys) {
3173
+ const workerIndex = parseInt(workerKey.split(':')[1], 10)
3149
3174
  this.logger.info(
3150
3175
  { health: { current: currentHealth, new: health } },
3151
- `Restarting application "${applicationId}" worker ${i} to update config health heap...`
3176
+ `Restarting application "${applicationId}" worker ${workerIndex} to update config health heap...`
3152
3177
  )
3153
3178
 
3154
- const worker = await this.#getWorkerByIdOrNext(applicationId, i)
3179
+ const worker = this.#workers.get(workerKey)
3155
3180
  if (health.maxHeapTotal) {
3156
3181
  worker[kConfig].health.maxHeapTotal = health.maxHeapTotal
3157
3182
  }
@@ -3159,11 +3184,11 @@ export class Runtime extends EventEmitter {
3159
3184
  worker[kConfig].health.maxYoungGeneration = health.maxYoungGeneration
3160
3185
  }
3161
3186
 
3162
- await this.#replaceWorker(config, applicationConfig, currentWorkers, applicationId, i, worker)
3163
- report.updated.push(i)
3187
+ await this.#replaceWorker(config, applicationConfig, currentWorkers, applicationId, workerIndex, worker)
3188
+ report.updated.push(workerIndex)
3164
3189
  this.logger.info(
3165
3190
  { health: { current: currentHealth, new: health } },
3166
- `Restarted application "${applicationId}" worker ${i}`
3191
+ `Restarted application "${applicationId}" worker ${workerIndex}`
3167
3192
  )
3168
3193
  }
3169
3194
  report.success = true
@@ -3196,9 +3221,10 @@ export class Runtime extends EventEmitter {
3196
3221
  report.started = []
3197
3222
  try {
3198
3223
  for (let i = currentWorkers; i < workers; i++) {
3199
- await this.#setupWorker(config, applicationConfig, workers, applicationId, i)
3200
- await this.#startWorker(config, applicationConfig, workers, applicationId, i, false, 0)
3201
- report.started.push(i)
3224
+ const newIndex = this.#getNextWorkerIndex(applicationId)
3225
+ await this.#setupWorker(config, applicationConfig, workers, applicationId, newIndex)
3226
+ await this.#startWorker(config, applicationConfig, workers, applicationId, newIndex, false, 0)
3227
+ report.started.push(newIndex)
3202
3228
  startedWorkersCount++
3203
3229
  }
3204
3230
  report.success = true
@@ -3217,11 +3243,19 @@ export class Runtime extends EventEmitter {
3217
3243
  // keep the current workers count until all the application workers are all stopped
3218
3244
  report.stopped = []
3219
3245
  try {
3220
- for (let i = currentWorkers - 1; i >= workers; i--) {
3221
- const worker = await this.#getWorkerByIdOrNext(applicationId, i, false, false)
3246
+ // Get actual worker keys and sort by index descending to stop most recent first (snapshot to avoid mutation during iteration)
3247
+ const workerKeys = [...this.#workers.getKeys(applicationId)]
3248
+ const workerIndices = workerKeys
3249
+ .map(key => parseInt(key.split(':')[1], 10))
3250
+ .sort((a, b) => b - a) // descending order
3251
+
3252
+ const workersToStop = currentWorkers - workers
3253
+ for (let i = 0; i < workersToStop && i < workerIndices.length; i++) {
3254
+ const workerIndex = workerIndices[i]
3255
+ const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
3222
3256
  await sendViaITC(worker, 'removeFromMesh')
3223
- await this.#stopWorker(currentWorkers, applicationId, i, false, worker, [])
3224
- report.stopped.push(i)
3257
+ await this.#stopWorker(currentWorkers, applicationId, workerIndex, false, worker, [])
3258
+ report.stopped.push(workerIndex)
3225
3259
  stoppedWorkersCount++
3226
3260
  }
3227
3261
  report.success = true
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ensureLoggableError,
3
- executeWithTimeout,
4
3
  FileWatcher,
5
4
  kHandledError,
6
5
  listRecognizedConfigurationFiles,
@@ -37,17 +36,24 @@ function fetchApplicationUrl (application, key) {
37
36
  return getApplicationUrl(application.id)
38
37
  }
39
38
 
40
- function handleUnhandled (app, type, err) {
39
+ function handleUnhandled (app, event, listeners, timeout, err, ...args) {
41
40
  const label = `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
42
41
 
43
42
  const logger = getLogger()
44
- logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type}.`)
43
+ logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${event} event.`)
45
44
 
46
- executeWithTimeout(app?.stop(), 1000)
47
- .catch()
48
- .finally(() => {
49
- process.exit(1)
50
- })
45
+ // Give some time to the listeners, logger and ITC notifications to land before shutting down
46
+ setTimeout(() => process.exit(1), timeout)
47
+
48
+ for (const listener of listeners) {
49
+ try {
50
+ listener(err, ...args)
51
+ } catch (err) {
52
+ logger.error({ err: ensureLoggableError(err) }, `${event} error listener failed.`)
53
+ }
54
+ }
55
+
56
+ app.stop().catch()
51
57
  }
52
58
 
53
59
  export class Controller extends EventEmitter {
@@ -74,6 +80,8 @@ export class Controller extends EventEmitter {
74
80
 
75
81
  this.#context = {
76
82
  controller: this,
83
+ runtimeConfig: this.runtimeConfig,
84
+ applicationConfig: this.applicationConfig,
77
85
  applicationId: this.applicationId,
78
86
  workerId: this.workerId,
79
87
  directory: this.applicationConfig.path,
@@ -149,16 +157,19 @@ export class Controller extends EventEmitter {
149
157
  cleanupHandlers()
150
158
  }
151
159
 
152
- if (this.capability.exitOnUnhandledErrors && this.runtimeConfig.exitOnUnhandledErrors) {
153
- this.#setupHandlers()
160
+ let exitOnUnhandledErrors = this.runtimeConfig.exitOnUnhandledErrors
161
+
162
+ if (exitOnUnhandledErrors === true || typeof exitOnUnhandledErrors === 'undefined') {
163
+ exitOnUnhandledErrors = 100
164
+ }
165
+
166
+ if (typeof exitOnUnhandledErrors === 'number' && exitOnUnhandledErrors > 0) {
167
+ this.#setupHandlers(exitOnUnhandledErrors)
154
168
  }
155
169
  } catch (err) {
156
170
  if (err.validationErrors) {
157
171
  const logger = getLogger()
158
- logger.error(
159
- { err: ensureLoggableError(err) },
160
- 'The application threw a validation error.'
161
- )
172
+ logger.error({ err: ensureLoggableError(err) }, 'The application threw a validation error.')
162
173
 
163
174
  throw err
164
175
  } else {
@@ -263,20 +274,22 @@ export class Controller extends EventEmitter {
263
274
  const onHttpStatsFree = getOnHttpStatsFree({ throwOnMissing: false })
264
275
 
265
276
  if (onHttpStatsFree && dispatcher?.stats) {
266
- const onHttpStatsConnected = getOnHttpStatsConnected()
267
- const onHttpStatsPending = getOnHttpStatsPending()
268
- const onHttpStatsQueued = getOnHttpStatsQueued()
269
- const onHttpStatsRunning = getOnHttpStatsRunning()
270
- const onHttpStatsSize = getOnHttpStatsSize()
277
+ // The capability might come from an older version of @platformatic/basic
278
+ // which registered these globals without the fields tracking, so never throw.
279
+ const onHttpStatsConnected = getOnHttpStatsConnected({ throwOnMissing: false })
280
+ const onHttpStatsPending = getOnHttpStatsPending({ throwOnMissing: false })
281
+ const onHttpStatsQueued = getOnHttpStatsQueued({ throwOnMissing: false })
282
+ const onHttpStatsRunning = getOnHttpStatsRunning({ throwOnMissing: false })
283
+ const onHttpStatsSize = getOnHttpStatsSize({ throwOnMissing: false })
271
284
 
272
285
  for (const url in dispatcher.stats) {
273
286
  const { free, connected, pending, queued, running, size } = dispatcher.stats[url]
274
287
  onHttpStatsFree(url, free || 0)
275
- onHttpStatsConnected(url, connected || 0)
276
- onHttpStatsPending(url, pending || 0)
277
- onHttpStatsQueued(url, queued || 0)
278
- onHttpStatsRunning(url, running || 0)
279
- onHttpStatsSize(url, size || 0)
288
+ onHttpStatsConnected?.(url, connected || 0)
289
+ onHttpStatsPending?.(url, pending || 0)
290
+ onHttpStatsQueued?.(url, queued || 0)
291
+ onHttpStatsRunning?.(url, running || 0)
292
+ onHttpStatsSize?.(url, size || 0)
280
293
  }
281
294
  }
282
295
  const onActiveResourcesEventLoop = getOnActiveResourcesEventLoop({ throwOnMissing: false })
@@ -359,16 +372,25 @@ export class Controller extends EventEmitter {
359
372
  mirrorGlobalDispatcherForBuiltinFetch(dispatcher)
360
373
  }
361
374
 
362
- #setupHandlers () {
363
- process.on('uncaughtException', handleUnhandled.bind(null, this, 'uncaught exception'))
364
- process.on('unhandledRejection', handleUnhandled.bind(null, this, 'unhandled rejection'))
375
+ #setupHandlers (timeout) {
376
+ const unhandledListeners = { uncaughtException: [], unhandledRejection: [] }
377
+
378
+ process.on(
379
+ 'uncaughtException',
380
+ handleUnhandled.bind(null, this, 'uncaughtException', unhandledListeners.uncaughtException, timeout)
381
+ )
382
+ process.on(
383
+ 'unhandledRejection',
384
+ handleUnhandled.bind(null, this, 'unhandledRejection', unhandledListeners.unhandledRejection, timeout)
385
+ )
365
386
 
366
- process.on('newListener', event => {
387
+ process.on('newListener', (event, listener) => {
367
388
  if (event === 'uncaughtException' || event === 'unhandledRejection') {
368
- const logger = getLogger()
369
- logger.warn(
370
- `A listener has been added for the "process.${event}" event. This listener will be never triggered as Watt default behavior will kill the process before.\n To disable this behavior, set "exitOnUnhandledErrors" to false in the runtime config.`
371
- )
389
+ unhandledListeners[event].push(listener)
390
+
391
+ process.nextTick(() => {
392
+ process.removeListener(event, listener)
393
+ })
372
394
  }
373
395
  })
374
396
  }
@@ -28,14 +28,12 @@ import { setupITC } from './itc.js'
28
28
  import { SharedContext } from './shared-context.js'
29
29
  import { kStderrMarker } from './symbols.js'
30
30
 
31
- function handlePreInitializationUnhandled (type, err) {
31
+ function handlePreInitializationUnhandled (type, timeout, err) {
32
32
  const label = `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
33
33
  const logger = getLogger()
34
- logger.error(
35
- { err: ensureLoggableError(err) },
36
- `The ${label} threw an ${type} before initialization.`
37
- )
38
- process.exit(exitCodes.PROCESS_UNHANDLED_ERROR)
34
+ logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type} before initialization.`)
35
+
36
+ setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), timeout)
39
37
  }
40
38
 
41
39
  class ForwardingEventEmitter extends EventEmitter {
@@ -68,9 +66,9 @@ function patchLogging () {
68
66
  }
69
67
  }
70
68
 
71
- function setupHandlers () {
72
- const unhandledExceptionHandler = handlePreInitializationUnhandled.bind(null, 'uncaught exception')
73
- const unhandledRejectionHandler = handlePreInitializationUnhandled.bind(null, 'uncaught rejection')
69
+ function setupHandlers (timeout) {
70
+ const unhandledExceptionHandler = handlePreInitializationUnhandled.bind(null, 'uncaught exception', timeout)
71
+ const unhandledRejectionHandler = handlePreInitializationUnhandled.bind(null, 'uncaught rejection', timeout)
74
72
  process.on('uncaughtException', unhandledExceptionHandler)
75
73
  process.on('unhandledRejection', unhandledRejectionHandler)
76
74
 
@@ -201,7 +199,16 @@ async function setupCompileCache (runtimeConfig, applicationConfig, logger) {
201
199
  }
202
200
 
203
201
  async function main () {
204
- const cleanup = setupHandlers()
202
+ let cleanup = null
203
+ let exitOnUnhandledErrors = workerData.config.exitOnUnhandledErrors
204
+
205
+ if (exitOnUnhandledErrors === true || typeof exitOnUnhandledErrors === 'undefined') {
206
+ exitOnUnhandledErrors = 100
207
+ }
208
+
209
+ if (typeof exitOnUnhandledErrors === 'number' && exitOnUnhandledErrors > 0) {
210
+ cleanup = setupHandlers(exitOnUnhandledErrors)
211
+ }
205
212
 
206
213
  installUndiciGlobals(globalThis)
207
214
  updateGlobals({
@@ -319,15 +326,14 @@ async function main () {
319
326
 
320
327
  // Setup management client for privileged applications
321
328
  if (applicationConfig.management) {
322
- const mgmtEnabled = typeof applicationConfig.management === 'boolean'
323
- ? applicationConfig.management
324
- : applicationConfig.management.enabled !== false
329
+ const mgmtEnabled =
330
+ typeof applicationConfig.management === 'boolean'
331
+ ? applicationConfig.management
332
+ : applicationConfig.management.enabled !== false
325
333
 
326
334
  if (mgmtEnabled) {
327
335
  const { ManagementClient } = await import('./management.js')
328
- const ops = typeof applicationConfig.management === 'object'
329
- ? applicationConfig.management.operations
330
- : undefined
336
+ const ops = typeof applicationConfig.management === 'object' ? applicationConfig.management.operations : undefined
331
337
  updateGlobals({ management: new ManagementClient(ops) })
332
338
  }
333
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.56.0",
3
+ "version": "3.58.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -18,9 +18,10 @@
18
18
  "@fastify/compress": "^8.0.0",
19
19
  "@fastify/express": "^4.0.0",
20
20
  "@fastify/formbody": "^8.0.0",
21
+ "@sentry/node": "^10.58.0",
21
22
  "atomic-sleep": "^1.0.0",
22
23
  "autocannon": "^8.0.0",
23
- "c8": "^10.0.0",
24
+ "c8": "^11.0.0",
24
25
  "cleaner-spec-reporter": "^0.5.0",
25
26
  "eslint": "9",
26
27
  "execa": "^9.0.0",
@@ -31,19 +32,20 @@
31
32
  "json-schema-to-typescript": "^15.0.0",
32
33
  "neostandard": "^0.12.0",
33
34
  "pino-abstract-transport": "^2.0.0",
35
+ "pino-sentry-transport": "^1.6.0",
34
36
  "split2": "^4.2.0",
35
37
  "tstyche": "^6.2.0",
36
38
  "typescript": "^5.5.4",
37
- "undici-oidc-interceptor": "^0.5.0",
39
+ "undici-oidc-interceptor": "^0.9.0",
38
40
  "why-is-node-running": "^2.2.2",
39
- "@platformatic/composer": "3.56.0",
40
- "@platformatic/db": "3.56.0",
41
- "@platformatic/service": "3.56.0",
42
- "@platformatic/gateway": "3.56.0",
43
- "@platformatic/sql-graphql": "3.56.0",
44
- "@platformatic/sql-mapper": "3.56.0",
45
- "@platformatic/node": "3.56.0",
46
- "@platformatic/wattpm-pprof-capture": "3.56.0"
41
+ "@platformatic/composer": "3.58.0",
42
+ "@platformatic/db": "3.58.0",
43
+ "@platformatic/gateway": "3.58.0",
44
+ "@platformatic/node": "3.58.0",
45
+ "@platformatic/sql-graphql": "3.58.0",
46
+ "@platformatic/service": "3.58.0",
47
+ "@platformatic/sql-mapper": "3.58.0",
48
+ "@platformatic/wattpm-pprof-capture": "3.58.0"
47
49
  },
48
50
  "dependencies": {
49
51
  "@fastify/accepts": "^5.0.0",
@@ -52,7 +54,7 @@
52
54
  "@fastify/websocket": "^11.0.0",
53
55
  "@opentelemetry/api": "^1.9.0",
54
56
  "@platformatic/prom-client": "^1.0.0",
55
- "@platformatic/undici-cache-memory": "^0.8.1",
57
+ "@platformatic/undici-cache-memory": "^0.9.0",
56
58
  "@watchable/unpromise": "^1.0.2",
57
59
  "change-case-all": "^2.1.0",
58
60
  "close-with-grace": "^2.3.0",
@@ -73,13 +75,13 @@
73
75
  "undici": "^7.27.2",
74
76
  "undici-thread-interceptor": "^1.3.1",
75
77
  "ws": "^8.16.0",
76
- "@platformatic/basic": "3.56.0",
77
- "@platformatic/generators": "3.56.0",
78
- "@platformatic/globals": "3.56.0",
79
- "@platformatic/metrics": "3.56.0",
80
- "@platformatic/itc": "3.56.0",
81
- "@platformatic/telemetry": "3.56.0",
82
- "@platformatic/foundation": "3.56.0"
78
+ "@platformatic/basic": "3.58.0",
79
+ "@platformatic/foundation": "3.58.0",
80
+ "@platformatic/generators": "3.58.0",
81
+ "@platformatic/itc": "3.58.0",
82
+ "@platformatic/metrics": "3.58.0",
83
+ "@platformatic/telemetry": "3.58.0",
84
+ "@platformatic/globals": "3.58.0"
83
85
  },
84
86
  "engines": {
85
87
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.56.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.58.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -1928,7 +1928,14 @@
1928
1928
  },
1929
1929
  "exitOnUnhandledErrors": {
1930
1930
  "default": true,
1931
- "type": "boolean"
1931
+ "anyOf": [
1932
+ {
1933
+ "type": "boolean"
1934
+ },
1935
+ {
1936
+ "type": "number"
1937
+ }
1938
+ ]
1932
1939
  },
1933
1940
  "gracefulShutdown": {
1934
1941
  "type": "object",
@@ -2110,6 +2117,17 @@
2110
2117
  },
2111
2118
  "additionalProperties": false
2112
2119
  },
2120
+ "healthProbes": {
2121
+ "anyOf": [
2122
+ {
2123
+ "type": "boolean"
2124
+ },
2125
+ {
2126
+ "type": "string"
2127
+ }
2128
+ ],
2129
+ "default": true
2130
+ },
2113
2131
  "undici": {
2114
2132
  "type": "object",
2115
2133
  "properties": {
@@ -2361,6 +2379,99 @@
2361
2379
  "endpoint": {
2362
2380
  "type": "string"
2363
2381
  },
2382
+ "https": {
2383
+ "type": "object",
2384
+ "properties": {
2385
+ "allowHTTP1": {
2386
+ "type": "boolean"
2387
+ },
2388
+ "key": {
2389
+ "anyOf": [
2390
+ {
2391
+ "type": "string"
2392
+ },
2393
+ {
2394
+ "type": "object",
2395
+ "properties": {
2396
+ "path": {
2397
+ "type": "string",
2398
+ "resolvePath": true
2399
+ }
2400
+ },
2401
+ "additionalProperties": false
2402
+ },
2403
+ {
2404
+ "type": "array",
2405
+ "items": {
2406
+ "anyOf": [
2407
+ {
2408
+ "type": "string"
2409
+ },
2410
+ {
2411
+ "type": "object",
2412
+ "properties": {
2413
+ "path": {
2414
+ "type": "string",
2415
+ "resolvePath": true
2416
+ }
2417
+ },
2418
+ "additionalProperties": false
2419
+ }
2420
+ ]
2421
+ }
2422
+ }
2423
+ ]
2424
+ },
2425
+ "cert": {
2426
+ "anyOf": [
2427
+ {
2428
+ "type": "string"
2429
+ },
2430
+ {
2431
+ "type": "object",
2432
+ "properties": {
2433
+ "path": {
2434
+ "type": "string",
2435
+ "resolvePath": true
2436
+ }
2437
+ },
2438
+ "additionalProperties": false
2439
+ },
2440
+ {
2441
+ "type": "array",
2442
+ "items": {
2443
+ "anyOf": [
2444
+ {
2445
+ "type": "string"
2446
+ },
2447
+ {
2448
+ "type": "object",
2449
+ "properties": {
2450
+ "path": {
2451
+ "type": "string",
2452
+ "resolvePath": true
2453
+ }
2454
+ },
2455
+ "additionalProperties": false
2456
+ }
2457
+ ]
2458
+ }
2459
+ }
2460
+ ]
2461
+ },
2462
+ "requestCert": {
2463
+ "type": "boolean"
2464
+ },
2465
+ "rejectUnauthorized": {
2466
+ "type": "boolean"
2467
+ }
2468
+ },
2469
+ "additionalProperties": false,
2470
+ "required": [
2471
+ "key",
2472
+ "cert"
2473
+ ]
2474
+ },
2364
2475
  "auth": {
2365
2476
  "type": "object",
2366
2477
  "properties": {
@@ -2676,6 +2787,22 @@
2676
2787
  "path": {
2677
2788
  "type": "string",
2678
2789
  "description": "The path to write the traces to. Only for file exporter."
2790
+ },
2791
+ "protocol": {
2792
+ "type": "string",
2793
+ "enum": [
2794
+ "http",
2795
+ "grpc"
2796
+ ],
2797
+ "description": "The OTLP transport protocol to use. Only for the otlp exporter. Defaults to http."
2798
+ },
2799
+ "transport": {
2800
+ "type": "string",
2801
+ "enum": [
2802
+ "http",
2803
+ "grpc"
2804
+ ],
2805
+ "description": "Alias for protocol. Only for the otlp exporter. Defaults to http."
2679
2806
  }
2680
2807
  }
2681
2808
  },
@@ -2712,6 +2839,22 @@
2712
2839
  "path": {
2713
2840
  "type": "string",
2714
2841
  "description": "The path to write the traces to. Only for file exporter."
2842
+ },
2843
+ "protocol": {
2844
+ "type": "string",
2845
+ "enum": [
2846
+ "http",
2847
+ "grpc"
2848
+ ],
2849
+ "description": "The OTLP transport protocol to use. Only for the otlp exporter. Defaults to http."
2850
+ },
2851
+ "transport": {
2852
+ "type": "string",
2853
+ "enum": [
2854
+ "http",
2855
+ "grpc"
2856
+ ],
2857
+ "description": "Alias for protocol. Only for the otlp exporter. Defaults to http."
2715
2858
  }
2716
2859
  }
2717
2860
  },