@platformatic/runtime 3.72.0 → 4.0.0-alpha.1

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/config.js CHANGED
@@ -1,103 +1,109 @@
1
- import { importCapabilityAndConfig, validationOptions } from '@platformatic/basic'
2
- import {
3
- extractModuleFromSchemaUrl,
4
- findConfigurationFile,
5
- kMetadata,
6
- loadConfiguration,
7
- loadConfigurationModule,
8
- loadModule,
9
- omitProperties,
10
- runtimeUnwrappablePropertiesList
11
- } from '@platformatic/foundation'
12
- import { realpathSync } from 'node:fs'
13
- import { readdir, readFile } from 'node:fs/promises'
14
- import { createRequire } from 'node:module'
15
- import { isAbsolute, join, resolve as resolvePath } from 'node:path'
1
+ import { createDirectory, kMetadata, loadModule } from '@platformatic/foundation'
2
+ import { loadAdditionalApplications } from '@platformatic/foundation/loader'
3
+ import { createRequire, findPackageJSON } from 'node:module'
4
+ import { availableParallelism } from 'node:os'
5
+ import { dirname, resolve as resolvePath } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
16
7
 
17
8
  import {
18
- ApplicationIdCollisionError,
9
+ ApplicationsPortsOverlapError,
19
10
  InspectAndInspectBrkError,
20
11
  InspectorHostError,
21
12
  InspectorPortError,
22
13
  InvalidArgumentError,
23
- InvalidEntrypointError
14
+ MissingDependencyError
24
15
  } from './errors.js'
25
- import { schema } from './schema.js'
26
- import { upgrade } from './upgrade.js'
16
+
17
+ // The runtime package's own entry point, which is where the bundled capability copies live.
18
+ const runtimeScopePath = fileURLToPath(new URL('../index.js', import.meta.url))
27
19
 
28
20
  // Validate and coerce workers values early to avoid runtime hangs when invalid
