@platformatic/runtime 4.0.0-new-config.2 → 4.0.0-new-config.6

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/lib/runtime.js CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  executeInParallel,
6
6
  executeWithTimeout,
7
7
  features,
8
- kEnvFileFallbackKeys,
9
8
  kMetadata,
10
9
  kTimeout,
11
10
  parseMemorySize
@@ -25,6 +24,7 @@ import { STATUS_CODES } from 'node:http'
25
24
  import { createRequire } from 'node:module'
26
25
  import { availableParallelism } from 'node:os'
27
26
  import { basename, dirname, isAbsolute, join } from 'node:path'
27
+ import { randomUUID } from 'node:crypto'
28
28
  import { Readable } from 'node:stream'
29
29
  import { finished } from 'node:stream/promises'
30
30
  import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
@@ -32,7 +32,7 @@ import { pathToFileURL } from 'node:url'
32
32
  import { Worker } from 'node:worker_threads'
33
33
  import SonicBoom from 'sonic-boom'
34
34
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
35
- import { createThreadInterceptor } from 'undici-thread-interceptor'
35
+ import { createCoordinator, createInterceptor } from 'undici-thread-interceptor'
36
36
  import { pprofCapturePreloadPath } from './config.js'
37
37
  import {
38
38
  AddressInUseError,
@@ -53,17 +53,17 @@ import {
53
53
  MessagingError,
54
54
  MetricFamilyCollisionError,
55
55
  MissingPprofCapture,
56
+ MixedServingStateError,
56
57
  ReservedITCHandlerNameError,
57
58
  RuntimeAbortedError,
58
59
  RuntimeExtensionBuildAlreadyCalledError,
59
- WorkerInterceptorJoinTimeoutError,
60
60
  WorkerNotFoundError
61
61
  } from './errors.js'
62
62
  import { abstractLogger, createLogger } from './logger.js'
63
63
  import { startManagementApi } from './management-api.js'
64
64
  import { createManagementHandlers } from './management-handlers.js'
65
65
  import { OpenTelemetryMetricsForwarder } from './opentelemetry-metrics.js'
66
- import { createChannelCreationHook } from './policies.js'
66
+ import { createChannelCreationHook, createTargetPermissionHook } from './policies.js'
67
67
  import { startHealthProbesServer, startPrometheusServer } from './prom-server.js'
68
68
  import { startScheduler } from './scheduler.js'
69
69
  import { createSharedStore } from './shared-http-cache.js'
@@ -79,7 +79,6 @@ import {
79
79
  kFullId,
80
80
  kHealthCheckTimer,
81
81
  kId,
82
- kInterceptorReadyPromise,
83
82
  kIsSubprocessHost,
84
83
  kITC,
85
84
  kLastHealthCheckELU,
@@ -87,6 +86,7 @@ import {
87
86
  kWorkerHealthSignals,
88
87
  kWorkerId,
89
88
  kWorkerPortOffset,
89
+ kWorkerServerOptions,
90
90
  kWorkerUrl,
91
91
  kWorkersBroadcast,
92
92
  kWorkerStartTime,
@@ -176,6 +176,55 @@ const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
176
176
  const MAX_WORKERS = 100
177
177
  const DEFAULT_RESTART_ON_ERROR_DELAY = 5000
178
178
 
179
+ /*
180
+ Both public payloads are built from a snapshot and frozen through. What a consumer could observe
181
+ in v3 was scalars and a file path, so handing out interior state was harmless in practice; v4
182
+ nests resolvedConfig -- an entire capability payload -- inside every entry, and the getters read
183
+ straight off live state. A consumer mutating what it received would be editing the configuration
184
+ that later restarts and scale-up workers read, silently, and would make worker generations
185
+ disagree about what they are running. setApplicationConfigPatch exists precisely so that changing
186
+ a running application's configuration is explicit, patch-shaped and visible.
187
+
188
+ Only plain objects and arrays are copied. Anything carrying its own prototype -- a class
189
+ instance, a stream, a buffer -- is handed back as it is, because a copy of it would not be the
190
+ thing; the hazard this closes is a consumer editing configuration, not one editing a socket.
191
+ */
192
+ function frozenSnapshot (value, seen = new Map()) {
193
+ if (value === null || typeof value !== 'object') {
194
+ return value
195
+ }
196
+
197
+ if (seen.has(value)) {
198
+ return seen.get(value)
199
+ }
200
+
201
+ if (Array.isArray(value)) {
202
+ const copy = []
203
+ seen.set(value, copy)
204
+
205
+ for (const entry of value) {
206
+ copy.push(frozenSnapshot(entry, seen))
207
+ }
208
+
209
+ return Object.freeze(copy)
210
+ }
211
+
212
+ const prototype = Object.getPrototypeOf(value)
213
+
214
+ if (prototype !== Object.prototype && prototype !== null) {
215
+ return value
216
+ }
217
+
218
+ const copy = {}
219
+ seen.set(value, copy)
220
+
221
+ for (const [key, entry] of Object.entries(value)) {
222
+ copy[key] = frozenSnapshot(entry, seen)
223
+ }
224
+
225
+ return Object.freeze(copy)
226
+ }
227
+
179
228
  export class Runtime extends EventEmitter {
180
229
  logger
181
230
  error
@@ -201,11 +250,15 @@ export class Runtime extends EventEmitter {
201
250
  #healthMetricsCollectionActive
202
251
 
203
252
  #meshInterceptor
253
+ #meshCoordinator
254
+ #meshId
204
255
  #dispatcher
205
256
 
206
257
  #managementApi
207
258
  #prometheusServer
208
259
  #healthProbesServer
260
+ #metricsServersInitialized
261
+ #metricsServersStartPromise
209
262
  #opentelemetryMetricsForwarder
210
263
  #inspectorServer
211
264
  #metricsLabelName
@@ -223,6 +276,7 @@ export class Runtime extends EventEmitter {
223
276
  #extensionLivenessChecks
224
277
  #extensionHealthRoutes
225
278
  #lastOverloadProfiles
279
+ #servingStates
226
280
  #restartingApplications
227
281
  #restartingWorkers
228
282
  #workerPortOffsets
@@ -255,13 +309,7 @@ export class Runtime extends EventEmitter {
255
309
  this.#applicationRestartCounts = new Map()
256
310
  this.#workers = new RoundRobinMap()
257
311
  this.#channelCreationHook = createChannelCreationHook(this.#config)
258
- this.#meshInterceptor = createThreadInterceptor({
259
- domain: '.plt.local',
260
- timeout: this.#config.applicationTimeout,
261
- meshTimeout: this.#context.meshTimeout ?? true,
262
- onChannelCreation: this.#channelCreationHook,
263
- onError: this.#onMeshInterceptorError.bind(this)
264
- })
312
+ this.#meshId = `runtime-${randomUUID()}`
265
313
  this.logger = abstractLogger // This is replaced by the real logger in init() and eventually removed in close()
266
314
  this.#status = undefined
267
315
  this.#restartingApplications = new Set()
@@ -296,20 +344,26 @@ export class Runtime extends EventEmitter {
296
344
  // Registered per-worker in #setupWorker, reserved so extensions cannot clobber it
297
345
  this.#reservedITCHandlerNames.add('profiling:started')
298
346
  this.#extensions = []
347
+ this.#metricsServersInitialized = false
348
+ this.#metricsServersStartPromise = null
299
349
  this.#extensionsWantHealthMetrics = false
300
350
  this.#extensionReadinessChecks = new Map()
301
351
  this.#extensionLivenessChecks = new Map()
302
352
  this.#extensionHealthRoutes = []
303
353
  this.#lastOverloadProfiles = new Map()
354
+ this.#servingStates = new Map()
304
355
  this.#sharedContext = {}
305
356
 
306
- if (this.#isProduction) {
307
- this.#env.PLT_DEV = 'false'
308
- this.#env.PLT_ENVIRONMENT = 'production'
309
- } else {
310
- this.#env.PLT_DEV = 'true'
311
- this.#env.PLT_ENVIRONMENT = 'development'
312
- }
357
+ /*
358
+ v3 injected `PLT_DEV` and `PLT_ENVIRONMENT` here. v4 removes them: an application branches on
359
+ its own variables, or the decision moves into the configuration, where the context carries
360
+ `production` and `mode` with types. `NODE_ENV` is the one the runtime still defaults, at the
361
+ bottom of the env ladder, so anything the project sets outranks it.
362
+
363
+ They were worse than redundant by the end -- the v4 worker environment is the one the loader
364
+ resolved per application, and these were written onto the runtime's own copy, so under
365
+ `wattpm start` an application still read `PLT_ENVIRONMENT=development`.
366
+ */
313
367
  }
314
368
 
315
369
  async init () {
@@ -327,10 +381,9 @@ export class Runtime extends EventEmitter {
327
381
  this.#metricsLabelName = 'applicationId'
328
382
  }
329
383
 
330
- // Initialize process-level metrics registry in the main thread if metrics or management API is enabled
384
+ // Initialize process-level metrics registry only when metrics are enabled.
331
385
  // These metrics are the same across all workers and only need to be collected once
332
- // We need this for management API as it can request metrics even without explicit metrics config
333
- if (config.metrics || config.managementApi) {
386
+ if (config.metrics && config.metrics.enabled !== false) {
334
387
  this.#processMetricsRegistry = new metricsClient.Registry()
335
388
  collectProcessMetrics(this.#processMetricsRegistry)
336
389
  }
@@ -366,12 +419,22 @@ export class Runtime extends EventEmitter {
366
419
  // readiness/liveness checks and probe routes before Fastify starts listening.
367
420
  await this.#loadExtensions()
368
421
 
369
- if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
370
- this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
422
+ // A runtime without applications exits during start and must not briefly
423
+ // claim the default health-probe port. Applications added later, including
424
+ // by extension start hooks, initialize these servers in addApplications().
425
+ if (config.applications.length > 0) {
426
+ await this.#ensureMetricsServersStarted()
371
427
  }
372
428
 
373
- this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
374
- this.#assertExtensionHealthRoutesApplied()
429
+ this.#meshCoordinator = createCoordinator({ meshId: this.#meshId })
430
+ this.#meshInterceptor = createInterceptor({
431
+ meshId: this.#meshId,
432
+ domain: '.plt.local',
433
+ connectTimeout: this.#config.applicationTimeout,
434
+ bootstrapTimeout: this.#config.applicationTimeout,
435
+ allowTarget: createTargetPermissionHook(this.#config)
436
+ })
437
+ await this.#meshInterceptor.ready
375
438
 
376
439
  await this.addApplications(this.#config.applications)
377
440
  await this.#setDispatcher(config.undici)
@@ -487,7 +550,8 @@ export class Runtime extends EventEmitter {
487
550
 
488
551
  await this.stopApplications(this.getApplicationsIds(), silent)
489
552
 
490
- await this.#meshInterceptor.close()
553
+ await this.#meshInterceptor?.close()
554
+ this.#meshCoordinator?.destroy()
491
555
  this.#workersBroadcastChannel?.close()
492
556
 
493
557
  this.#updateStatus('stopped')
@@ -520,6 +584,7 @@ export class Runtime extends EventEmitter {
520
584
  clearTimeout(this.#healthMetricsTimer)
521
585
  this.#healthMetricsCollectionActive = false
522
586
  this.#lastOverloadProfiles.clear()
587
+ this.#servingStates.clear()
523
588
 
524
589
  await this.stop(silent)
525
590
  this.#updateStatus('closing')
@@ -666,6 +731,10 @@ export class Runtime extends EventEmitter {
666
731
 
667
732
  await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
668
733
 
734
+ if (applications.length > 0) {
735
+ await this.#ensureMetricsServersStarted()
736
+ }
737
+
669
738
  for (const application of applications) {
670
739
  this.logger.debug(`Added application "${application.id}".`)
671
740
  this.emitAndNotify('application:added', application)
@@ -677,7 +746,7 @@ export class Runtime extends EventEmitter {
677
746
 
678
747
  const created = []
679
748
  for (const { id } of applications) {
680
- created.push(await this.getApplicationDetails(id))
749
+ created.push(await this.#buildApplicationDetails(id))
681
750
  }
682
751
 
683
752
  this.#updateLoggingPrefixes()
@@ -693,8 +762,13 @@ export class Runtime extends EventEmitter {
693
762
 
694
763
  // Use allowUnloaded so that applications without a live worker
695
764
  // (stopped or crashed with restartOnError: 0) can still be removed.
696
- const details = await this.getApplicationDetails(application, true)
765
+ const details = await this.#buildApplicationDetails(application, true)
697
766
  details.status = 'removed'
767
+
768
+ // The snapshot is taken while the application is still up, but what it reports is an
769
+ // application about to be gone: it is not making a claim about how it would serve.
770
+ delete details.servingState
771
+
698
772
  removed.push(details)
699
773
  }
700
774
 
@@ -735,8 +809,24 @@ export class Runtime extends EventEmitter {
735
809
  const levels = topologicalLevels(applications, dependencies)
736
810
 
737
811
  for (const level of levels) {
738
- const startInvocations = level.map(app => [app, silent])
739
- await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
812
+ const applicationsWithPort = await Promise.all(
813
+ level.map(async applicationId => {
814
+ const worker = await this.#getWorkerByIdOrNext(applicationId)
815
+ const applicationConfig = await sendViaITC(worker, 'getApplicationConfig')
816
+ const port = Number(applicationConfig?.server?.port)
817
+ return { applicationId, hasPort: Number.isInteger(port) && port > 0 }
818
+ })
819
+ )
820
+
821
+ for (const hasPort of [true, false]) {
822
+ const startInvocations = applicationsWithPort
823
+ .filter(application => application.hasPort === hasPort)
824
+ .map(({ applicationId }) => [applicationId, silent])
825
+
826
+ if (startInvocations.length > 0) {
827
+ await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
828
+ }
829
+ }
740
830
  }
741
831
  }
742
832
 
@@ -838,6 +928,7 @@ export class Runtime extends EventEmitter {
838
928
  }
839
929
 
840
930
  await this.#registerApplicationSchedulerJobs(id)
931
+ await this.#collectServingState(id)
841
932
 
842
933
  this.emitAndNotify('application:started', id)
843
934
  await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
@@ -864,6 +955,11 @@ export class Runtime extends EventEmitter {
864
955
  await executeInParallel(this.#stopWorker.bind(this), stopInvocations, this.#concurrency)
865
956
  }
866
957
 
958
+ // Absent rather than 'inactive': a stopped application is not making a claim about how it
959
+ // would serve, and conflating "not started" with "started and serving nothing" is the exact
960
+ // distinction this field exists to draw.
961
+ this.#servingStates.delete(id)
962
+
867
963
  this.emitAndNotify('application:stopped', id)
868
964
  }
869
965
 
@@ -1308,6 +1404,8 @@ export class Runtime extends EventEmitter {
1308
1404
  this.#healthProbesServer = null
1309
1405
  }
1310
1406
 
1407
+ this.#metricsServersInitialized = false
1408
+
1311
1409
  if (this.#opentelemetryMetricsForwarder) {
1312
1410
  await this.#opentelemetryMetricsForwarder.close()
1313
1411
  this.#opentelemetryMetricsForwarder = null
@@ -1321,10 +1419,7 @@ export class Runtime extends EventEmitter {
1321
1419
  entry.applied = false
1322
1420
  }
1323
1421
 
1324
- this.#prometheusServer = await startPrometheusServer(this, metricsConfig, this.#config.healthProbes)
1325
-
1326
- this.#healthProbesServer = await startHealthProbesServer(this, metricsConfig, this.#config.healthProbes)
1327
- this.#assertExtensionHealthRoutesApplied()
1422
+ await this.#ensureMetricsServersStarted()
1328
1423
 
1329
1424
  await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
1330
1425
 
@@ -1346,6 +1441,41 @@ export class Runtime extends EventEmitter {
1346
1441
  return { success: true, config: metricsConfig }
1347
1442
  }
1348
1443
 
1444
+ async #ensureMetricsServersStarted () {
1445
+ if (this.#metricsServersInitialized) {
1446
+ return
1447
+ }
1448
+
1449
+ if (this.#metricsServersStartPromise) {
1450
+ return this.#metricsServersStartPromise
1451
+ }
1452
+
1453
+ this.#metricsServersStartPromise = (async () => {
1454
+ const config = this.#config
1455
+
1456
+ try {
1457
+ if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
1458
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
1459
+ }
1460
+
1461
+ this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
1462
+ this.#assertExtensionHealthRoutesApplied()
1463
+ this.#metricsServersInitialized = true
1464
+ } catch (err) {
1465
+ await Promise.allSettled([this.#prometheusServer?.close(), this.#healthProbesServer?.close()])
1466
+ this.#prometheusServer = null
1467
+ this.#healthProbesServer = null
1468
+ throw err
1469
+ }
1470
+ })()
1471
+
1472
+ try {
1473
+ await this.#metricsServersStartPromise
1474
+ } finally {
1475
+ this.#metricsServersStartPromise = null
1476
+ }
1477
+ }
1478
+
1349
1479
  async #startOpenTelemetryMetricsForwarder (config) {
1350
1480
  if (!config?.endpoint || config.enabled === false || config.enabled === 'false') {
1351
1481
  return
@@ -1581,6 +1711,14 @@ export class Runtime extends EventEmitter {
1581
1711
  execPath: process.execPath,
1582
1712
  nodeVersion: process.version,
1583
1713
  projectDir: this.#root,
1714
+ /*
1715
+ What `applications:add`/`remove --save` actually consume, so they can stop reading the whole
1716
+ runtime configuration over HTTP to get at three values. `autoload` is the declaration as
1717
+ authored, not the expansion: v4 expands it in the eval worker, and --save has to edit what
1718
+ the file says rather than what it produced.
1719
+ */
1720
+ configPath: this.#config[kMetadata]?.path ?? null,
1721
+ autoload: this.#config.autoload ?? null,
1584
1722
  packageName: packageJson.name ?? null,
1585
1723
  packageVersion: packageJson.version ?? null,
1586
1724
  platformaticVersion: version,
@@ -1592,13 +1730,31 @@ export class Runtime extends EventEmitter {
1592
1730
  return this.#env
1593
1731
  }
1594
1732
 
1733
+ /*
1734
+ What a watcher has to follow to know this configuration changed. v4 reports the whole set the
1735
+ evaluation read; v3 resolves per worker and has no such set, so it is the deciding file alone --
1736
+ which is what dev watched before either way.
1737
+ */
1738
+ getConfigurationWatchTargets () {
1739
+ const metadata = this.#config[kMetadata]
1740
+ const targets = metadata?.v4?.watchTargets
1741
+
1742
+ if (targets) {
1743
+ return { files: [...targets.files], directories: [...targets.directories] }
1744
+ }
1745
+
1746
+ return { files: metadata?.path ? [metadata.path] : [], directories: [] }
1747
+ }
1748
+
1595
1749
  getRuntimeConfig (includeMeta = false) {
1750
+ // includeMeta is an internal contract and hands back live state, symbol key and all. It leaves
1751
+ // the public surface with the DTO change; until then, its in-tree callers depend on identity.
1596
1752
  if (includeMeta) {
1597
1753
  return this.#config
1598
1754
  }
1599
1755
 
1600
1756
  const { [kMetadata]: _, ...config } = this.#config
1601
- return config
1757
+ return frozenSnapshot(config)
1602
1758
  }
1603
1759
 
1604
1760
  getInterceptor () {
@@ -1617,6 +1773,41 @@ export class Runtime extends EventEmitter {
1617
1773
  return this.#managementApi?.server.address() ?? null
1618
1774
  }
1619
1775
 
1776
+ /*
1777
+ servingState is computed per worker: for a worker-classified capability it depends on what the
1778
+ application's factory returned in *that* worker, and nothing stops arbitrary code from returning
1779
+ a server from worker 0 and a background result from worker 1. Sampling one worker -- which is
1780
+ what getApplicationDetails does for every other field -- would make the reported value depend on
1781
+ which worker the selector picked, and, worse, would leave mesh dispatch routing a share of
1782
+ requests to a worker that destroys them. So every worker answers and a mixed answer is refused,
1783
+ naming each worker and the state it reported.
1784
+ */
1785
+ async #collectServingState (id) {
1786
+ const invocations = this.#workers.getKeys(id).map(workerId => [workerId, this.#workers.get(workerId)])
1787
+
1788
+ if (invocations.length === 0) {
1789
+ this.#servingStates.delete(id)
1790
+ return
1791
+ }
1792
+
1793
+ const states = await sendMultipleViaITC(invocations, 'getServingState', undefined, [], this.#concurrency)
1794
+ const reported = Object.entries(states).filter(([, state]) => typeof state === 'string')
1795
+
1796
+ if (reported.length === 0) {
1797
+ this.#servingStates.delete(id)
1798
+ return
1799
+ }
1800
+
1801
+ const distinct = new Set(reported.map(([, state]) => state))
1802
+
1803
+ if (distinct.size > 1) {
1804
+ this.#servingStates.delete(id)
1805
+ throw new MixedServingStateError(id, reported.map(([worker, state]) => `${worker} reported ${state}`).join(', '))
1806
+ }
1807
+
1808
+ this.#servingStates.set(id, reported[0][1])
1809
+ }
1810
+
1620
1811
  async getCustomHealthChecks () {
1621
1812
  const invocations = []
1622
1813
 
@@ -1768,7 +1959,7 @@ export class Runtime extends EventEmitter {
1768
1959
  // Drop any configured custom label that shares the name of the application
1769
1960
  // label (a config can set both `applicationLabel: 'serviceId'` and a static
1770
1961
  // `serviceId` label): keeping it would make these runtime-wide metrics look
1771
- // like they belong to an application, both here and in getFormattedMetrics().
1962
+ // like they belong to an application during metrics aggregation.
1772
1963
  const processLabels = { ...this.#config.metrics?.labels }
1773
1964
  delete processLabels[this.#metricsLabelName]
1774
1965
  this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
@@ -1933,155 +2124,6 @@ export class Runtime extends EventEmitter {
1933
2124
  return output
1934
2125
  }
1935
2126
 
1936
- async getFormattedMetrics () {
1937
- try {
1938
- const { metrics } = await this.getMetrics()
1939
-
1940
- if (metrics === null || metrics.length === 0) {
1941
- return null
1942
- }
1943
-
1944
- const metricsNames = [
1945
- 'process_cpu_percent_usage',
1946
- 'process_resident_memory_bytes',
1947
- 'nodejs_heap_size_total_bytes',
1948
- 'nodejs_heap_size_used_bytes',
1949
- 'nodejs_heap_space_size_total_bytes',
1950
- 'nodejs_eventloop_utilization',
1951
- 'http_request_all_summary_seconds'
1952
- ]
1953
-
1954
- const applicationsMetrics = {}
1955
-
1956
- // Process-level metrics are reported only once for the whole runtime, without
1957
- // an application label, since they are shared by all the applications running
1958
- // in worker threads (see issue #3332). Applications running as separate OS
1959
- // processes report their own labeled values, which take precedence below.
1960
- const runtimeProcessMetrics = {}
1961
- const applicationProcessMetrics = new Set()
1962
-
1963
- for (const metric of metrics) {
1964
- const { name, values } = metric
1965
-
1966
- if (!metricsNames.includes(name)) continue
1967
- if (!values || values.length === 0) continue
1968
-
1969
- const labels = values[0].labels
1970
- // Use the configured label name (serviceId for v2 compatibility, applicationId for v3+)
1971
- const applicationId = labels?.[this.#metricsLabelName]
1972
-
1973
- if (!applicationId) {
1974
- if (name === 'process_cpu_percent_usage') {
1975
- runtimeProcessMetrics.cpu = values[0].value
1976
- continue
1977
- }
1978
- if (name === 'process_resident_memory_bytes') {
1979
- runtimeProcessMetrics.rss = values[0].value
1980
- continue
1981
- }
1982
-
1983
- throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
1984
- }
1985
-
1986
- if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
1987
- applicationProcessMetrics.add(`${applicationId}:${name}`)
1988
- }
1989
-
1990
- let applicationMetrics = applicationsMetrics[applicationId]
1991
- if (!applicationMetrics) {
1992
- applicationMetrics = {
1993
- cpu: 0,
1994
- rss: 0,
1995
- totalHeapSize: 0,
1996
- usedHeapSize: 0,
1997
- newSpaceSize: 0,
1998
- oldSpaceSize: 0,
1999
- elu: 0,
2000
- latency: {
2001
- p50: 0,
2002
- p90: 0,
2003
- p95: 0,
2004
- p99: 0
2005
- }
2006
- }
2007
- applicationsMetrics[applicationId] = applicationMetrics
2008
- }
2009
-
2010
- parsePromMetric(applicationMetrics, metric)
2011
- }
2012
-
2013
- // Apply the runtime-wide process-level values to every application that did
2014
- // not report its own (i.e. every application running in a worker thread).
2015
- for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
2016
- if (
2017
- runtimeProcessMetrics.cpu !== undefined &&
2018
- !applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
2019
- ) {
2020
- applicationMetrics.cpu = runtimeProcessMetrics.cpu
2021
- }
2022
-
2023
- if (
2024
- runtimeProcessMetrics.rss !== undefined &&
2025
- !applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
2026
- ) {
2027
- applicationMetrics.rss = runtimeProcessMetrics.rss
2028
- }
2029
- }
2030
-
2031
- function parsePromMetric (applicationMetrics, promMetric) {
2032
- const { name } = promMetric
2033
-
2034
- if (name === 'process_cpu_percent_usage') {
2035
- applicationMetrics.cpu = promMetric.values[0].value
2036
- return
2037
- }
2038
- if (name === 'process_resident_memory_bytes') {
2039
- applicationMetrics.rss = promMetric.values[0].value
2040
- return
2041
- }
2042
- if (name === 'nodejs_heap_size_total_bytes') {
2043
- applicationMetrics.totalHeapSize = promMetric.values[0].value
2044
- return
2045
- }
2046
- if (name === 'nodejs_heap_size_used_bytes') {
2047
- applicationMetrics.usedHeapSize = promMetric.values[0].value
2048
- return
2049
- }
2050
- if (name === 'nodejs_heap_space_size_total_bytes') {
2051
- const newSpaceSize = promMetric.values.find(value => value.labels.space === 'new')
2052
- const oldSpaceSize = promMetric.values.find(value => value.labels.space === 'old')
2053
-
2054
- applicationMetrics.newSpaceSize = newSpaceSize.value
2055
- applicationMetrics.oldSpaceSize = oldSpaceSize.value
2056
- return
2057
- }
2058
- if (name === 'nodejs_eventloop_utilization') {
2059
- applicationMetrics.elu = promMetric.values[0].value
2060
- return
2061
- }
2062
- if (name === 'http_request_all_summary_seconds') {
2063
- applicationMetrics.latency = {
2064
- p50: promMetric.values.find(value => value.labels.quantile === 0.5)?.value || 0,
2065
- p90: promMetric.values.find(value => value.labels.quantile === 0.9)?.value || 0,
2066
- p95: promMetric.values.find(value => value.labels.quantile === 0.95)?.value || 0,
2067
- p99: promMetric.values.find(value => value.labels.quantile === 0.99)?.value || 0
2068
- }
2069
- }
2070
- }
2071
-
2072
- return {
2073
- version: 1,
2074
- date: new Date().toISOString(),
2075
- applications: applicationsMetrics
2076
- }
2077
- } catch (err) {
2078
- // If any metric is missing, return nothing
2079
- this.logger.warn({ err }, 'Cannot fetch metrics')
2080
-
2081
- return null
2082
- }
2083
- }
2084
-
2085
2127
  getSharedContext () {
2086
2128
  return this.#sharedContext
2087
2129
  }
@@ -2100,12 +2142,16 @@ export class Runtime extends EventEmitter {
2100
2142
  }
2101
2143
 
2102
2144
  async getApplications (allowUnloaded = false) {
2103
- return {
2145
+ return frozenSnapshot({
2104
2146
  production: this.#isProduction,
2105
2147
  applications: await Promise.all(
2106
- this.getApplicationsIds().map(id => this.getApplicationDetails(id, allowUnloaded))
2148
+ this.getApplicationsIds().map(id => this.#buildApplicationDetails(id, allowUnloaded))
2107
2149
  )
2108
- }
2150
+ })
2151
+ }
2152
+
2153
+ async getApplicationDetails (id, allowUnloaded = false) {
2154
+ return frozenSnapshot(await this.#buildApplicationDetails(id, allowUnloaded))
2109
2155
  }
2110
2156
 
2111
2157
  async getApplicationMeta (id) {
@@ -2133,7 +2179,7 @@ export class Runtime extends EventEmitter {
2133
2179
  }
2134
2180
  }
2135
2181
 
2136
- async getApplicationDetails (id, allowUnloaded = false) {
2182
+ async #buildApplicationDetails (id, allowUnloaded = false) {
2137
2183
  let application
2138
2184
 
2139
2185
  try {
@@ -2146,7 +2192,7 @@ export class Runtime extends EventEmitter {
2146
2192
  throw e
2147
2193
  }
2148
2194
 
2149
- const { localUrl, config, path } = application[kConfig]
2195
+ const { localUrl, config, configPath, path } = application[kConfig]
2150
2196
 
2151
2197
  const sourceMaps = application[kConfig].sourceMaps ?? this.#config.sourceMaps
2152
2198
  const status = await sendViaITC(application, 'getStatus')
@@ -2155,7 +2201,6 @@ export class Runtime extends EventEmitter {
2155
2201
  const applicationDetails = {
2156
2202
  id,
2157
2203
  type,
2158
- config,
2159
2204
  path,
2160
2205
  status,
2161
2206
  dependencies,
@@ -2164,6 +2209,27 @@ export class Runtime extends EventEmitter {
2164
2209
  sourceMaps
2165
2210
  }
2166
2211
 
2212
+ /*
2213
+ v3's `config` was the application's configuration file path. v4 evaluates that file main-side
2214
+ and hands the worker the payload, so the path becomes `configPath` and the entry's own
2215
+ `config` is no longer a path to report. Emitting the key that matches the dialect keeps a
2216
+ consumer from reading one and silently getting the other.
2217
+ */
2218
+ if (typeof config === 'string') {
2219
+ applicationDetails.config = config
2220
+ } else if (configPath) {
2221
+ applicationDetails.configPath = configPath
2222
+ }
2223
+
2224
+ // status is worker lifecycle; servingState is how the thing serves. They are different
2225
+ // questions, and the runtime gates URL emission on status === 'started', so they cannot share
2226
+ // a field.
2227
+ const servingState = this.#servingStates.get(id)
2228
+
2229
+ if (servingState) {
2230
+ applicationDetails.servingState = servingState
2231
+ }
2232
+
2167
2233
  if (this.#isProduction) {
2168
2234
  applicationDetails.workers = this.#workers.getKeys(id).length
2169
2235
  }
@@ -2431,6 +2497,17 @@ export class Runtime extends EventEmitter {
2431
2497
  }
2432
2498
  }
2433
2499
 
2500
+ async #refuseUnresolvedApplication (id) {
2501
+ const executable = getExecutable() ?? 'platformatic'
2502
+
2503
+ this.logger.error(
2504
+ `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
2505
+ id
2506
+ )
2507
+
2508
+ await this.closeAndThrow(new RuntimeAbortedError())
2509
+ }
2510
+
2434
2511
  async #setupApplication (applicationConfig) {
2435
2512
  if (this.#status === 'stopping' || this.#status === 'closed') {
2436
2513
  return
@@ -2446,13 +2523,7 @@ export class Runtime extends EventEmitter {
2446
2523
  applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, id)
2447
2524
 
2448
2525
  if (!existsSync(applicationConfig.path)) {
2449
- const executable = getExecutable() ?? 'platformatic'
2450
- this.logger.error(
2451
- `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
2452
- id
2453
- )
2454
-
2455
- await this.closeAndThrow(new RuntimeAbortedError())
2526
+ await this.#refuseUnresolvedApplication(id)
2456
2527
  }
2457
2528
  } else {
2458
2529
  this.logger.error(
@@ -2462,6 +2533,15 @@ export class Runtime extends EventEmitter {
2462
2533
 
2463
2534
  await this.closeAndThrow(new RuntimeAbortedError())
2464
2535
  }
2536
+ } else if (applicationConfig.unresolved) {
2537
+ /*
2538
+ A remote application that declares where its clone belongs, and whose clone is not there --
2539
+ either the directory is missing or it holds no application at all. The pathless case above
2540
+ is the same state said differently, and both are what `resolve` exists to fix, so both get
2541
+ the message that names it rather than the capability detector's report that the directory
2542
+ holds nothing.
2543
+ */
2544
+ await this.#refuseUnresolvedApplication(id)
2465
2545
  }
2466
2546
 
2467
2547
  let workers = applicationConfig.workers.static
@@ -2527,11 +2607,11 @@ export class Runtime extends EventEmitter {
2527
2607
  inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
2528
2608
  }
2529
2609
 
2530
- if (config.telemetry) {
2531
- applicationConfig.telemetry = {
2532
- ...config.telemetry,
2533
- ...applicationConfig.telemetry,
2534
- applicationName: `${config.telemetry.applicationName}-${applicationConfig.id}`
2610
+ if (config.tracing) {
2611
+ applicationConfig.tracing = {
2612
+ ...config.tracing,
2613
+ ...applicationConfig.tracing,
2614
+ applicationName: `${config.tracing.applicationName}-${applicationConfig.id}`
2535
2615
  }
2536
2616
  }
2537
2617
 
@@ -2540,9 +2620,9 @@ export class Runtime extends EventEmitter {
2540
2620
 
2541
2621
  const execArgv = applicationConfig.execArgv ?? []
2542
2622
 
2543
- if (!applicationConfig.skipTelemetryHooks && config.telemetry && config.telemetry.enabled !== false) {
2623
+ if (!applicationConfig.skipTracingHooks && config.tracing && config.tracing.enabled !== false) {
2544
2624
  const require = createRequire(import.meta.url)
2545
- const telemetryPath = require.resolve('@platformatic/telemetry')
2625
+ const telemetryPath = require.resolve('@platformatic/tracing')
2546
2626
  const openTelemetrySetupPath = join(telemetryPath, '..', 'lib', 'node-telemetry.js')
2547
2627
  const hookUrl = pathToFileURL(require.resolve('@opentelemetry/instrumentation/hook.mjs'))
2548
2628
 
@@ -2598,6 +2678,7 @@ export class Runtime extends EventEmitter {
2598
2678
  const worker = new Worker(kWorkerFile, {
2599
2679
  workerData: {
2600
2680
  config: workerConfig,
2681
+ meshId: this.#meshId,
2601
2682
  applicationConfig: {
2602
2683
  ...applicationConfig,
2603
2684
  isProduction: this.#isProduction,
@@ -2615,10 +2696,7 @@ export class Runtime extends EventEmitter {
2615
2696
  codeRangeSizeMb
2616
2697
  },
2617
2698
  inspectorOptions,
2618
- dirname: this.#root,
2619
- // Keys of the worker environment which only come from an env file of the runtime:
2620
- // the env file of the application is allowed to override those.
2621
- envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
2699
+ dirname: this.#root
2622
2700
  },
2623
2701
  argv: applicationConfig.arguments,
2624
2702
  execArgv,
@@ -2739,6 +2817,10 @@ export class Runtime extends EventEmitter {
2739
2817
  // Forward events from the worker
2740
2818
  // Do not use emitAndNotify here since we don't want to forward unknown events
2741
2819
  worker[kITC].on('event', ({ event, payload }) => {
2820
+ if (event === 'serverOptions') {
2821
+ worker[kWorkerServerOptions] = payload[0]
2822
+ }
2823
+
2742
2824
  event = `application:worker:event:${event}`
2743
2825
 
2744
2826
  this.emit(event, ...payload, workerId, applicationId, index)
@@ -2924,11 +3006,6 @@ export class Runtime extends EventEmitter {
2924
3006
  this.#workers.set(workerId, worker)
2925
3007
  }
2926
3008
 
2927
- // Setup the interceptor
2928
- // kInterceptorReadyPromise resolves when the worker
2929
- // is ready to receive requests: after calling the replaceServer method
2930
- worker[kInterceptorReadyPromise] = this.#meshInterceptor.route(applicationId, worker)
2931
-
2932
3009
  // Wait for initialization
2933
3010
  try {
2934
3011
  await waitEventFromITC(worker, 'init')
@@ -3369,12 +3446,6 @@ export class Runtime extends EventEmitter {
3369
3446
 
3370
3447
  this.#recordWorkerUrl(worker, id, workerUrl)
3371
3448
 
3372
- // Wait for the interceptor to be ready
3373
- const interceptorResult = await executeWithTimeout(worker[kInterceptorReadyPromise], config.startTimeout)
3374
- if (interceptorResult === kTimeout) {
3375
- throw new WorkerInterceptorJoinTimeoutError(label, config.startTimeout)
3376
- }
3377
-
3378
3449
  worker[kWorkerStatus] = 'started'
3379
3450
  worker[kWorkerStartTime] = Date.now()
3380
3451
 
@@ -3560,7 +3631,6 @@ export class Runtime extends EventEmitter {
3560
3631
  }
3561
3632
 
3562
3633
  async #discardWorker (worker) {
3563
- await this.#meshInterceptor.unroute(worker[kApplicationId], worker, true)
3564
3634
  worker.removeAllListeners('exit')
3565
3635
  await worker.terminate()
3566
3636
 
@@ -3730,6 +3800,7 @@ export class Runtime extends EventEmitter {
3730
3800
 
3731
3801
  const stopBeforeStart =
3732
3802
  Boolean(worker[kWorkerUrl]) &&
3803
+ worker[kWorkerServerOptions]?.port !== 0 &&
3733
3804
  (config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
3734
3805
 
3735
3806
  try {
@@ -3970,11 +4041,20 @@ export class Runtime extends EventEmitter {
3970
4041
  }
3971
4042
 
3972
4043
  async #getRuntimePackageJson () {
3973
- const runtimeDir = this.#root
3974
- const packageJsonPath = join(runtimeDir, 'package.json')
3975
- const packageJsonFile = await readFile(packageJsonPath, 'utf8')
3976
- const packageJson = JSON.parse(packageJsonFile)
3977
- return packageJson
4044
+ const packageJsonPath = join(this.#root, 'package.json')
4045
+
4046
+ /*
4047
+ A project need not have one, and a runtime that cannot report its metadata without one is a
4048
+ runtime whose `applications:add --save` stops working -- that command reads `projectDir`,
4049
+ `configPath` and `autoload` from this, and has done since `GET /config` was removed. The
4050
+ metadata already declares `packageName` and `packageVersion` as nullable, so absence is a
4051
+ state its shape allows.
4052
+ */
4053
+ try {
4054
+ return JSON.parse(await readFile(packageJsonPath, 'utf8'))
4055
+ } catch {
4056
+ return {}
4057
+ }
3978
4058
  }
3979
4059
 
3980
4060
  #handleWorkerStandardStreams (worker, applicationId, workerId) {
@@ -4391,6 +4471,10 @@ export class Runtime extends EventEmitter {
4391
4471
  report.started.push(newIndex)
4392
4472
  startedWorkersCount++
4393
4473
  }
4474
+
4475
+ // A worker added after boot re-runs the application's code, so it can answer differently
4476
+ // from the workers already running. Scale-up is the second place the answers must agree.
4477
+ await this.#collectServingState(applicationId)
4394
4478
  report.success = true
4395
4479
  } catch (err) {
4396
4480
  if (pendingWorkerId) {
@@ -4478,6 +4562,10 @@ export class Runtime extends EventEmitter {
4478
4562
  }
4479
4563
  }
4480
4564
 
4565
+ if (applicationConfig.sourcePath) {
4566
+ allows.add(`--allow-fs-read=${join(applicationConfig.sourcePath, '*')}`)
4567
+ }
4568
+
4481
4569
  if (write?.length) {
4482
4570
  for (const p of write) {
4483
4571
  allows.add(`--allow-fs-write=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
@@ -4952,18 +5040,6 @@ export class Runtime extends EventEmitter {
4952
5040
  this.#loggerContext.updatePrefixes(ids)
4953
5041
  }
4954
5042
 
4955
- #onMeshInterceptorError (error) {
4956
- const worker = error.port
4957
-
4958
- this.logger.error(
4959
- { err: ensureLoggableError(error.cause) },
4960
- `The ${this.#workerExtendedLabel(worker[kApplicationId], worker[kWorkerId])} threw an error during mesh network setup. Replacing it ...`
4961
- )
4962
-
4963
- this.emit('application:worker:init:failed', { application: worker[kApplicationId], worker: worker[kWorkerId] })
4964
- worker.terminate()
4965
- }
4966
-
4967
5043
  #getPortOwner (port, applicationId, hostname, includeSameApplication = false) {
4968
5044
  if (!Number.isInteger(port) || port <= 0) {
4969
5045
  return null