@platformatic/runtime 3.8.0 → 3.10.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
@@ -31,10 +31,39 @@ export type PlatformaticRuntimeConfig = {
31
31
  maxHeapTotal?: number | string;
32
32
  maxYoungGeneration?: number | string;
33
33
  };
34
- preload?: string | string[];
35
34
  dependencies?: string[];
36
35
  arguments?: string[];
36
+ env?: {
37
+ [k: string]: string;
38
+ };
39
+ envfile?: string;
40
+ sourceMaps?: boolean;
41
+ packageManager?: "npm" | "pnpm" | "yarn";
42
+ preload?: string | string[];
37
43
  nodeOptions?: string;
44
+ permissions?: {
45
+ fs?: {
46
+ read?: string[];
47
+ write?: string[];
48
+ };
49
+ };
50
+ telemetry?: {
51
+ /**
52
+ * An array of instrumentations loaded if telemetry is enabled
53
+ */
54
+ instrumentations?: (
55
+ | string
56
+ | {
57
+ package: string;
58
+ exportName?: string;
59
+ options?: {
60
+ [k: string]: unknown;
61
+ };
62
+ [k: string]: unknown;
63
+ }
64
+ )[];
65
+ [k: string]: unknown;
66
+ };
38
67
  };
39
68
  };
40
69
  };
@@ -255,6 +284,7 @@ export type PlatformaticRuntimeConfig = {
255
284
  };
256
285
  };
257
286
  plugins?: string[];
287
+ timeout?: number | string;
258
288
  };