29
- function coercePositiveInteger (value) {
30
- if (typeof value === 'number') {
31
- if (!Number.isInteger(value) || value < 1) return null
32
- return value
21
+ function coercePositiveInteger (value, location) {
22
+ const count = typeof value === 'string' ? Number(value) : value
23
+ if (!Number.isInteger(count) || count < 1) {
24
+ const hint = typeof value === 'string' && /\{.*\}/.test(value) ? ' (check your environment variable)' : ''
25
+ throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${value}"${hint}`)
33
26
  }
34
- if (typeof value === 'string') {
35
- // Trim to handle accidental spaces
36
- const trimmed = value.trim()
37
- if (trimmed.length === 0) return null
38
- const num = Number(trimmed)
39
- if (!Number.isFinite(num) || !Number.isInteger(num) || num < 1) return null
40
- return num
41
- }
42
- return null
27
+ return count
43
28
  }
44
29
 
45
- function raiseInvalidWorkersError (location, received, hint) {
46
- const extra = hint ? ` (${hint})` : ''
47
- throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
48
- }
30
+ function coerceWorkers (workers, prefix) {
31
+ if (workers === undefined) return {}
49
32
 
50
- function parseWorkers (config, prefix, defaultWorkers = { static: 1, dynamic: false }) {
51
- if (typeof config.workers !== 'undefined') {
52
- // Number
53
- if (typeof config.workers !== 'object') {
54
- const coerced = coercePositiveInteger(config.workers)
55
-
56
- if (coerced === null) {
57
- const raw = config.workers
58
- const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
59
- raiseInvalidWorkersError(prefix, config.workers, hint)
60
- } else {
61
- config.workers = { static: coerced, dynamic: false }
62
- }
63
- // Object
64
- } else {
65
- for (const key of ['minimum', 'maximum', 'static']) {
66
- if (typeof config.workers[key] === 'undefined') {
67
- continue
68
- }
69
-
70
- const coerced = coercePositiveInteger(config.workers[key])
71
- if (coerced === null) {
72
- const raw = config.workers
73
- const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
74
- raiseInvalidWorkersError(`${prefix} ${key}`, config.workers, hint)
75
- } else {
76
- config.workers[key] = coerced
77
- }
78
- }
33
+ if (typeof workers !== 'object') {
34
+ return {
35
+ static: coercePositiveInteger(workers, prefix),
36
+ dynamic: false
79
37
  }
80
- } else {
81
- config.workers = {}
82
38
  }
83
39
 
84
- // Fill missing values from defaults
85
- for (const key of ['minimum', 'maximum', 'static', 'dynamic']) {
86
- if (typeof config.workers[key] === 'undefined' && typeof defaultWorkers[key] !== 'undefined') {
87
- config.workers[key] = defaultWorkers[key]
40
+ for (const key of ['minimum', 'maximum', 'static']) {
41
+ if (workers[key] !== undefined) {
42
+ workers[key] = coercePositiveInteger(workers[key], `${prefix} ${key}`)
88
43
  }
89
44
  }
45
+ return workers
46
+ }
47
+
48
+ function parseRuntimeWorkers (config) {
49
+ const workers = coerceWorkers(config.workers, 'Runtime')
50
+ workers.total ??= availableParallelism()
51
+ workers.static ??= 1
52
+ workers.dynamic ??= false
53
+
54
+ // Ajv does not apply property defaults inside the workers schema's anyOf branches.
55
+ workers.eluThreshold ??= 0.8
56
+ workers.processIntervalMs ??= 10000
57
+ workers.maxScaleUpStep ??= 1
58
+ workers.redistributionMs ??= 10000
59
+ workers.alphaUp ??= 0.2
60
+ workers.alphaDown ??= 0.1
61
+ workers.betaUp ??= 0.1
62
+ workers.betaDown ??= 0.1
63
+ workers.cooldowns ??= {}
64
+ workers.cooldowns.scaleUpAfterScaleUpMs ??= 5000
65
+ workers.cooldowns.scaleUpAfterScaleDownMs ??= 5000
66
+ workers.cooldowns.scaleDownAfterScaleUpMs ??= 30000
67
+ workers.cooldowns.scaleDownAfterScaleDownMs ??= 20000
68
+
69
+ if (workers.maximum < workers.minimum) {
70
+ throw new InvalidArgumentError(
71
+ `Workers minimum (${workers.minimum}) must not exceed maximum (${workers.maximum})`
72
+ )
73
+ }
74
+ if (workers.dynamic) {
75
+ workers.static = workers.minimum ?? 1
76
+ }
77
+
78
+ config.workers = workers
79
+ }
90
80
 
91
- // Additional validations
92
- if (config.workers.maximum < config.workers.minimum) {
93
- const t = config.workers.minimum
94
- config.workers.minimum = config.workers.maximum
95
- config.workers.maximum = t
81
+ function parseApplicationWorkers (applicationConfig, config) {
82
+ const workers = coerceWorkers(applicationConfig.workers, `Service "${applicationConfig.id}"`)
83
+ for (const key of ['minimum', 'maximum', 'static', 'dynamic']) {
84
+ if (workers[key] === undefined && config.workers[key] !== undefined) {
85
+ workers[key] = config.workers[key]
86
+ }
96
87
  }
97
88
 
98
- if (typeof config.workers.static === 'undefined') {
99
- config.workers.static = config.workers.minimum
89
+ if (workers.maximum < workers.minimum) {
90
+ throw new InvalidArgumentError(
91
+ `Workers minimum (${workers.minimum}) must not exceed maximum (${workers.maximum})`
92
+ )
100
93
  }
94
+ if (workers.dynamic) {
95
+ for (const key of ['eluThreshold', 'heapThresholdMb']) {
96
+ if (workers[key] === undefined && config.workers[key] !== undefined) {
97
+ workers[key] = config.workers[key]
98
+ }
99
+ }
100
+ workers.minimum ??= 1
101
+ // An implicit maximum must accommodate the application's required minimum.
102
+ workers.maximum ??= Math.max(workers.minimum, config.workers.total)
103
+ workers.static = workers.minimum
104
+ }
105
+
106
+ applicationConfig.workers = workers
101
107
  }
102
108
 
103
109
  export function pprofCapturePreloadPath () {
@@ -130,62 +136,6 @@ export function autoDetectPprofCapture (config) {
130
136
  return config
131
137
  }
132
138
 
133
- export async function wrapInRuntimeConfig (config, context) {
134
- let applicationId = 'main'
135
- try {
136
- const packageJson = JSON.parse(await readFile(join(config[kMetadata].root, 'package.json'), 'utf-8'))
137
- applicationId = packageJson?.name ?? 'main'
138
-
139
- if (applicationId.startsWith('@')) {
140
- applicationId = applicationId.split('/')[1]
141
- }
142
- } catch (err) {
143
- // on purpose, the package.json might be missing
144
- }
145
-
146
- // Carry over only the server properties that the user actually set, so
147
- // we do not end up with a `{ hostname: undefined, ... }` object that then
148
- // gets populated by schema defaults or trips truthy checks downstream.
149
- const server = {}
150
- if (config.server) {
151
- for (const key of ['hostname', 'port', 'http2', 'https']) {
152
- if (config.server[key] !== undefined) {
153
- server[key] = config.server[key]
154
- }
155
- }
156
- }
157
- const production = context?.isProduction ?? context?.production
158
-
159
- const runtimeConfig = config.runtime ?? {}
160
-
161
- // Important: do not change the order of the properties in this object
162
- /* c8 ignore next */
163
- const wrapped = {
164
- $schema: schema.$id,
165
- ...(Object.keys(server).length > 0 ? { server } : {}),
166
- watch: !production,
167
- ...omitProperties(runtimeConfig, runtimeUnwrappablePropertiesList),
168
- entrypoint: applicationId,
169
- applications: [
170
- {
171
- id: applicationId,
172
- path: config[kMetadata].root,
173
- config: config[kMetadata].path,
174
- ...(runtimeConfig.application ?? {})
175
- }
176
- ]
177
- }
178
-
179
- return loadConfiguration(wrapped, context?.schema ?? schema, {
180
- validationOptions,
181
- transform,
182
- upgrade,
183
- replaceEnv: true,
184
- root: config[kMetadata].root,
185
- ...context
186
- })
187
- }
188
-
189
139
  export function parseInspectorOptions (config, inspect, inspectBreak) {
190
140
  const hasInspect = inspect != null
191
141
  const hasInspectBrk = inspectBreak != null
@@ -228,297 +178,226 @@ export function parseInspectorOptions (config, inspect, inspectBreak) {
228
178
  config.watch = false
229
179
  }
230
180
 
231
- export async function prepareApplication (config, application, defaultWorkers) {
232
- // We need to have absolute paths here, ot the `loadConfig` will fail
233
- // Make sure we don't resolve if env var was not replaced
234
- if (application.path && !isAbsolute(application.path) && !application.path.match(/^\{.*\}$/)) {
235
- application.path = resolvePath(config[kMetadata].root, application.path)
236
- }
181
+ /*
182
+ The one entry point for applications added while the runtime is running, shared by the HTTP route
183
+ and the management ITC handler so the two cannot drift.
184
+
185
+ An added application is evaluated exactly as boot evaluates one -- its configuration
186
+ file is found in its own directory, its environment is resolved main-side, and it arrives with
187
+ resolvedConfig. Skipping that leaves the worker to look for a config file name that is never written,
188
+ which fails as an unhelpful "unable to initialize the worker".
189
+ */
190
+ export async function prepareAddedApplications (config, entries, existingIds = []) {
191
+ const metadata = config[kMetadata]
192
+
193
+ const { applications } = await loadAdditionalApplications({
194
+ configPath: metadata.path,
195
+ entries,
196
+ existingIds,
197
+ rootEnvBlock: config.env,
198
+ command: metadata.loader.command,
199
+ mode: metadata.loader.mode,
200
+ production: metadata.loader.production,
201
+ realEnv: metadata.loader.realEnv,
202
+ customEnvFile: metadata.loader.customEnvFile,
203
+ // The runtime is the fallback scope for capability resolution, the same as at boot.
204
+ runtimeScope: runtimeScopePath
205
+ })
237
206
 
238
- if (application.path && application.config) {
239
- application.config = resolvePath(application.path, application.config)
207
+ const prepared = []
208
+
209
+ for (const application of applications) {
210
+ prepared.push(await prepareRuntimeApplication(config, application))
240
211
  }
241
212
 
242
- // Skip capability detection for external services (url without path)
243
- // These services will have their path resolved later in runtime.js #setupApplication
244
- // Attempting to detect capability here would cause slow glob operations on the cwd
245
- if (application.url && !application.path) {
246
- application.type = 'unknown'
247
- } else {
248
- try {
249
- let pkg
213
+ return prepared
214
+ }
250
215
 
251
- if (application.config) {
252
- const config = await loadConfiguration(application.config)
253
- pkg = await loadConfigurationModule(application.path, config)
216
+ const hostnameWildcards = new Set(['0.0.0.0', '::', '[::]'])
254
217
 
255
- application.type = extractModuleFromSchemaUrl(config, true).module
256
- application.skipTelemetryHooks = pkg.skipTelemetryHooks
257
- } else {
258
- const { moduleName, capability } = await importCapabilityAndConfig(application.path)
259
- pkg = capability
218
+ // The listener an application declares in its own configuration, when it can be determined without
219
+ // starting it. A port coming from an environment variable which was not replaced, an ephemeral port
220
+ // and a missing server block all yield null, in which case only the start time check applies.
221
+ function declaredListener (applicationConfig) {
222
+ const server = applicationConfig?.server
260
223
 
261
- application.type = moduleName
262
- }
224
+ if (!server) {
225
+ return null
226
+ }
263
227
 
264
- application.skipTelemetryHooks = pkg.skipTelemetryHooks
228
+ const port = Number(server.port)
265
229
 
266
- // This is needed to work around Rust bug on dylibs:
267
- // https://github.com/rust-lang/rust/issues/91979
268
- // https://github.com/rollup/rollup/issues/5761
269
- const _require = createRequire(application.path)
270
- for (const m of pkg.modulesToLoad ?? []) {
271
- const toLoad = _require.resolve(m)
272
- loadModule(_require, toLoad).catch(() => {})
273
- }
274
- } catch (err) {
275
- // This should not happen, it happens on running some unit tests if we prepare the runtime
276
- // when not all the applications configs are available. Given that we are running this only
277
- // to ddetermine the type of the application, it's safe to ignore this error and default to unknown
278
- application.type = 'unknown'
279
- }
230
+ if (!Number.isInteger(port) || port <= 0) {
231
+ return null
280
232
  }
281
233
 
282
- // Validate and coerce per-service workers
283
- parseWorkers(application, `Service "${application.id}"`, defaultWorkers)
234
+ return { port, hostname: server.hostname, perWorker: server.portAssignment === 'perWorkerIncrement' }
235
+ }
284
236
 
285
- application.entrypoint = application.id === config.entrypoint
286
- application.dependencies ??= []
287
- application.localUrl = `http://${application.id}.plt.local`
237
+ // Two declared listeners can only be compared when both hostnames are known. When a hostname is
238
+ // omitted the capability picks its own default, so unless the other side is a wildcard - which
239
+ // overlaps with anything - the comparison is left to the start time check.
240
+ function declaredListenersOverlap (first, second) {
241
+ const hostname = first.hostname?.toLowerCase()
242
+ const otherHostname = second.hostname?.toLowerCase()
288
243
 
289
- if (typeof application.watch === 'undefined') {
290
- application.watch = config.watch
244
+ // A wildcard overlaps with any other hostname, known or not
245
+ if (hostnameWildcards.has(hostname) || hostnameWildcards.has(otherHostname)) {
246
+ return true
291
247
  }
292
248
 
293
- if (typeof application.management === 'undefined' && config.management) {
294
- application.management = config.management
249
+ // Both applications rely on the same capability default, so they do collide
250
+ if (typeof hostname === 'undefined' && typeof otherHostname === 'undefined') {
251
+ return true
295
252
  }
296
253
 
297
- return application
254
+ // Only one of the two defaults is unknown, so nothing can be concluded here
255
+ if (typeof hostname === 'undefined' || typeof otherHostname === 'undefined') {
256
+ return false
257
+ }
258
+
259
+ return hostname === otherHostname
298
260
  }
299
261
 
300
- function canonicalPath (path) {
301
- try {
302
- return realpathSync(path)
303
- } catch {
304
- return resolvePath(path)
305
- }
262
+ function describeDeclaredPorts (listener) {
263
+ const { port, last } = listener
264
+ return port === last ? `port ${port}` : `ports ${port}-${last}, one per worker`
306
265
  }
307
266
 
308
- // An autoloaded directory and an explicitly configured entry with the same id are the same application
309
- // only when they point to the same place: either the configured path resolves to the autoloaded
310
- // directory, or the entry is external and the autoloaded directory is where "resolve" would put it.
311
- // When they don't, returns a description of the configured entry to report in the error.
312
- function conflictingApplicationSource (root, resolvedApplicationsPath, existing, entryPath) {
313
- if (existing.path) {
314
- // The path still contains a placeholder, which means the environment variable was not replaced.
315
- // There is nothing to compare in that case.
316
- if (existing.path.match(/^\{.*\}$/)) {
317
- return null
267
+ // Rejects applications whose declared ports overlap before any of them is started, so that the
268
+ // failure does not depend on which worker happens to report its URL first. This is best effort:
269
+ // applications whose port cannot be determined here are checked when their workers start.
270
+ function verifyApplicationsPorts (applications) {
271
+ const listeners = []
272
+
273
+ for (const application of applications) {
274
+ // The validated capability configuration is kept on the application (resolvedConfig), so the
275
+ // declared listener is read from it directly rather than being stashed on a symbol during a
276
+ // separate config-loading pass, as it once was.
277
+ const listener = declaredListener(application.resolvedConfig)
278
+
279
+ // Dynamic scaling changes how many ports the application ends up using, so leave it to the start time check
280
+ if (!listener || application.workers?.dynamic) {
281
+ continue
318
282
  }
319
283
 
320
- const existingPath = isAbsolute(existing.path) ? existing.path : resolvePath(root, existing.path)
321
-
322
- return canonicalPath(existingPath) === canonicalPath(entryPath) ? null : `the path "${existing.path}"`
284
+ const workers = listener.perWorker ? (application.workers?.static ?? 1) : 1
285
+ listeners.push({ ...listener, id: application.id, last: listener.port + workers - 1 })
323
286
  }
324
287
 
325
- if (existing.url) {
326
- const resolvedPath = join(resolvedApplicationsPath, existing.id)
288
+ for (let i = 0; i < listeners.length; i++) {
289
+ for (let j = i + 1; j < listeners.length; j++) {
290
+ const first = listeners[i]
291
+ const second = listeners[j]
327
292
 
328
- return canonicalPath(resolvedPath) === canonicalPath(entryPath) ? null : `the URL "${existing.url}"`
329
- }
293
+ const port = Math.max(first.port, second.port)
330
294
 
331
- return null
295
+ if (port > Math.min(first.last, second.last) || !declaredListenersOverlap(first, second)) {
296
+ continue
297
+ }
298
+
299
+ throw new ApplicationsPortsOverlapError(
300
+ first.id,
301
+ describeDeclaredPorts(first),
302
+ second.id,
303
+ describeDeclaredPorts(second),
304
+ port
305
+ )
306
+ }
307
+ }
332
308
  }
333
309
 
334
- function isApplicationEnabled (application, environment) {
335
- const { enabled } = application
310
+ // Everything an application needs regardless of how its capability was determined, taken from the
311
+ // loader's envelope before any worker exists rather than by loading the application's config file.
312
+ export function finalizeApplication (config, application) {
313
+ // Validate and coerce per-service workers
314
+ parseApplicationWorkers(application, config)
336
315
 
337
- if (typeof enabled === 'undefined') {
338
- return true
339
- }
316
+ application.dependencies ??= []
317
+ application.localUrl = `http://${application.id}.plt.local`
340
318
 
341
- if (typeof enabled === 'string') {
342
- return enabled !== 'false'
319
+ if (typeof application.watch === 'undefined') {
320
+ application.watch = config.watch
343
321
  }
344
322
 
345
- if (typeof enabled === 'object' && enabled !== null) {
346
- return enabled[environment] ?? true
323
+ if (typeof application.management === 'undefined' && config.management) {
324
+ application.management = config.management
347
325
  }
348
326
 
349
- return enabled
327
+ return application
350
328
  }
351
329
 
352
- export async function transform (config, _, context) {
353
- const production = context?.isProduction ?? context?.production
354
- const environment = production ? 'production' : 'development'
355
- const applications = [...(config.applications ?? []), ...(config.services ?? []), ...(config.web ?? [])]
356
-
357
- const watchType = typeof config.watch
358
- if (watchType === 'string') {
359
- config.watch = config.watch === 'true'
360
- } else if (watchType === 'undefined') {
361
- config.watch = !production
362
- }
363
-
364
- // Migrate the old verticalScaler property, only applied if the new settings are not set, otherwise workers takes precedence
365
- // TODO: Remove in the next major version
366
- if (config.verticalScaler) {
367
- config.workers ??= {}
368
- config.workers.total ??= config.verticalScaler.maxTotalWorkers
369
- config.workers.dynamic ??= config.verticalScaler.enabled
370
- config.workers.minimum ??= config.verticalScaler.minWorkers ?? 1
371
- config.workers.maximum ??= config.verticalScaler.maxWorkers ?? config.verticalScaler.maxTotalWorkers ?? 1
372
- config.workers.maxMemory ??= config.verticalScaler.maxTotalMemory
373
-
374
- if (config.verticalScaler.cooldownSec) {
375
- config.workers.cooldown ??= config.verticalScaler.cooldownSec * 1000
376
- }
377
- if (config.verticalScaler.gracePeriod) {
378
- config.workers.gracePeriod ??= config.verticalScaler.gracePeriod
379
- }
380
- if (config.verticalScaler.scaleUpELU) {
381
- config.workers.scaleUpELU ??= config.verticalScaler.scaleUpELU
382
- }
383
- if (config.verticalScaler.scaleDownELU) {
384
- config.workers.scaleDownELU ??= config.verticalScaler.scaleDownELU
330
+ /*
331
+ The preparation step. The loader resolved the path, selected the capability and imported its schema
332
+ subpath — which carries skipTracingHooks and modulesToLoad — all main-side, before any worker
333
+ existed. So there is nothing to discover here, and in particular no application config file to
334
+ re-read: that is the whole point of evaluating configuration exactly once per load.
335
+ */
336
+ export async function prepareRuntimeApplication (config, application) {
337
+ // resolvedConfig replaces the config file path in workerData: the worker receives the validated
338
+ // capability payload as data and never re-reads a file.
339
+ application.resolvedConfig = application.config ?? {}
340
+ application.config = undefined
341
+
342
+ /*
343
+ A module application names an npm package as its capability instead of describing one in a file.
344
+ The package is resolved from the runtime root, and its own directory (sourcePath) is kept
345
+ separate from the application's writable root (path) — which the module treats as a working
346
+ directory and which need not exist yet. Keyed on moduleApplication, not module: a regular
347
+ application also carries a module (its detected capability), and must not be resolved this way.
348
+ */
349
+ if (application.moduleApplication) {
350
+ if (!application.path) {
351
+ throw new InvalidArgumentError(`Application "${application.id}" must define path when module is set`)
385
352
  }
386
353
 
387
- if (config.verticalScaler.applications) {
388
- for (const appId in config.verticalScaler.applications) {
389
- let appConfig = applications.find((app) => app.id === appId)
390
- if (!appConfig) {
391
- appConfig = { id: appId, workers: {} }
392
- applications.push(appConfig)
393
- }
394
-
395
- const scaleConfig = config.verticalScaler.applications[appId]
396
- const workersConfig = appConfig.workers
397
-
398
- workersConfig.minimum ??= scaleConfig.minWorkers ?? config.workers.minimum
399
- workersConfig.maximum ??= scaleConfig.maxWorkers ?? config.workers.maximum
400
-
401
- if (scaleConfig.scaleUpELU) {
402
- workersConfig.scaleUpELU ??= scaleConfig.scaleUpELU
403
- }
404
- if (scaleConfig.scaleDownELU) {
405
- workersConfig.scaleDownELU ??= scaleConfig.scaleDownELU
406
- }
407
- }
354
+ try {
355
+ application.sourcePath = dirname(findPackageJSON(application.module, resolvePath(config[kMetadata].root, 'noop.js')))
356
+ } catch (error) {
357
+ throw new MissingDependencyError(application.module, { cause: error })
408
358
  }
409
359
 
410
- config.verticalScaler = undefined
360
+ application.moduleRoot = config[kMetadata].root
361
+ await createDirectory(application.path)
411
362
  }
412
363
 
413
- if (config.autoload) {
414
- const { exclude = [], mappings = {} } = config.autoload
415
- let { path } = config.autoload
416
-
417
- // Capture these before the loop, as config is shadowed in its body
418
- const root = config[kMetadata].root
419
- const resolvedApplicationsPath = resolvePath(root, config.resolvedApplicationsBasePath ?? 'external')
420
-
421
- path = resolvePath(root, path)
422
- const entries = await readdir(path, { withFileTypes: true })
423
-
424
- for (let i = 0; i < entries.length; ++i) {
425
- const entry = entries[i]
426
-
427
- if (exclude.includes(entry.name) || !entry.isDirectory()) {
428
- continue
429
- }
430
-
431
- const mapping = mappings[entry.name] ?? {}
432
- const id = mapping.id ?? entry.name
433
- const entryPath = join(path, entry.name)
434
-
435
- let config
436
- const configFilename = mapping.config ?? (await findConfigurationFile(entryPath))
437
-
438
- if (typeof configFilename === 'string') {
439
- config = join(entryPath, configFilename)
440
- }
441
-
442
- const application = { id, config, path: entryPath, ...mapping }
443
- const existingApplicationId = applications.findIndex(application => application.id === id)
444
-
445
- if (existingApplicationId !== -1) {
446
- const existing = applications[existingApplicationId]
364
+ application.type = application.module ?? 'unknown'
365
+ application.skipTracingHooks = application.capabilityMetadata?.skipTracingHooks ?? false
447
366
 
448
- // Merging on the id alone can boot local code where the configuration named a repository, as
449
- // the autoloaded path survives next to the configured url. Reject the duplicate instead: an id
450
- // is the mesh hostname, the injected PLT_<ID>_URL name, the metrics label and the argument of
451
- // "wattpm inject", so two different applications cannot share one.
452
- if (isApplicationEnabled(existing, environment)) {
453
- const source = conflictingApplicationSource(root, resolvedApplicationsPath, existing, entryPath)
367
+ // This is needed to work around a Rust bug on dylibs.
368
+ const modulesToLoad = application.capabilityMetadata?.modulesToLoad ?? []
454
369
 
455
- if (source) {
456
- throw new ApplicationIdCollisionError(id, entryPath, source)
457
- }
458
- }
370
+ if (modulesToLoad.length > 0 && application.path) {
371
+ const _require = createRequire(application.sourcePath ?? application.path)
459
372
 
460
- applications[existingApplicationId] = { ...application, ...existing }
461
- } else {
462
- applications.push(application)
373
+ for (const m of modulesToLoad) {
374
+ try {
375
+ loadModule(_require, _require.resolve(m)).catch(() => {})
376
+ } catch {
377
+ // A module the capability names but the application cannot resolve is not fatal here; the
378
+ // worker reports it with its own context when it actually needs it.
463
379
  }
464
380
  }
465
381
  }
466
382
 
467
- for (let i = applications.length - 1; i >= 0; --i) {
468
- if (!isApplicationEnabled(applications[i], environment)) {
469
- applications.splice(i, 1)
470
- }
471
- }
383
+ return finalizeApplication(config, application)
384
+ }
472
385
 
386
+ // The half of the transform the loader does not own: inspector options, worker counts, the
387
+ // per-application preparation, and the runtime-level normalizations. This is reached directly,
388
+ // having already expanded autoload and resolved enabled in the eval worker.
389
+ export async function finalizeConfiguration (config, applications, context, production, prepare) {
473
390
  config.inspectorOptions = undefined
474
391
  parseInspectorOptions(config, context?.inspect, context?.inspectBreak)
475
392
 
476
- let hasValidEntrypoint = false
477
-
478
393
  // Root-level workers
479
- parseWorkers(config, 'Runtime', { static: 1, dynamic: false })
480
- const defaultWorkers = config.workers
394
+ parseRuntimeWorkers(config)
481
395
 
482
396
  for (let i = 0; i < applications.length; ++i) {
483
- const application = await prepareApplication(config, applications[i], defaultWorkers)
484
-
485
- if (application.entrypoint) {
486
- hasValidEntrypoint = true
487
- }
397
+ await prepare(config, applications[i])
488
398
  }
489
399
 
490
- // If there is no entrypoint, autodetect one
491
- if (!config.entrypoint) {
492
- // If there is only one application, it becomes the entrypoint
493
- if (applications.length === 1) {
494
- applications[0].entrypoint = true
495
- config.entrypoint = applications[0].id
496
- hasValidEntrypoint = true
497
- } else {
498
- // Search if exactly application uses @platformatic/gateway
499
- const gateways = []
500
-
501
- for (const application of applications) {
502
- if (!application.config) {
503
- continue
504
- }
505
-
506
- if (application.type === '@platformatic/gateway') {
507
- gateways.push(application.id)
508
- }
509
- }
510
-
511
- if (gateways.length === 1) {
512
- applications.find(s => s.id === gateways[0]).entrypoint = true
513
- config.entrypoint = gateways[0]
514
- hasValidEntrypoint = true
515
- }
516
- }
517
- }
518
-
519
- if (!hasValidEntrypoint && config.entrypoint && !context.allowMissingEntrypoint) {
520
- throw new InvalidEntrypointError(config.entrypoint)
521
- }
400
+ verifyApplicationsPorts(applications)
522
401
 
523
402
  if (typeof config.metrics === 'boolean') {
524
403
  config.metrics = {
@@ -557,3 +436,27 @@ export async function transform (config, _, context) {
557
436
 
558
437
  return config
559
438
  }
439
+
440
+ /*
441
+ The transform. What is absent is the point:
442
+
443
+ - no alias merging, because `services` and `web` do not exist;
444
+ - no autoload expansion and no `enabled` filtering, because both ran in the root eval worker,
445
+ which is the only place either runs. Leaving them reachable here would let a second expansion
446
+ re-merge entries and read the filesystem after the authoritative snapshot already exists;
447
+ - no verticalScaler migration, which the schema audit removes.
448
+
449
+ The `autoload` declaration survives as data beside the expanded list — `GET /metadata` reports it
450
+ and `applications:add --save` needs it to decide whether a new application belongs in a mapping
451
+ or as an explicit entry. It is carried, never re-executed.
452
+ */
453
+ export async function transformConfiguration (config, _, context) {
454
+ const production = context?.isProduction ?? context?.production
455
+ const applications = config.applications ?? []
456
+
457
+ if (typeof config.watch === 'undefined') {
458
+ config.watch = !production
459
+ }
460
+
461
+ return finalizeConfiguration(config, applications, context, production, prepareRuntimeApplication)
462
+ }