@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/config.js CHANGED
@@ -1,28 +1,17 @@
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 { readdir, readFile } from 'node:fs/promises'
13
- import { createRequire } from 'node:module'
1
+ import { createDirectory, kMetadata, loadModule } from '@platformatic/foundation'
14
2
  import { loadAdditionalApplications } from '@platformatic/foundation/lib/v4/index.js'
15
- import { isAbsolute, join, resolve as resolvePath } from 'node:path'
3
+ import { createRequire, findPackageJSON } from 'node:module'
4
+ import { dirname, resolve as resolvePath } from 'node:path'
16
5
  import { fileURLToPath } from 'node:url'
17
6
 
18
7
  import {
8
+ ApplicationsPortsOverlapError,
19
9
  InspectAndInspectBrkError,
20
10
  InspectorHostError,
21
11
  InspectorPortError,
22
- InvalidArgumentError
12
+ InvalidArgumentError,
13
+ MissingDependencyError
23
14
  } from './errors.js'
24
- import { schema } from './schema.js'
25
- import { upgrade } from './upgrade.js'
26
15
 
27
16
  // The runtime package's own entry point, which is where the bundled capability copies live.
28
17
  const runtimeScopePath = fileURLToPath(new URL('../index.js', import.meta.url))
@@ -83,6 +72,10 @@ function parseWorkers (config, prefix, defaultWorkers = { static: 1, dynamic: fa
83
72
  config.workers = {}
84
73
  }
85
74
 
75
+ // What this entry asked for, before the defaults are folded in.
76
+ const declaredMinimum = config.workers.minimum
77
+ const declaredStatic = config.workers.static
78
+
86
79
  // Fill missing values from defaults
87
80
  for (const key of ['minimum', 'maximum', 'static', 'dynamic']) {
88
81
  if (typeof config.workers[key] === 'undefined' && typeof defaultWorkers[key] !== 'undefined') {
@@ -97,6 +90,16 @@ function parseWorkers (config, prefix, defaultWorkers = { static: 1, dynamic: fa
97
90
  config.workers.maximum = t
98
91
  }
99
92
 
93
+ /*
94
+ `static` is how many workers the application starts with, and an entry that declared its own
95
+ `minimum` has already said. The runtime-wide default -- which is 1 unless the project set one --
96
+ must not override it: `{ dynamic: true, minimum: 2 }` otherwise starts a single worker and
97
+ leaves the autoscaler to climb to the floor the entry asked for.
98
+ */
99
+ if (typeof declaredStatic === 'undefined' && typeof declaredMinimum !== 'undefined') {
100
+ config.workers.static = declaredMinimum
101
+ }
102
+
100
103
  if (typeof config.workers.static === 'undefined') {
101
104
  config.workers.static = config.workers.minimum
102
105
  }
@@ -132,49 +135,6 @@ export function autoDetectPprofCapture (config) {
132
135
  return config
133
136
  }
134
137
 
135
- export async function wrapInRuntimeConfig (config, context) {
136
- let applicationId = 'main'
137
- try {
138
- const packageJson = JSON.parse(await readFile(join(config[kMetadata].root, 'package.json'), 'utf-8'))
139
- applicationId = packageJson?.name ?? 'main'
140
-
141
- if (applicationId.startsWith('@')) {
142
- applicationId = applicationId.split('/')[1]
143
- }
144
- } catch (err) {
145
- // on purpose, the package.json might be missing
146
- }
147
-
148
- const production = context?.isProduction ?? context?.production
149
-
150
- const runtimeConfig = config.runtime ?? {}
151
-
152
- // Important: do not change the order of the properties in this object
153
- /* c8 ignore next */
154
- const wrapped = {
155
- $schema: schema.$id,
156
- watch: !production,
157
- ...omitProperties(runtimeConfig, runtimeUnwrappablePropertiesList),
158
- applications: [
159
- {
160
- id: applicationId,
161
- path: config[kMetadata].root,
162
- config: config[kMetadata].path,
163
- ...(runtimeConfig.application ?? {})
164
- }
165
- ]
166
- }
167
-
168
- return loadConfiguration(wrapped, context?.schema ?? schema, {
169
- validationOptions,
170
- transform,
171
- upgrade,
172
- replaceEnv: true,
173
- root: config[kMetadata].root,
174
- ...context
175
- })
176
- }
177
-
178
138
  export function parseInspectorOptions (config, inspect, inspectBreak) {
179
139
  const hasInspect = inspect != null
180
140
  const hasInspectBrk = inspectBreak != null
@@ -229,16 +189,6 @@ export function parseInspectorOptions (config, inspect, inspectBreak) {
229
189
  export async function prepareAddedApplications (config, entries, existingIds = []) {
230
190
  const metadata = config[kMetadata]
231
191
 
232
- if (!metadata?.v4) {
233
- const prepared = []
234
-
235
- for (const entry of entries) {
236
- prepared.push(await prepareApplication(config, entry, config.workers))
237
- }
238
-
239
- return prepared
240
- }
241
-
242
192
  const { applications } = await loadAdditionalApplications({
243
193
  configPath: metadata.path,
244
194
  entries,
@@ -247,6 +197,8 @@ export async function prepareAddedApplications (config, entries, existingIds = [
247
197
  command: metadata.v4.command,
248
198
  mode: metadata.v4.mode,
249
199
  production: metadata.v4.production,
200
+ realEnv: metadata.v4.realEnv,
201
+ customEnvFile: metadata.v4.customEnvFile,
250
202
  // The runtime is the fallback scope for capability resolution, the same as at boot.
251
203
  runtimeScope: runtimeScopePath
252
204
  })
@@ -260,58 +212,98 @@ export async function prepareAddedApplications (config, entries, existingIds = [
260
212
  return prepared
261
213
  }
262
214
 
263
- export async function prepareApplication (config, application, defaultWorkers) {
264
- // We need to have absolute paths here, ot the `loadConfig` will fail
265
- // Make sure we don't resolve if env var was not replaced
266
- if (application.path && !isAbsolute(application.path) && !application.path.match(/^\{.*\}$/)) {
267
- application.path = resolvePath(config[kMetadata].root, application.path)
215
+ const hostnameWildcards = new Set(['0.0.0.0', '::', '[::]'])
216
+
217
+ // The listener an application declares in its own configuration, when it can be determined without
218
+ // starting it. A port coming from an environment variable which was not replaced, an ephemeral port
219
+ // and a missing server block all yield null, in which case only the start time check applies.
220
+ function declaredListener (applicationConfig) {
221
+ const server = applicationConfig?.server
222
+
223
+ if (!server) {
224
+ return null
268
225
  }
269
226
 
270
- if (application.path && application.config) {
271
- application.config = resolvePath(application.path, application.config)
227
+ const port = Number(server.port)
228
+
229
+ if (!Number.isInteger(port) || port <= 0) {
230
+ return null
272
231
  }
273
232
 
274
- // Skip capability detection for external services (url without path)
275
- // These services will have their path resolved later in runtime.js #setupApplication
276
- // Attempting to detect capability here would cause slow glob operations on the cwd
277
- if (application.url && !application.path) {
278
- application.type = 'unknown'
279
- } else {
280
- try {
281
- let pkg
233
+ return { port, hostname: server.hostname, perWorker: server.portAssignment === 'perWorkerIncrement' }
234
+ }
282
235
 
283
- if (application.config) {
284
- const applicationConfig = await loadConfiguration(application.config)
285
- pkg = await loadConfigurationModule(application.path, applicationConfig)
236
+ // Two declared listeners can only be compared when both hostnames are known. When a hostname is
237
+ // omitted the capability picks its own default, so unless the other side is a wildcard - which
238
+ // overlaps with anything - the comparison is left to the start time check.
239
+ function declaredListenersOverlap (first, second) {
240
+ const hostname = first.hostname?.toLowerCase()
241
+ const otherHostname = second.hostname?.toLowerCase()
286
242
 
287
- application.type = extractModuleFromSchemaUrl(applicationConfig, true).module
288
- application.skipTelemetryHooks = pkg.skipTelemetryHooks
289
- } else {
290
- const { moduleName, capability } = await importCapabilityAndConfig(application.path)
291
- pkg = capability
243
+ // A wildcard overlaps with any other hostname, known or not
244
+ if (hostnameWildcards.has(hostname) || hostnameWildcards.has(otherHostname)) {
245
+ return true
246
+ }
292
247
 
293
- application.type = moduleName
294
- }
248
+ // Both applications rely on the same capability default, so they do collide
249
+ if (typeof hostname === 'undefined' && typeof otherHostname === 'undefined') {
250
+ return true
251
+ }
252
+
253
+ // Only one of the two defaults is unknown, so nothing can be concluded here
254
+ if (typeof hostname === 'undefined' || typeof otherHostname === 'undefined') {
255
+ return false
256
+ }
295
257
 
296
- application.skipTelemetryHooks = pkg.skipTelemetryHooks
258
+ return hostname === otherHostname
259
+ }
297
260
 
298
- // This is needed to work around Rust bug on dylibs:
299
- // https://github.com/rust-lang/rust/issues/91979
300
- // https://github.com/rollup/rollup/issues/5761
301
- const _require = createRequire(application.path)
302
- for (const m of pkg.modulesToLoad ?? []) {
303
- const toLoad = _require.resolve(m)
304
- loadModule(_require, toLoad).catch(() => {})
305
- }
306
- } catch (err) {
307
- // This should not happen, it happens on running some unit tests if we prepare the runtime
308
- // when not all the applications configs are available. Given that we are running this only
309
- // to ddetermine the type of the application, it's safe to ignore this error and default to unknown
310
- application.type = 'unknown'
261
+ function describeDeclaredPorts (listener) {
262
+ const { port, last } = listener
263
+ return port === last ? `port ${port}` : `ports ${port}-${last}, one per worker`
264
+ }
265
+
266
+ // Rejects applications whose declared ports overlap before any of them is started, so that the
267
+ // failure does not depend on which worker happens to report its URL first. This is best effort:
268
+ // applications whose port cannot be determined here are checked when their workers start.
269
+ function verifyApplicationsPorts (applications) {
270
+ const listeners = []
271
+
272
+ for (const application of applications) {
273
+ // v4 keeps the validated capability configuration on the application (resolvedConfig), so the
274
+ // declared listener is read from it directly rather than being stashed on a symbol during a
275
+ // separate config-loading pass, as the v3 path did.
276
+ const listener = declaredListener(application.resolvedConfig)
277
+
278
+ // Dynamic scaling changes how many ports the application ends up using, so leave it to the start time check
279
+ if (!listener || application.workers?.dynamic) {
280
+ continue
311
281
  }
282
+
283
+ const workers = listener.perWorker ? (application.workers?.static ?? 1) : 1
284
+ listeners.push({ ...listener, id: application.id, last: listener.port + workers - 1 })
312
285
  }
313
286
 
314
- return finalizeApplication(config, application, defaultWorkers)
287
+ for (let i = 0; i < listeners.length; i++) {
288
+ for (let j = i + 1; j < listeners.length; j++) {
289
+ const first = listeners[i]
290
+ const second = listeners[j]
291
+
292
+ const port = Math.max(first.port, second.port)
293
+
294
+ if (port > Math.min(first.last, second.last) || !declaredListenersOverlap(first, second)) {
295
+ continue
296
+ }
297
+
298
+ throw new ApplicationsPortsOverlapError(
299
+ first.id,
300
+ describeDeclaredPorts(first),
301
+ second.id,
302
+ describeDeclaredPorts(second),
303
+ port
304
+ )
305
+ }
306
+ }
315
307
  }
316
308
 
317
309
  // Everything an application needs regardless of how its capability was determined. v3 discovers
@@ -337,7 +329,7 @@ export function finalizeApplication (config, application, defaultWorkers) {
337
329
 
338
330
  /*
339
331
  The v4 preparation. The loader resolved the path, selected the capability and imported its schema
340
- subpath — which carries skipTelemetryHooks and modulesToLoad — all main-side, before any worker
332
+ subpath — which carries skipTracingHooks and modulesToLoad — all main-side, before any worker
341
333
  existed. So there is nothing to discover here, and in particular no application config file to
342
334
  re-read: that is the whole point of evaluating configuration exactly once per load.
343
335
  */
@@ -346,14 +338,37 @@ export async function prepareV4Application (config, application, defaultWorkers)
346
338
  // capability payload as data and never re-reads a file.
347
339
  application.resolvedConfig = application.config ?? {}
348
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`)
352
+ }
353
+
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 })
358
+ }
359
+
360
+ application.moduleRoot = config[kMetadata].root
361
+ await createDirectory(application.path)
362
+ }
363
+
349
364
  application.type = application.module ?? 'unknown'
350
- application.skipTelemetryHooks = application.capabilityMetadata?.skipTelemetryHooks ?? false
365
+ application.skipTracingHooks = application.capabilityMetadata?.skipTracingHooks ?? false
351
366
 
352
367
  // This is needed to work around a Rust bug on dylibs, as in the v3 path.
353
368
  const modulesToLoad = application.capabilityMetadata?.modulesToLoad ?? []
354
369
 
355
370
  if (modulesToLoad.length > 0 && application.path) {
356
- const _require = createRequire(application.path)
371
+ const _require = createRequire(application.sourcePath ?? application.path)
357
372
 
358
373
  for (const m of modulesToLoad) {
359
374
  try {
@@ -368,130 +383,6 @@ export async function prepareV4Application (config, application, defaultWorkers)
368
383
  return finalizeApplication(config, application, defaultWorkers)
369
384
  }
370
385
 
371
- function isApplicationEnabled (application, environment) {
372
- const { enabled } = application
373
-
374
- if (typeof enabled === 'undefined') {
375
- return true
376
- }
377
-
378
- if (typeof enabled === 'string') {
379
- return enabled !== 'false'
380
- }
381
-
382
- if (typeof enabled === 'object' && enabled !== null) {
383
- return enabled[environment] ?? true
384
- }
385
-
386
- return enabled
387
- }
388
-
389
- export async function transform (config, _, context) {
390
- const production = context?.isProduction ?? context?.production
391
- const environment = production ? 'production' : 'development'
392
- const applications = [...(config.applications ?? []), ...(config.services ?? []), ...(config.web ?? [])]
393
-
394
- const watchType = typeof config.watch
395
- if (watchType === 'string') {
396
- config.watch = config.watch === 'true'
397
- } else if (watchType === 'undefined') {
398
- config.watch = !production
399
- }
400
-
401
- // Migrate the old verticalScaler property, only applied if the new settings are not set, otherwise workers takes precedence
402
- // TODO: Remove in the next major version
403
- if (config.verticalScaler) {
404
- config.workers ??= {}
405
- config.workers.total ??= config.verticalScaler.maxTotalWorkers
406
- config.workers.dynamic ??= config.verticalScaler.enabled
407
- config.workers.minimum ??= config.verticalScaler.minWorkers ?? 1
408
- config.workers.maximum ??= config.verticalScaler.maxWorkers ?? config.verticalScaler.maxTotalWorkers ?? 1
409
- config.workers.maxMemory ??= config.verticalScaler.maxTotalMemory
410
-
411
- if (config.verticalScaler.cooldownSec) {
412
- config.workers.cooldown ??= config.verticalScaler.cooldownSec * 1000
413
- }
414
- if (config.verticalScaler.gracePeriod) {
415
- config.workers.gracePeriod ??= config.verticalScaler.gracePeriod
416
- }
417
- if (config.verticalScaler.scaleUpELU) {
418
- config.workers.scaleUpELU ??= config.verticalScaler.scaleUpELU
419
- }
420
- if (config.verticalScaler.scaleDownELU) {
421
- config.workers.scaleDownELU ??= config.verticalScaler.scaleDownELU
422
- }
423
-
424
- if (config.verticalScaler.applications) {
425
- for (const appId in config.verticalScaler.applications) {
426
- let appConfig = applications.find((app) => app.id === appId)
427
- if (!appConfig) {
428
- appConfig = { id: appId, workers: {} }
429
- applications.push(appConfig)
430
- }
431
-
432
- const scaleConfig = config.verticalScaler.applications[appId]
433
- const workersConfig = appConfig.workers ??= {}
434
-
435
- workersConfig.minimum ??= scaleConfig.minWorkers ?? config.workers.minimum
436
- workersConfig.maximum ??= scaleConfig.maxWorkers ?? config.workers.maximum
437
-
438
- if (scaleConfig.scaleUpELU) {
439
- workersConfig.scaleUpELU ??= scaleConfig.scaleUpELU
440
- }
441
- if (scaleConfig.scaleDownELU) {
442
- workersConfig.scaleDownELU ??= scaleConfig.scaleDownELU
443
- }
444
- }
445
- }
446
-
447
- config.verticalScaler = undefined
448
- }
449
-
450
- if (config.autoload) {
451
- const { exclude = [], mappings = {} } = config.autoload
452
- let { path } = config.autoload
453
-
454
- path = resolvePath(config[kMetadata].root, path)
455
- const entries = await readdir(path, { withFileTypes: true })
456
-
457
- for (let i = 0; i < entries.length; ++i) {
458
- const entry = entries[i]
459
-
460
- if (exclude.includes(entry.name) || !entry.isDirectory()) {
461
- continue
462
- }
463
-
464
- const mapping = mappings[entry.name] ?? {}
465
- const id = mapping.id ?? entry.name
466
- const entryPath = join(path, entry.name)
467
-
468
- let config
469
- const configFilename = mapping.config ?? (await findConfigurationFile(entryPath))
470
-
471
- if (typeof configFilename === 'string') {
472
- config = join(entryPath, configFilename)
473
- }
474
-
475
- const application = { id, config, path: entryPath, ...mapping }
476
- const existingApplicationId = applications.findIndex(application => application.id === id)
477
-
478
- if (existingApplicationId !== -1) {
479
- applications[existingApplicationId] = { ...application, ...applications[existingApplicationId] }
480
- } else {
481
- applications.push(application)
482
- }
483
- }
484
- }
485
-
486
- for (let i = applications.length - 1; i >= 0; --i) {
487
- if (!isApplicationEnabled(applications[i], environment)) {
488
- applications.splice(i, 1)
489
- }
490
- }
491
-
492
- return finalizeConfiguration(config, applications, context, production, prepareApplication)
493
- }
494
-
495
386
  // The half of the transform the loader does not own: inspector options, worker counts, the
496
387
  // per-application preparation, and the runtime-level normalizations. v4 reaches this directly,
497
388
  // having already expanded autoload and resolved enabled in the eval worker.
@@ -507,6 +398,8 @@ export async function finalizeConfiguration (config, applications, context, prod
507
398
  await prepare(config, applications[i], defaultWorkers)
508
399
  }
509
400
 
401
+ verifyApplicationsPorts(applications)
402
+
510
403
  if (typeof config.metrics === 'boolean') {
511
404
  config.metrics = {
512
405
  enabled: config.metrics,
package/lib/errors.js CHANGED
@@ -15,6 +15,10 @@ export const AddressInUseError = createError(
15
15
  `${ERROR_PREFIX}_EADDR_IN_USE`,
16
16
  'Port %d is already in use by applications "%s" and "%s"'
17
17
  )
18
+ export const ApplicationsPortsOverlapError = createError(
19
+ `${ERROR_PREFIX}_APPLICATIONS_PORTS_OVERLAP`,
20
+ 'The applications "%s" (%s) and "%s" (%s) are both configured to listen on port %d. Applications cannot share a port: change one of the ports or make them listen on different hostnames.'
21
+ )
18
22
  export const WorkerAddressInUseError = createError(
19
23
  `${ERROR_PREFIX}_WORKER_EADDR_IN_USE`,
20
24
  'Port %d is already in use by another worker of the application "%s". Multiple workers can share a port only when the reusePort feature is available in your OS, otherwise set "server.portAssignment" to "perWorkerIncrement" in the application configuration or use a single worker.'
@@ -103,6 +107,10 @@ export const NoConfigFileFoundError = createError(
103
107
  "No config file found for application '%s'"
104
108
  )
105
109
  export const MissingDependencyError = createError(`${ERROR_PREFIX}_MISSING_DEPENDENCY`, 'Missing dependency: "%s"')
110
+ export const InvalidApplicationModuleError = createError(
111
+ `${ERROR_PREFIX}_INVALID_APPLICATION_MODULE`,
112
+ 'Application module "%s" must export a create function'
113
+ )
106
114
  export const InspectAndInspectBrkError = createError(
107
115
  `${ERROR_PREFIX}_INSPECT_AND_INSPECT_BRK`,
108
116
  '--inspect and --inspect-brk cannot be used together'
@@ -232,3 +240,7 @@ export const WorkerInterceptorJoinTimeoutError = createError(
232
240
  `${ERROR_PREFIX}_WORKER_INTERCEPTOR_JOIN_TIMEOUT`,
233
241
  'The %s failed to join the mesh network in %dms.'
234
242
  )
243
+ export const MixedServingStateError = createError(
244
+ `${ERROR_PREFIX}_MIXED_SERVING_STATE`,
245
+ 'The workers of the application "%s" disagree about how it serves: %s. One application is one thing; workers that disagree about whether it serves HTTP would have the mesh route a share of requests to a worker that destroys them.'
246
+ )