259
289
  telemetry?: {
260
290
  enabled?: boolean | string;
@@ -343,6 +373,7 @@ export type PlatformaticRuntimeConfig = {
343
373
  timeWindowSec?: number;
344
374
  cooldownSec?: number;
345
375
  scaleIntervalSec?: number;
376
+ gracePeriod?: number;
346
377
  applications?: {
347
378
  [k: string]: {
348
379
  minWorkers?: number;
package/lib/config.js CHANGED
@@ -45,7 +45,7 @@ function raiseInvalidWorkersError (location, received, hint) {
45
45
  throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
46
46
  }
47
47
 
48
- export function autoDetectPprofCapture (config) {
48
+ export function pprofCapturePreloadPath () {
49
49
  const require = createRequire(import.meta.url)
50
50
 
51
51
  let pprofCapturePath
@@ -55,6 +55,12 @@ export function autoDetectPprofCapture (config) {
55
55
  // No-op
56
56
  }
57
57
 
58
+ return pprofCapturePath
59
+ }
60
+
61
+ export function autoDetectPprofCapture (config) {
62
+ const pprofCapturePath = pprofCapturePreloadPath()
63
+
58
64
  // Add to preload if not already present
59
65
  if (!config.preload) {
60
66
  config.preload = []
@@ -192,14 +198,7 @@ export async function transform (config, _, context) {
192
198
  config = join(entryPath, configFilename)
193
199
  }
194
200
 
195
- const application = {
196
- id,
197
- config,
198
- path: entryPath,
199
- useHttp: !!mapping.useHttp,
200
- health: mapping.health,
201
- dependencies: mapping.dependencies
202
- }
201
+ const application = { id, config, path: entryPath, ...mapping }
203
202
  const existingApplicationId = applications.findIndex(application => application.id === id)
204
203
 
205
204
  if (existingApplicationId !== -1) {
@@ -336,6 +335,13 @@ export async function transform (config, _, context) {
336
335
  // like adding other applications.
337
336
  }
338
337
 
338
+ if (config.metrics === true) {
339
+ config.metrics = {
340
+ enabled: true,
341
+ timeout: 1000
342
+ }
343
+ }
344
+
339
345
  config.applications = applications
340
346
  config.web = undefined
341
347
  config.services = undefined
@@ -126,7 +126,8 @@ export async function managementApiPlugin (app, opts) {
126
126
  const { id } = request.params
127
127
  app.log.debug('stop profiling', { id })
128
128
 
129
- const profileData = await runtime.stopApplicationProfiling(id)
129
+ const options = request.body || {}
130
+ const profileData = await runtime.stopApplicationProfiling(id, options)
130
131
  reply.type('application/octet-stream').code(200).send(profileData)
131
132
  })
132
133
 
package/lib/runtime.js CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  kTimeout,
10
10
  parseMemorySize
11
11
  } from '@platformatic/foundation'
12
- import os from 'node:os'
13
12
  import { ITC } from '@platformatic/itc'
14
13
  import fastify from 'fastify'
15
14
  import { EventEmitter, once } from 'node:events'
@@ -17,13 +16,15 @@ import { existsSync } from 'node:fs'
17
16
  import { readFile } from 'node:fs/promises'
18
17
  import { STATUS_CODES } from 'node:http'
19
18
  import { createRequire } from 'node:module'
20
- import { join } from 'node:path'
19
+ import os from 'node:os'
20
+ import { dirname, isAbsolute, join } from 'node:path'
21
21
  import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
22
22
  import { pathToFileURL } from 'node:url'
23
23
  import { Worker } from 'node:worker_threads'
24
24
  import SonicBoom from 'sonic-boom'
25
25
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
26
26
  import { createThreadInterceptor } from 'undici-thread-interceptor'
27
+ import { pprofCapturePreloadPath } from './config.js'
27
28
  import {
28
29
  ApplicationAlreadyStartedError,
29
30
  ApplicationNotFoundError,
@@ -40,12 +41,12 @@ import {
40
41
  import { abstractLogger, createLogger } from './logger.js'
41
42
  import { startManagementApi } from './management-api.js'
42
43
  import { startPrometheusServer } from './prom-server.js'
44
+ import ScalingAlgorithm from './scaling-algorithm.js'
43
45
  import { startScheduler } from './scheduler.js'
44
46
  import { createSharedStore } from './shared-http-cache.js'
45
47
  import { version } from './version.js'
46
48
  import { sendViaITC, waitEventFromITC } from './worker/itc.js'
47
49
  import { RoundRobinMap } from './worker/round-robin-map.js'
48
- import ScalingAlgorithm from './scaling-algorithm.js'
49
50
  import {
50
51
  kApplicationId,
51
52
  kConfig,
@@ -57,7 +58,8 @@ import {
57
58
  kStderrMarker,
58
59
  kWorkerId,
59
60
  kWorkersBroadcast,
60
- kWorkerStatus
61
+ kWorkerStatus,
62
+ kWorkerStartTime
61
63
  } from './worker/symbols.js'
62
64
 
63
65
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
@@ -277,7 +279,7 @@ export class Runtime extends EventEmitter {
277
279
  }
278
280
 
279
281
  if (this.#config.verticalScaler?.enabled) {
280
- this.#setupVerticalScaler()
282
+ await this.#setupVerticalScaler()
281
283
  }
282
284
 
283
285
  this.#showUrl()
@@ -562,11 +564,11 @@ export class Runtime extends EventEmitter {
562
564
  return sendViaITC(service, 'startProfiling', options)
563
565
  }
564
566
 
565
- async stopApplicationProfiling (id, ensureStarted = true) {
567
+ async stopApplicationProfiling (id, options = {}, ensureStarted = true) {
566
568
  const service = await this.#getApplicationById(id, ensureStarted)
567
569
  this.#validatePprofCapturePreload()
568
570
 
569
- return sendViaITC(service, 'stopProfiling')
571
+ return sendViaITC(service, 'stopProfiling', options)
570
572
  }
571
573
 
572
574
  async updateUndiciInterceptors (undiciConfig) {
@@ -891,8 +893,12 @@ export class Runtime extends EventEmitter {
891
893
  continue
892
894
  }
893
895
 
894
- const applicationMetrics = await sendViaITC(worker, 'getMetrics', format)
895
- if (applicationMetrics) {
896
+ const applicationMetrics = await executeWithTimeout(
897
+ sendViaITC(worker, 'getMetrics', format),
898
+ this.#config.metrics?.timeout ?? 10000
899
+ )
900
+
901
+ if (applicationMetrics && applicationMetrics !== kTimeout) {
896
902
  if (metrics === null) {
897
903
  metrics = format === 'json' ? [] : ''
898
904
  }
@@ -1315,6 +1321,17 @@ export class Runtime extends EventEmitter {
1315
1321
  execArgv.push('--enable-source-maps')
1316
1322
  }
1317
1323
 
1324
+ if (applicationConfig.permissions?.fs) {
1325
+ execArgv.push(...this.#setupPermissions(applicationConfig))
1326
+ }
1327
+
1328
+ let preload = config.preload
1329
+ if (execArgv.includes('--permission')) {
1330
+ // Remove wattpm-pprof-capture from preload since it is not supported
1331
+ const pprofCapturePath = pprofCapturePreloadPath()
1332
+ preload = preload.filter(p => p !== pprofCapturePath)
1333
+ }
1334
+
1318
1335
  const workerEnv = structuredClone(this.#env)
1319
1336
 
1320
1337
  if (applicationConfig.nodeOptions?.trim().length > 0) {
@@ -1337,7 +1354,10 @@ export class Runtime extends EventEmitter {
1337
1354
 
1338
1355
  const worker = new Worker(kWorkerFile, {
1339
1356
  workerData: {
1340
- config,
1357
+ config: {
1358
+ ...config,
1359
+ preload
1360
+ },
1341
1361
  applicationConfig: {
1342
1362
  ...applicationConfig,
1343
1363
  isProduction: this.#isProduction,
@@ -1642,6 +1662,8 @@ export class Runtime extends EventEmitter {
1642
1662
  }
1643
1663
 
1644
1664
  worker[kWorkerStatus] = 'started'
1665
+ worker[kWorkerStartTime] = Date.now()
1666
+
1645
1667
  this.emitAndNotify('application:worker:started', eventPayload)
1646
1668
  this.#broadcastWorkers()
1647
1669
 
@@ -2441,9 +2463,14 @@ export class Runtime extends EventEmitter {
2441
2463
  }
2442
2464
  }
2443
2465
 
2444
- #setupVerticalScaler () {
2445
- const isWorkersFixed = this.#config.workers !== undefined
2446
- if (isWorkersFixed) return
2466
+ async #setupVerticalScaler () {
2467
+ const fixedWorkersCount = this.#config.workers
2468
+ if (fixedWorkersCount !== undefined) {
2469
+ this.logger.warn(
2470
+ `Vertical scaler disabled because the "workers" configuration is set to ${fixedWorkersCount}`
2471
+ )
2472
+ return
2473
+ }
2447
2474
 
2448
2475
  const scalerConfig = this.#config.verticalScaler
2449
2476
 
@@ -2456,6 +2483,7 @@ export class Runtime extends EventEmitter {
2456
2483
  scalerConfig.minELUDiff ??= 0.2
2457
2484
  scalerConfig.scaleIntervalSec ??= 60
2458
2485
  scalerConfig.timeWindowSec ??= 60
2486
+ scalerConfig.gracePeriod ??= 30 * 1000
2459
2487
  scalerConfig.applications ??= {}
2460
2488
 
2461
2489
  const maxTotalWorkers = scalerConfig.maxTotalWorkers
@@ -2468,9 +2496,18 @@ export class Runtime extends EventEmitter {
2468
2496
  const scaleIntervalSec = scalerConfig.scaleIntervalSec
2469
2497
  const timeWindowSec = scalerConfig.timeWindowSec
2470
2498
  const applicationsConfigs = scalerConfig.applications
2499
+ const gracePeriod = scalerConfig.gracePeriod
2500
+ const healthCheckInterval = 1000
2501
+
2502
+ const initialResourcesUpdates = []
2471
2503
 
2472
2504
  for (const application of this.#config.applications) {
2473
2505
  if (application.entrypoint && !features.node.reusePort) {
2506
+ this.logger.warn(
2507
+ `The "${application.id}" application cannot be scaled because it is an entrypoint` +
2508
+ ' and the "reusePort" feature is not available in your OS.'
2509
+ )
2510
+
2474
2511
  applicationsConfigs[application.id] = {
2475
2512
  minWorkers: 1,
2476
2513
  maxWorkers: 1
@@ -2478,6 +2515,10 @@ export class Runtime extends EventEmitter {
2478
2515
  continue
2479
2516
  }
2480
2517
  if (application.workers !== undefined) {
2518
+ this.logger.warn(
2519
+ `The "${application.id}" application cannot be scaled because` +
2520
+ ` it has a fixed number of workers (${application.workers}).`
2521
+ )
2481
2522
  applicationsConfigs[application.id] = {
2482
2523
  minWorkers: application.workers,
2483
2524
  maxWorkers: application.workers
@@ -2488,12 +2529,22 @@ export class Runtime extends EventEmitter {
2488
2529
  applicationsConfigs[application.id] ??= {}
2489
2530
  applicationsConfigs[application.id].minWorkers ??= minWorkers
2490
2531
  applicationsConfigs[application.id].maxWorkers ??= maxWorkers
2532
+
2533
+ const appMinWorkers = applicationsConfigs[application.id].minWorkers
2534
+ if (appMinWorkers > 1) {
2535
+ initialResourcesUpdates.push({
2536
+ application: application.id,
2537
+ workers: minWorkers
2538
+ })
2539
+ }
2540
+ }
2541
+
2542
+ if (initialResourcesUpdates.length > 0) {
2543
+ await this.updateApplicationsResources(initialResourcesUpdates)
2491
2544
  }
2492
2545
 
2493
2546
  for (const applicationId in applicationsConfigs) {
2494
- const application = this.#config.applications.find(
2495
- app => app.id === applicationId
2496
- )
2547
+ const application = this.#config.applications.find(app => app.id === applicationId)
2497
2548
  if (!application) {
2498
2549
  delete applicationsConfigs[applicationId]
2499
2550
 
@@ -2512,18 +2563,43 @@ export class Runtime extends EventEmitter {
2512
2563
  applications: applicationsConfigs
2513
2564
  })
2514
2565
 
2515
- this.on('application:worker:health', async (healthInfo) => {
2516
- if (!healthInfo) {
2517
- this.logger.error('No health info received')
2518
- return
2519
- }
2566
+ const healthCheckTimeout = setTimeout(async () => {
2567
+ let shouldCheckForScaling = false
2568
+
2569
+ const now = Date.now()
2520
2570
 
2521
- scalingAlgorithm.addWorkerHealthInfo(healthInfo)
2571
+ for (const worker of this.#workers.values()) {
2572
+ if (
2573
+ worker[kWorkerStatus] !== 'started' ||
2574
+ worker[kWorkerStartTime] + gracePeriod > now
2575
+ ) {
2576
+ continue
2577
+ }
2578
+
2579
+ try {
2580
+ const health = await this.#getHealth(worker)
2581
+ if (!health) continue
2582
+
2583
+ scalingAlgorithm.addWorkerHealthInfo({
2584
+ workerId: worker[kId],
2585
+ applicationId: worker[kApplicationId],
2586
+ elu: health.elu
2587
+ })
2522
2588
 
2523
- if (healthInfo.currentHealth.elu > scaleUpELU) {
2589
+ if (health.elu > scaleUpELU) {
2590
+ shouldCheckForScaling = true
2591
+ }
2592
+ } catch (err) {
2593
+ this.logger.error({ err }, 'Failed to get health for worker')
2594
+ }
2595
+ }
2596
+
2597
+ if (shouldCheckForScaling) {
2524
2598
  await checkForScaling()
2525
2599
  }
2526
- })
2600
+
2601
+ healthCheckTimeout.refresh()
2602
+ }, healthCheckInterval).unref()
2527
2603
 
2528
2604
  let isScaling = false
2529
2605
  let lastScaling = 0
@@ -2548,16 +2624,16 @@ export class Runtime extends EventEmitter {
2548
2624
  const recommendations = scalingAlgorithm.getRecommendations(appsWorkersInfo)
2549
2625
  if (recommendations.length > 0) {
2550
2626
  await applyRecommendations(recommendations)
2627
+ lastScaling = Date.now()
2551
2628
  }
2552
2629
  } catch (err) {
2553
2630
  this.logger.error({ err }, 'Failed to scale applications')
2554
2631
  } finally {
2555
2632
  isScaling = false
2556
- lastScaling = Date.now()
2557
2633
  }
2558
2634
  }
2559
2635
 
2560
- const applyRecommendations = async (recommendations) => {
2636
+ const applyRecommendations = async recommendations => {
2561
2637
  const resourcesUpdates = []
2562
2638
  for (const recommendation of recommendations) {
2563
2639
  const { applicationId, workersCount, direction } = recommendation
@@ -2574,4 +2650,48 @@ export class Runtime extends EventEmitter {
2574
2650
  // Interval for periodic scaling checks
2575
2651
  setInterval(checkForScaling, scaleIntervalSec * 1000).unref()
2576
2652
  }
2653
+
2654
+ #setupPermissions (applicationConfig) {
2655
+ const argv = []
2656
+ const allows = new Set()
2657
+ const { read, write } = applicationConfig.permissions.fs
2658
+
2659
+ if (read?.length) {
2660
+ for (const p of read) {
2661
+ allows.add(`--allow-fs-read=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
2662
+ }
2663
+ }
2664
+
2665
+ if (write?.length) {
2666
+ for (const p of write) {
2667
+ allows.add(`--allow-fs-write=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
2668
+ }
2669
+ }
2670
+
2671
+ if (allows.size === 0) {
2672
+ return argv
2673
+ }
2674
+
2675
+ // We need to allow read access to the node_modules folder both at the runtime level and at the application level
2676
+ allows.add(`--allow-fs-read=${join(this.#root, 'node_modules', '*')}`)
2677
+ allows.add(`--allow-fs-read=${join(applicationConfig.path, 'node_modules', '*')}`)
2678
+
2679
+ // Since we can't really predict how dependencies are installed (symlinks, pnpm store, and so forth), we also
2680
+ // add any node_modules folder found in the ancestors of the current file
2681
+ let lastPath = import.meta.dirname
2682
+ let currentPath = import.meta.dirname
2683
+
2684
+ do {
2685
+ lastPath = currentPath
2686
+ const nodeModules = join(currentPath, 'node_modules')
2687
+ if (existsSync(nodeModules)) {
2688
+ allows.add(`--allow-fs-read=${join(nodeModules, '*')}`)
2689
+ }
2690
+
2691
+ currentPath = dirname(currentPath)
2692
+ } while (lastPath !== currentPath)
2693
+
2694
+ argv.push('--permission', ...allows)
2695
+ return argv
2696
+ }
2577
2697
  }
@@ -19,9 +19,7 @@ class ScalingAlgorithm {
19
19
  }
20
20
 
21
21
  addWorkerHealthInfo (healthInfo) {
22
- const workerId = healthInfo.id
23
- const applicationId = healthInfo.application
24
- const elu = healthInfo.currentHealth.elu
22
+ const { workerId, applicationId, elu } = healthInfo
25
23
  const timestamp = Date.now()
26
24
 
27
25
  if (!this.#appsELUs[applicationId]) {
@@ -59,6 +57,27 @@ class ScalingAlgorithm {
59
57
 
60
58
  for (const { applicationId, elu, workersCount } of appsInfo) {
61
59
  const appMinWorkers = this.#appsConfigs[applicationId]?.minWorkers ?? 1
60
+ const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
61
+
62
+ if (workersCount < appMinWorkers) {
63
+ recommendations.push({
64
+ applicationId,
65
+ workersCount: appMinWorkers,
66
+ direction: 'up'
67
+ })
68
+ totalWorkersCount += appMinWorkers - workersCount
69
+ continue
70
+ }
71
+
72
+ if (workersCount > appMaxWorkers) {
73
+ recommendations.push({
74
+ applicationId,
75
+ workersCount: appMaxWorkers,
76
+ direction: 'down'
77
+ })
78
+ totalWorkersCount -= workersCount - appMaxWorkers
79
+ continue
80
+ }
62
81
 
63
82
  if (elu < this.#scaleDownELU && workersCount > appMinWorkers) {
64
83
  recommendations.push({
@@ -75,6 +94,12 @@ class ScalingAlgorithm {
75
94
 
76
95
  const { applicationId, workersCount } = scaleUpCandidate
77
96
 
97
+ const isScaled = recommendations.some(
98
+ r => r.applicationId === applicationId &&
99
+ r.direction === 'up'
100
+ )
101
+ if (isScaled) continue
102
+
78
103
  const appMaxWorkers = this.#appsConfigs[applicationId]?.maxWorkers ?? this.#maxTotalWorkers
79
104
  if (workersCount >= appMaxWorkers) continue
80
105
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  ensureLoggableError,
3
+ executeWithTimeout,
3
4
  FileWatcher,
4
5
  kHandledError,
5
6
  listRecognizedConfigurationFiles,
@@ -24,6 +25,21 @@ function fetchApplicationUrl (application, key) {
24
25
  return getApplicationUrl(application.id)
25
26
  }
26
27
 
28
+ function handleUnhandled (app, type, err) {
29
+ const label =
30
+ workerData.worker.count > 1
31
+ ? `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
32
+ : `application "${workerData.applicationConfig.id}"`
33
+
34
+ globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type}.`)
35
+
36
+ executeWithTimeout(app?.stop(), 1000)
37
+ .catch()
38
+ .finally(() => {
39
+ process.exit(1)
40
+ })
41
+ }
42
+
27
43
  export class Controller extends EventEmitter {
28
44
  #starting
29
45
  #started
@@ -34,21 +50,13 @@ export class Controller extends EventEmitter {
34
50
  #context
35
51
  #lastELU
36
52
 
37
- constructor (
38
- appConfig,
39
- workerId,
40
- telemetryConfig,
41
- loggerConfig,
42
- serverConfig,
43
- metricsConfig,
44
- hasManagementApi,
45
- watch
46
- ) {
53
+ constructor (runtimeConfig, applicationConfig, workerId, serverConfig, metricsConfig) {
47
54
  super()
48
- this.appConfig = appConfig
49
- this.applicationId = this.appConfig.id
55
+ this.runtimeConfig = runtimeConfig
56
+ this.applicationConfig = applicationConfig
57
+ this.applicationId = this.applicationConfig.id
50
58
  this.workerId = workerId
51
- this.#watch = watch
59
+ this.#watch = !!runtimeConfig.watch
52
60
  this.#starting = false
53
61
  this.#started = false
54
62
  this.#listening = false
@@ -60,17 +68,17 @@ export class Controller extends EventEmitter {
60
68
  controller: this,
61
69
  applicationId: this.applicationId,
62
70
  workerId: this.workerId,
63
- directory: this.appConfig.path,
64
- dependencies: this.appConfig.dependencies,
65
- isEntrypoint: this.appConfig.entrypoint,
66
- isProduction: this.appConfig.isProduction,
67
- telemetryConfig,
71
+ directory: this.applicationConfig.path,
72
+ dependencies: this.applicationConfig.dependencies,
73
+ isEntrypoint: this.applicationConfig.entrypoint,
74
+ isProduction: this.applicationConfig.isProduction,
75
+ telemetryConfig: this.applicationConfig.telemetry,
76
+ loggerConfig: runtimeConfig.logger,
68
77
  metricsConfig,
69
- loggerConfig,
70
78
  serverConfig,
71
79
  worker: workerData?.worker,
72
- hasManagementApi: !!hasManagementApi,
73
- fetchApplicationUrl: fetchApplicationUrl.bind(null, appConfig)
80
+ hasManagementApi: !!runtimeConfig.managementApi,
81
+ fetchApplicationUrl: fetchApplicationUrl.bind(null, applicationConfig)
74
82
  }
75
83
  }
76
84
 
@@ -90,7 +98,7 @@ export class Controller extends EventEmitter {
90
98
  // Note: capability's init() is executed within start
91
99
  async init () {
92
100
  try {
93
- const appConfig = this.appConfig
101
+ const appConfig = this.applicationConfig
94
102
 
95
103
  if (appConfig.isProduction && !process.env.NODE_ENV) {
96
104
  process.env.NODE_ENV = 'production'
@@ -120,6 +128,10 @@ export class Controller extends EventEmitter {
120
128
  }
121
129
 
122
130
  this.#updateDispatcher()
131
+
132
+ if (this.capability.exitOnUnhandledErrors && this.runtimeConfig.exitOnUnhandledErrors) {
133
+ this.#setupHandlers()
134
+ }
123
135
  } catch (err) {
124
136
  if (err.validationErrors) {
125
137
  globalThis.platformatic.logger.error(
@@ -168,7 +180,7 @@ export class Controller extends EventEmitter {
168
180
  }
169
181
  }
170
182
 
171
- const listen = !!this.appConfig.useHttp
183
+ const listen = !!this.applicationConfig.useHttp
172
184
 
173
185
  try {
174
186
  await this.capability.start({ listen })
@@ -203,7 +215,7 @@ export class Controller extends EventEmitter {
203
215
 
204
216
  async listen () {
205
217
  // This server is not an entrypoint or already listened in start. Behave as no-op.
206
- if (!this.appConfig.entrypoint || this.appConfig.useHttp || this.#listening) {
218
+ if (!this.applicationConfig.entrypoint || this.applicationConfig.useHttp || this.#listening) {
207
219
  return
208
220
  }
209
221
 
@@ -299,4 +311,17 @@ export class Controller extends EventEmitter {
299
311
 
300
312
  setGlobalDispatcher(dispatcher)
301
313
  }
314
+
315
+ #setupHandlers () {
316
+ process.on('uncaughtException', handleUnhandled.bind(null, this, 'uncaught exception'))
317
+ process.on('unhandledRejection', handleUnhandled.bind(null, this, 'unhandled rejection'))
318
+
319
+ process.on('newListener', event => {
320
+ if (event === 'uncaughtException' || event === 'unhandledRejection') {
321
+ globalThis.platformatic.logger.warn(
322
+ `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.`
323
+ )
324
+ }
325
+ })
326
+ }
302
327
  }
package/lib/worker/itc.js CHANGED
@@ -71,7 +71,7 @@ export async function waitEventFromITC (worker, event) {
71
71
 
72
72
  export function setupITC (controller, application, dispatcher, sharedContext) {
73
73
  const logger = globalThis.platformatic.logger
74
- const messaging = new MessagingITC(controller.appConfig.id, workerData.config, logger)
74
+ const messaging = new MessagingITC(controller.applicationConfig.id, workerData.config, logger)
75
75
 
76
76
  Object.assign(globalThis.platformatic ?? {}, {
77
77
  messaging: {
@@ -82,7 +82,7 @@ export function setupITC (controller, application, dispatcher, sharedContext) {
82
82
  })
83
83
 
84
84
  const itc = new ITC({
85
- name: controller.appConfig.id + '-worker',
85
+ name: controller.applicationConfig.id + '-worker',
86
86
  port: parentPort,
87
87
  handlers: {
88
88
  async start () {
@@ -2,8 +2,6 @@ import {
2
2
  buildPinoFormatters,
3
3
  buildPinoTimestamp,
4
4
  disablePinoDirectWrite,
5
- ensureLoggableError,
6
- executeWithTimeout,
7
5
  getPrivateSymbol
8
6
  } from '@platformatic/foundation'
9
7
  import dotenv from 'dotenv'
@@ -30,21 +28,6 @@ class ForwardingEventEmitter extends EventEmitter {
30
28
  }
31
29
  }
32
30
 
33
- function handleUnhandled (app, type, err) {
34
- const label =
35
- workerData.worker.count > 1
36
- ? `worker ${workerData.worker.index} of the application "${workerData.applicationConfig.id}"`
37
- : `application "${workerData.applicationConfig.id}"`
38
-
39
- globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${type}.`)
40
-
41
- executeWithTimeout(app?.stop(), 1000)
42
- .catch()
43
- .finally(() => {
44
- process.exit(1)
45
- })
46
- }
47
-
48
31
  function patchLogging () {
49
32
  disablePinoDirectWrite()
50
33
 
@@ -113,16 +96,16 @@ async function main () {
113
96
  events: new ForwardingEventEmitter()
114
97
  })
115
98
 
116
- const config = workerData.config
99
+ const runtimeConfig = workerData.config
117
100
 
118
- await performPreloading(config, workerData.applicationConfig)
101
+ await performPreloading(runtimeConfig, workerData.applicationConfig)
119
102
 
120
- const application = workerData.applicationConfig
103
+ const applicationConfig = workerData.applicationConfig
121
104
 
122
105
  // Load env file and mixin env vars from application config
123
106
  let envfile
124
- if (application.envfile) {
125
- envfile = resolve(workerData.dirname, application.envfile)
107
+ if (applicationConfig.envfile) {
108
+ envfile = resolve(workerData.dirname, applicationConfig.envfile)
126
109
  } else {
127
110
  envfile = resolve(workerData.applicationConfig.path, '.env')
128
111
  }
@@ -133,20 +116,20 @@ async function main () {
133
116
  path: envfile
134
117
  })
135
118
 
136
- if (config.env) {
137
- Object.assign(process.env, config.env)
119
+ if (runtimeConfig.env) {
120
+ Object.assign(process.env, runtimeConfig.env)
138
121
  }
139
- if (application.env) {
140
- Object.assign(process.env, application.env)
122
+ if (applicationConfig.env) {
123
+ Object.assign(process.env, applicationConfig.env)
141
124
  }
142
125
 
143
- const { threadDispatcher } = await setDispatcher(config)
126
+ const { threadDispatcher } = await setDispatcher(runtimeConfig)
144
127
 
145
128
  // If the application is an entrypoint and runtime server config is defined, use it.
146
129
  let serverConfig = null
147
- if (config.server && application.entrypoint) {
148
- serverConfig = config.server
149
- } else if (application.useHttp) {
130
+ if (runtimeConfig.server && applicationConfig.entrypoint) {
131
+ serverConfig = runtimeConfig.server
132
+ } else if (applicationConfig.useHttp) {
150
133
  serverConfig = {
151
134
  port: 0,
152
135
  hostname: '127.0.0.1',
@@ -169,48 +152,32 @@ async function main () {
169
152
  const res = await fetch(url)
170
153
  const [{ devtoolsFrontendUrl }] = await res.json()
171
154
 
172
- console.log(`For ${application.id} debugger open the following in chrome: "${devtoolsFrontendUrl}"`)
155
+ console.log(`For ${applicationConfig.id} debugger open the following in chrome: "${devtoolsFrontendUrl}"`)
173
156
  }
174
157
 
175
158
  // Create the application
176
159
  // Add idLabel to metrics config to determine which label name to use (defaults to applicationId)
177
- const metricsConfig = config.metrics
160
+ const metricsConfig = runtimeConfig.metrics
178
161
  ? {
179
- ...config.metrics,
180
- idLabel: config.metrics.applicationLabel || 'applicationId'
162
+ ...runtimeConfig.metrics,
163
+ idLabel: runtimeConfig.metrics.applicationLabel || 'applicationId'
181
164
  }
182
- : config.metrics
165
+ : runtimeConfig.metrics
183
166
 
184
167
  const controller = new Controller(
185
- application,
168
+ runtimeConfig,
169
+ applicationConfig,
186
170
  workerData.worker.count > 1 ? workerData.worker.index : undefined,
187
- application.telemetry,
188
- config.logger,
189
171
  serverConfig,
190
- metricsConfig,
191
- !!config.managementApi,
192
- !!config.watch
172
+ metricsConfig
193
173
  )
194
174
 
195
- if (config.exitOnUnhandledErrors) {
196
- process.on('uncaughtException', handleUnhandled.bind(null, controller, 'uncaught exception'))
197
- process.on('unhandledRejection', handleUnhandled.bind(null, controller, 'unhandled rejection'))
198
-
199
- process.on('newListener', event => {
200
- if (event === 'uncaughtException' || event === 'unhandledRejection') {
201
- globalThis.platformatic.logger.warn(
202
- `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.`
203
- )
204
- }
205
- })
206
- }
207
-
208
175
  await controller.init()
209
176
 
210
- if (application.entrypoint && config.basePath) {
177
+ if (applicationConfig.entrypoint && runtimeConfig.basePath) {
211
178
  const meta = await controller.capability.getMeta()
212
179
  if (!meta.gateway.wantsAbsoluteUrls) {
213
- stripBasePath(config.basePath)
180
+ stripBasePath(runtimeConfig.basePath)
214
181
  }
215
182
  }
216
183
 
@@ -222,7 +189,7 @@ async function main () {
222
189
  }
223
190
 
224
191
  // Setup interaction with parent port
225
- const itc = setupITC(controller, application, threadDispatcher, sharedContext)
192
+ const itc = setupITC(controller, applicationConfig, threadDispatcher, sharedContext)
226
193
  globalThis[kITC] = itc
227
194
  globalThis.platformatic.itc = itc
228
195
 
@@ -6,6 +6,7 @@ export const kWorkerId = Symbol.for('plt.runtime.worker.id')
6
6
  export const kITC = Symbol.for('plt.runtime.itc')
7
7
  export const kHealthCheckTimer = Symbol.for('plt.runtime.worker.healthCheckTimer')
8
8
  export const kWorkerStatus = Symbol('plt.runtime.worker.status')
9
+ export const kWorkerStartTime = Symbol.for('plt.runtime.worker.startTime')
9
10
  export const kInterceptors = Symbol.for('plt.runtime.worker.interceptors')
10
11
  export const kLastELU = Symbol.for('plt.runtime.worker.lastELU')
11
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.8.0",
3
+ "version": "3.10.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "@fastify/express": "^4.0.0",
20
20
  "@fastify/formbody": "^8.0.0",
21
21
  "autocannon": "^8.0.0",
22
+ "atomic-sleep": "^1.0.0",
22
23
  "c8": "^10.0.0",
23
24
  "cleaner-spec-reporter": "^0.5.0",
24
25
  "eslint": "9",
@@ -34,14 +35,14 @@
34
35
  "typescript": "^5.5.4",
35
36
  "undici-oidc-interceptor": "^0.5.0",
36
37
  "why-is-node-running": "^2.2.2",
37
- "@platformatic/composer": "3.8.0",
38
- "@platformatic/db": "3.8.0",
39
- "@platformatic/gateway": "3.8.0",
40
- "@platformatic/node": "3.8.0",
41
- "@platformatic/sql-graphql": "3.8.0",
42
- "@platformatic/service": "3.8.0",
43
- "@platformatic/sql-mapper": "3.8.0",
44
- "@platformatic/wattpm-pprof-capture": "3.8.0"
38
+ "@platformatic/composer": "3.10.0",
39
+ "@platformatic/db": "3.10.0",
40
+ "@platformatic/node": "3.10.0",
41
+ "@platformatic/gateway": "3.10.0",
42
+ "@platformatic/service": "3.10.0",
43
+ "@platformatic/sql-graphql": "3.10.0",
44
+ "@platformatic/sql-mapper": "3.10.0",
45
+ "@platformatic/wattpm-pprof-capture": "3.10.0"
45
46
  },
46
47
  "dependencies": {
47
48
  "@fastify/accepts": "^5.0.0",
@@ -71,12 +72,12 @@
71
72
  "undici": "^7.0.0",
72
73
  "undici-thread-interceptor": "^0.14.0",
73
74
  "ws": "^8.16.0",
74
- "@platformatic/basic": "3.8.0",
75
- "@platformatic/foundation": "3.8.0",
76
- "@platformatic/itc": "3.8.0",
77
- "@platformatic/metrics": "3.8.0",
78
- "@platformatic/generators": "3.8.0",
79
- "@platformatic/telemetry": "3.8.0"
75
+ "@platformatic/basic": "3.10.0",
76
+ "@platformatic/foundation": "3.10.0",
77
+ "@platformatic/itc": "3.10.0",
78
+ "@platformatic/generators": "3.10.0",
79
+ "@platformatic/metrics": "3.10.0",
80
+ "@platformatic/telemetry": "3.10.0"
80
81
  },
81
82
  "engines": {
82
83
  "node": ">=22.19.0"
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.8.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.10.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",
@@ -171,6 +171,40 @@
171
171
  },
172
172
  "additionalProperties": false
173
173
  },
174
+ "dependencies": {
175
+ "type": "array",
176
+ "items": {
177
+ "type": "string"
178
+ },
179
+ "default": []
180
+ },
181
+ "arguments": {
182
+ "type": "array",
183
+ "items": {
184
+ "type": "string"
185
+ }
186
+ },
187
+ "env": {
188
+ "type": "object",
189
+ "additionalProperties": {
190
+ "type": "string"
191
+ }
192
+ },
193
+ "envfile": {
194
+ "type": "string"
195
+ },
196
+ "sourceMaps": {
197
+ "type": "boolean",
198
+ "default": false
199
+ },
200
+ "packageManager": {
201
+ "type": "string",
202
+ "enum": [
203
+ "npm",
204
+ "pnpm",
205
+ "yarn"
206
+ ]
207
+ },
174
208
  "preload": {
175
209
  "anyOf": [
176
210
  {
@@ -186,20 +220,66 @@
186
220
  }
187
221
  ]
188
222
  },
189
- "dependencies": {
190
- "type": "array",
191
- "items": {
192
- "type": "string"
193
- }
194
- },
195
- "arguments": {
196
- "type": "array",
197
- "items": {
198
- "type": "string"
199
- }
200
- },
201
223
  "nodeOptions": {
202
224
  "type": "string"
225
+ },
226
+ "permissions": {
227
+ "type": "object",
228
+ "properties": {
229
+ "fs": {
230
+ "type": "object",
231
+ "properties": {
232
+ "read": {
233
+ "type": "array",
234
+ "items": {
235
+ "type": "string"
236
+ }
237
+ },
238
+ "write": {
239
+ "type": "array",
240
+ "items": {
241
+ "type": "string"
242
+ }
243
+ }
244
+ },
245
+ "additionalProperties": false
246
+ }
247
+ },
248
+ "additionalProperties": false
249
+ },
250
+ "telemetry": {
251
+ "type": "object",
252
+ "properties": {
253
+ "instrumentations": {
254
+ "type": "array",
255
+ "description": "An array of instrumentations loaded if telemetry is enabled",
256
+ "items": {
257
+ "oneOf": [
258
+ {
259
+ "type": "string"
260
+ },
261
+ {
262
+ "type": "object",
263
+ "properties": {
264
+ "package": {
265
+ "type": "string"
266
+ },
267
+ "exportName": {
268
+ "type": "string"
269
+ },
270
+ "options": {
271
+ "type": "object",
272
+ "additionalProperties": true
273
+ }
274
+ },
275
+ "required": [
276
+ "package"
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ }
282
+ }
203
283
  }
204
284
  }
205
285
  }
@@ -405,6 +485,30 @@
405
485
  "nodeOptions": {
406
486
  "type": "string"
407
487
  },
488
+ "permissions": {
489
+ "type": "object",
490
+ "properties": {
491
+ "fs": {
492
+ "type": "object",
493
+ "properties": {
494
+ "read": {
495
+ "type": "array",
496
+ "items": {
497
+ "type": "string"
498
+ }
499
+ },
500
+ "write": {
501
+ "type": "array",
502
+ "items": {
503
+ "type": "string"
504
+ }
505
+ }
506
+ },
507
+ "additionalProperties": false
508
+ }
509
+ },
510
+ "additionalProperties": false
511
+ },
408
512
  "telemetry": {
409
513
  "type": "object",
410
514
  "properties": {
@@ -641,6 +745,30 @@
641
745
  "nodeOptions": {
642
746
  "type": "string"
643
747
  },
748
+ "permissions": {
749
+ "type": "object",
750
+ "properties": {
751
+ "fs": {
752
+ "type": "object",
753
+ "properties": {
754
+ "read": {
755
+ "type": "array",
756
+ "items": {
757
+ "type": "string"
758
+ }
759
+ },
760
+ "write": {
761
+ "type": "array",
762
+ "items": {
763
+ "type": "string"
764
+ }
765
+ }
766
+ },
767
+ "additionalProperties": false
768
+ }
769
+ },
770
+ "additionalProperties": false
771
+ },
644
772
  "telemetry": {
645
773
  "type": "object",
646
774
  "properties": {
@@ -877,6 +1005,30 @@
877
1005
  "nodeOptions": {
878
1006
  "type": "string"
879
1007
  },
1008
+ "permissions": {
1009
+ "type": "object",
1010
+ "properties": {
1011
+ "fs": {
1012
+ "type": "object",
1013
+ "properties": {
1014
+ "read": {
1015
+ "type": "array",
1016
+ "items": {
1017
+ "type": "string"
1018
+ }
1019
+ },
1020
+ "write": {
1021
+ "type": "array",
1022
+ "items": {
1023
+ "type": "string"
1024
+ }
1025
+ }
1026
+ },
1027
+ "additionalProperties": false
1028
+ }
1029
+ },
1030
+ "additionalProperties": false
1031
+ },
880
1032
  "telemetry": {
881
1033
  "type": "object",
882
1034
  "properties": {
@@ -1691,6 +1843,17 @@
1691
1843
  }
1692
1844
  ]
1693
1845
  }
1846
+ },
1847
+ "timeout": {
1848
+ "anyOf": [
1849
+ {
1850
+ "type": "integer"
1851
+ },
1852
+ {
1853
+ "type": "string"
1854
+ }
1855
+ ],
1856
+ "default": 10000
1694
1857
  }
1695
1858
  },
1696
1859
  "additionalProperties": false
@@ -1873,6 +2036,10 @@
1873
2036
  "type": "number",
1874
2037
  "minimum": 0
1875
2038
  },
2039
+ "gracePeriod": {
2040
+ "type": "number",
2041
+ "minimum": 0
2042
+ },
1876
2043
  "applications": {
1877
2044
  "type": "object",
1878
2045
  "additionalProperties": {