@platformatic/runtime 3.4.1 → 3.5.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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/config.d.ts +224 -77
  3. package/eslint.config.js +3 -5
  4. package/index.d.ts +73 -24
  5. package/index.js +173 -29
  6. package/lib/config.js +279 -197
  7. package/lib/errors.js +126 -34
  8. package/lib/generator.js +640 -0
  9. package/lib/logger.js +43 -41
  10. package/lib/management-api.js +109 -118
  11. package/lib/prom-server.js +202 -16
  12. package/lib/runtime.js +1963 -585
  13. package/lib/scheduler.js +119 -0
  14. package/lib/schema.js +22 -234
  15. package/lib/shared-http-cache.js +43 -0
  16. package/lib/upgrade.js +6 -8
  17. package/lib/utils.js +6 -61
  18. package/lib/version.js +7 -0
  19. package/lib/versions/v1.36.0.js +2 -4
  20. package/lib/versions/v1.5.0.js +2 -4
  21. package/lib/versions/v2.0.0.js +3 -5
  22. package/lib/versions/v3.0.0.js +16 -0
  23. package/lib/worker/controller.js +302 -0
  24. package/lib/worker/http-cache.js +171 -0
  25. package/lib/worker/interceptors.js +190 -10
  26. package/lib/worker/itc.js +146 -59
  27. package/lib/worker/main.js +220 -81
  28. package/lib/worker/messaging.js +182 -0
  29. package/lib/worker/round-robin-map.js +62 -0
  30. package/lib/worker/shared-context.js +22 -0
  31. package/lib/worker/symbols.js +14 -5
  32. package/package.json +47 -38
  33. package/schema.json +1383 -55
  34. package/help/compile.txt +0 -8
  35. package/help/help.txt +0 -5
  36. package/help/start.txt +0 -21
  37. package/index.test-d.ts +0 -41
  38. package/lib/build-server.js +0 -69
  39. package/lib/compile.js +0 -98
  40. package/lib/dependencies.js +0 -59
  41. package/lib/generator/README.md +0 -32
  42. package/lib/generator/errors.js +0 -10
  43. package/lib/generator/runtime-generator.d.ts +0 -37
  44. package/lib/generator/runtime-generator.js +0 -498
  45. package/lib/start.js +0 -190
  46. package/lib/worker/app.js +0 -278
  47. package/lib/worker/default-stackable.js +0 -33
  48. package/lib/worker/metrics.js +0 -122
  49. package/runtime.mjs +0 -54
package/lib/config.js CHANGED
@@ -1,52 +1,177 @@
1
- 'use strict'
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'
14
+ import { isAbsolute, join, resolve as resolvePath } from 'node:path'
15
+ import {
16
+ InspectAndInspectBrkError,
17
+ InspectorHostError,
18
+ InspectorPortError,
19
+ InvalidArgumentError,
20
+ InvalidEntrypointError,
21
+ MissingEntrypointError
22
+ } from './errors.js'
23
+ import { schema } from './schema.js'
24
+ import { upgrade } from './upgrade.js'
25
+
26
+ // Validate and coerce workers values early to avoid runtime hangs when invalid
27
+ function coercePositiveInteger (value) {
28
+ if (typeof value === 'number') {
29
+ if (!Number.isInteger(value) || value < 1) return null
30
+ return value
31
+ }
32
+ if (typeof value === 'string') {
33
+ // Trim to handle accidental spaces
34
+ const trimmed = value.trim()
35
+ if (trimmed.length === 0) return null
36
+ const num = Number(trimmed)
37
+ if (!Number.isFinite(num) || !Number.isInteger(num) || num < 1) return null
38
+ return num
39
+ }
40
+ return null
41
+ }
2
42
 
3
- const { readdir } = require('node:fs/promises')
4
- const { join, resolve: pathResolve } = require('node:path')
43
+ function raiseInvalidWorkersError (location, received, hint) {
44
+ const extra = hint ? ` (${hint})` : ''
45
+ throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
46
+ }
5
47
 
6
- const ConfigManager = require('@platformatic/config')
48
+ export function autoDetectPprofCapture (config) {
49
+ const require = createRequire(import.meta.url)
7
50
 
8
- const errors = require('./errors')
9
- const { schema } = require('./schema')
10
- const upgrade = require('./upgrade')
11
- const { parseArgs } = require('node:util')
51
+ let pprofCapturePath
52
+ try {
53
+ pprofCapturePath = require.resolve('@platformatic/wattpm-pprof-capture')
54
+ } catch (err) {
55
+ // No-op
56
+ }
12
57
 
13
- async function _transformConfig (configManager, args) {
14
- const config = configManager.current
58
+ // Add to preload if not already present
59
+ if (!config.preload) {
60
+ config.preload = []
61
+ } else if (typeof config.preload === 'string') {
62
+ config.preload = [config.preload]
63
+ }
15
64
 
16
- let services
17
- if (config.web?.length) {
18
- if (config.services?.length) {
19
- throw new errors.InvalidServicesWithWebError()
65
+ if (pprofCapturePath && !config.preload.includes(pprofCapturePath)) {
66
+ config.preload.push(pprofCapturePath)
67
+ }
68
+
69
+ return config
70
+ }
71
+
72
+ export async function wrapInRuntimeConfig (config, context) {
73
+ let applicationId = 'main'
74
+ try {
75
+ const packageJson = JSON.parse(await readFile(join(config[kMetadata].root, 'package.json'), 'utf-8'))
76
+ applicationId = packageJson?.name ?? 'main'
77
+
78
+ if (applicationId.startsWith('@')) {
79
+ applicationId = applicationId.split('/')[1]
20
80
  }
81
+ } catch (err) {
82
+ // on purpose, the package.json might be missing
83
+ }
21
84
 
22
- services = config.web
23
- } else {
24
- services = config.services ?? []
85
+ // If the application supports its (so far, only @platformatic/service and descendants)
86
+ const { hostname, port, http2, https } = config.server ?? {}
87
+ const server = { hostname, port, http2, https }
88
+ const production = context?.isProduction ?? context?.production
89
+
90
+ // Important: do not change the order of the properties in this object
91
+ /* c8 ignore next */
92
+ const wrapped = {
93
+ $schema: schema.$id,
94
+ server,
95
+ watch: !production,
96
+ ...omitProperties(config.runtime ?? {}, runtimeUnwrappablePropertiesList),
97
+ entrypoint: applicationId,
98
+ applications: [
99
+ {
100
+ id: applicationId,
101
+ path: config[kMetadata].root,
102
+ config: config[kMetadata].path
103
+ }
104
+ ]
25
105
  }
26
106
 
107
+ return loadConfiguration(wrapped, context?.schema ?? schema, {
108
+ validationOptions,
109
+ transform,
110
+ upgrade,
111
+ replaceEnv: true,
112
+ root: config[kMetadata].root,
113
+ ...context
114
+ })
115
+ }
116
+
117
+ export function parseInspectorOptions (config, inspect, inspectBreak) {
118
+ const hasInspect = inspect != null
119
+ const hasInspectBrk = inspectBreak != null
120
+
121
+ if (hasInspect && hasInspectBrk) {
122
+ throw new InspectAndInspectBrkError()
123
+ }
124
+
125
+ const value = inspectBreak ?? inspect
126
+
127
+ if (!value) {
128
+ return
129
+ }
130
+
131
+ let host = '127.0.0.1'
132
+ let port = 9229
133
+
134
+ if (typeof value === 'string' && value.length > 0) {
135
+ const splitAt = value.lastIndexOf(':')
136
+
137
+ if (splitAt === -1) {
138
+ port = value
139
+ } else {
140
+ host = value.substring(0, splitAt)
141
+ port = value.substring(splitAt + 1)
142
+ }
143
+
144
+ port = Number.parseInt(port, 10)
145
+
146
+ if (!(port === 0 || (port >= 1024 && port <= 65535))) {
147
+ throw new InspectorPortError()
148
+ }
149
+
150
+ if (!host) {
151
+ throw new InspectorHostError()
152
+ }
153
+ }
154
+
155
+ config.inspectorOptions = { host, port, breakFirstLine: hasInspectBrk, watchDisabled: !!config.watch }
156
+ config.watch = false
157
+ }
158
+
159
+ export async function transform (config, _, context) {
160
+ const production = context?.isProduction ?? context?.production
161
+ const applications = [...(config.applications ?? []), ...(config.services ?? []), ...(config.web ?? [])]
162
+
27
163
  const watchType = typeof config.watch
28
164
  if (watchType === 'string') {
29
165
  config.watch = config.watch === 'true'
30
166
  } else if (watchType === 'undefined') {
31
- const { values } = parseArgs({
32
- args,
33
- strict: false,
34
- options: { production: { type: 'boolean', short: 'p', default: false } }
35
- })
36
-
37
- config.watch = !values.production
167
+ config.watch = !production
38
168
  }
39
169
 
40
170
  if (config.autoload) {
41
171
  const { exclude = [], mappings = {} } = config.autoload
42
172
  let { path } = config.autoload
43
173
 
44
- // This is a hack, but it's the only way to not fix the paths for the autoloaded services
45
- // while we are upgrading the config
46
- if (configManager._fixPaths) {
47
- path = pathResolve(configManager.dirname, path)
48
- }
49
-
174
+ path = resolvePath(config[kMetadata].root, path)
50
175
  const entries = await readdir(path, { withFileTypes: true })
51
176
 
52
177
  for (let i = 0; i < entries.length; ++i) {
@@ -61,218 +186,175 @@ async function _transformConfig (configManager, args) {
61
186
  const entryPath = join(path, entry.name)
62
187
 
63
188
  let config
64
- const configFilename = mapping.config ?? (await ConfigManager.findConfigFile(entryPath))
189
+ const configFilename = mapping.config ?? (await findConfigurationFile(entryPath))
65
190
 
66
191
  if (typeof configFilename === 'string') {
67
192
  config = join(entryPath, configFilename)
68
193
  }
69
194
 
70
- const service = { id, config, path: entryPath, useHttp: !!mapping.useHttp }
71
- const existingServiceId = services.findIndex(service => service.id === id)
195
+ const application = {
196
+ id,
197
+ config,
198
+ path: entryPath,
199
+ useHttp: !!mapping.useHttp,
200
+ health: mapping.health,
201
+ dependencies: mapping.dependencies
202
+ }
203
+ const existingApplicationId = applications.findIndex(application => application.id === id)
72
204
 
73
- if (existingServiceId !== -1) {
74
- services[existingServiceId] = service
205
+ if (existingApplicationId !== -1) {
206
+ applications[existingApplicationId] = { ...application, ...applications[existingApplicationId] }
75
207
  } else {
76
- services.push(service)
208
+ applications.push(application)
77
209
  }
78
210
  }
79
211
  }
80
212
 
81
- configManager.current.serviceMap = new Map()
82
- configManager.current.inspectorOptions = undefined
213
+ config.inspectorOptions = undefined
214
+ parseInspectorOptions(config, context?.inspect, context?.inspectBreak)
83
215
 
84
216
  let hasValidEntrypoint = false
85
217
 
86
- for (let i = 0; i < services.length; ++i) {
87
- const service = services[i]
88
-
89
- if (configManager._fixPaths && service.config) {
90
- service.config = pathResolve(service.path, service.config)
91
- }
92
- service.entrypoint = service.id === config.entrypoint
93
- service.dependencies = []
94
- service.localServiceEnvVars = new Map()
95
- service.localUrl = `http://${service.id}.plt.local`
96
-
97
- if (typeof service.watch === 'undefined') {
98
- service.watch = config.watch
99
- }
100
-
101
- if (service.entrypoint) {
102
- hasValidEntrypoint = true
218
+ // Root-level workers
219
+ if (typeof config.workers !== 'undefined') {
220
+ const coerced = coercePositiveInteger(config.workers)
221
+ if (coerced === null) {
222
+ const raw = config.workers
223
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
224
+ raiseInvalidWorkersError('Runtime', config.workers, hint)
103
225
  }
104
-
105
- configManager.current.serviceMap.set(service.id, service)
226
+ config.workers = coerced
106
227
  }
107
228
 
108
- // If there is no entrypoint, autodetect one
109
- if (!config.entrypoint) {
110
- // If there is only one service, it becomes the entrypoint
111
- if (services.length === 1) {
112
- services[0].entrypoint = true
113
- config.entrypoint = services[0].id
114
- hasValidEntrypoint = true
115
- } else {
116
- // Search if exactly service uses @platformatic/composer
117
- const composers = []
118
-
119
- for (const service of services) {
120
- if (!service.config) {
121
- continue
122
- }
123
-
124
- const manager = new ConfigManager({ source: pathResolve(service.path, service.config) })
125
- await manager.parse()
126
- const config = manager.current
127
- const type = config.$schema ? ConfigManager.matchKnownSchema(config.$schema) : undefined
229
+ for (let i = 0; i < applications.length; ++i) {
230
+ const application = applications[i]
128
231
 
129
- if (type === 'composer') {
130
- composers.push(service.id)
131
- }
132
- }
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
+ }
133
237
 
134
- if (composers.length === 1) {
135
- services.find(s => s.id === composers[0]).entrypoint = true
136
- config.entrypoint = composers[0]
137
- hasValidEntrypoint = true
138
- }
238
+ if (application.path && application.config) {
239
+ application.config = resolvePath(application.path, application.config)
139
240
  }
140
- }
141
241
 
142
- if (!hasValidEntrypoint) {
143
- throw typeof config.entrypoint !== 'undefined'
144
- ? new errors.InvalidEntrypointError(config.entrypoint)
145
- : new errors.MissingEntrypointError()
146
- }
242
+ try {
243
+ let pkg
147
244
 
148
- configManager.current.services = services
149
- configManager.current.web = undefined
245
+ if (application.config) {
246
+ const config = await loadConfiguration(application.config)
247
+ pkg = await loadConfigurationModule(application.path, config)
150
248
 
151
- if (configManager.current.restartOnError === true) {
152
- configManager.current.restartOnError = 5000
153
- }
154
- }
249
+ application.type = extractModuleFromSchemaUrl(config, true).module
250
+ application.skipTelemetryHooks = pkg.skipTelemetryHooks
251
+ } else {
252
+ const { moduleName, capability } = await importCapabilityAndConfig(application.path)
253
+ pkg = capability
155
254
 
156
- async function platformaticRuntime () {
157
- // No-op. Here for consistency with other app types.
158
- }
255
+ application.type = moduleName
256
+ }
159
257
 
160
- platformaticRuntime[Symbol.for('skip-override')] = true
161
- platformaticRuntime.schema = schema
162
- platformaticRuntime.configType = 'runtime'
163
- platformaticRuntime.configManagerConfig = {
164
- version: require('../package.json').version,
165
- schema,
166
- allowToWatch: ['.env'],
167
- schemaOptions: {
168
- useDefaults: true,
169
- coerceTypes: true,
170
- allErrors: true,
171
- strict: false
172
- },
173
- async transformConfig (args) {
174
- await _transformConfig(this, args)
175
- },
176
- upgrade
177
- }
258
+ application.skipTelemetryHooks = pkg.skipTelemetryHooks
178
259
 
179
- async function wrapConfigInRuntimeConfig ({ configManager, args }) {
180
- let serviceId = 'main'
181
- try {
182
- const packageJson = join(configManager.dirname, 'package.json')
183
- serviceId = require(packageJson).name || 'main'
184
- if (serviceId.startsWith('@')) {
185
- serviceId = serviceId.split('/')[1]
260
+ // This is needed to work around Rust bug on dylibs:
261
+ // https://github.com/rust-lang/rust/issues/91979
262
+ // https://github.com/rollup/rollup/issues/5761
263
+ const _require = createRequire(application.path)
264
+ for (const m of pkg.modulesToLoad ?? []) {
265
+ const toLoad = _require.resolve(m)
266
+ loadModule(_require, toLoad).catch(() => {})
267
+ }
268
+ } catch (err) {
269
+ // This should not happen, it happens on running some unit tests if we prepare the runtime
270
+ // when not all the applications configs are available. Given that we are running this only
271
+ // to ddetermine the type of the application, it's safe to ignore this error and default to unknown
272
+ application.type = 'unknown'
186
273
  }
187
- } catch (err) {
188
- // on purpose, the package.json might be missing
189
- }
190
274
 
191
- /* c8 ignore next */
192
- const wrapperConfig = {
193
- $schema: schema.$id,
194
- entrypoint: serviceId,
195
- watch: true,
196
- services: [
197
- {
198
- id: serviceId,
199
- path: configManager.dirname,
200
- config: configManager.fullPath
275
+ // Validate and coerce per-service workers
276
+ if (typeof application.workers !== 'undefined') {
277
+ const coerced = coercePositiveInteger(application.workers)
278
+ if (coerced === null) {
279
+ const raw = config.application?.[i]?.workers
280
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
281
+ raiseInvalidWorkersError(`Service "${application.id}"`, application.workers, hint)
201
282
  }
202
- ]
203
- }
204
- const cm = new ConfigManager({
205
- source: wrapperConfig,
206
- schema,
207
- schemaOptions: {
208
- useDefaults: true,
209
- coerceTypes: true,
210
- allErrors: true,
211
- strict: false
212
- },
213
- transformConfig (args) {
214
- return _transformConfig(this, args)
283
+ application.workers = coerced
215
284
  }
216
- })
217
-
218
- await cm.parseAndValidate()
219
- return cm
220
- }
221
285
 
222
- function parseInspectorOptions (configManager) {
223
- const { current, args } = configManager
224
- const hasInspect = 'inspect' in args
225
- const hasInspectBrk = 'inspect-brk' in args
226
- let inspectFlag
286
+ application.entrypoint = application.id === config.entrypoint
287
+ application.dependencies ??= []
288
+ application.localUrl = `http://${application.id}.plt.local`
227
289
 
228
- if (hasInspect) {
229
- inspectFlag = args.inspect
290
+ if (typeof application.watch === 'undefined') {
291
+ application.watch = config.watch
292
+ }
230
293
 
231
- if (hasInspectBrk) {
232
- throw new errors.InspectAndInspectBrkError()
294
+ if (application.entrypoint) {
295
+ hasValidEntrypoint = true
233
296
  }
234
- } else if (hasInspectBrk) {
235
- inspectFlag = args['inspect-brk']
236
297
  }
237
298
 
238
- if (inspectFlag !== undefined) {
239
- let host = '127.0.0.1'
240
- let port = 9229
241
-
242
- if (typeof inspectFlag === 'string' && inspectFlag.length > 0) {
243
- const splitAt = inspectFlag.lastIndexOf(':')
244
-
245
- if (splitAt === -1) {
246
- port = inspectFlag
247
- } else {
248
- host = inspectFlag.substring(0, splitAt)
249
- port = inspectFlag.substring(splitAt + 1)
250
- }
299
+ // If there is no entrypoint, autodetect one
300
+ if (!config.entrypoint) {
301
+ // If there is only one application, it becomes the entrypoint
302
+ if (applications.length === 1) {
303
+ applications[0].entrypoint = true
304
+ config.entrypoint = applications[0].id
305
+ hasValidEntrypoint = true
306
+ } else {
307
+ // Search if exactly application uses @platformatic/gateway
308
+ const gateways = []
251
309
 
252
- port = Number.parseInt(port, 10)
310
+ for (const application of applications) {
311
+ if (!application.config) {
312
+ continue
313
+ }
253
314
 
254
- if (!(port === 0 || (port >= 1024 && port <= 65535))) {
255
- throw new errors.InspectorPortError()
315
+ if (application.type === '@platformatic/gateway') {
316
+ gateways.push(application.id)
317
+ }
256
318
  }
257
319
 
258
- if (!host) {
259
- throw new errors.InspectorHostError()
320
+ if (gateways.length === 1) {
321
+ applications.find(s => s.id === gateways[0]).entrypoint = true
322
+ config.entrypoint = gateways[0]
323
+ hasValidEntrypoint = true
260
324
  }
261
325
  }
326
+ }
262
327
 
263
- current.inspectorOptions = {
264
- host,
265
- port,
266
- breakFirstLine: hasInspectBrk,
267
- watchDisabled: !!current.watch
328
+ if (!hasValidEntrypoint && !context.allowMissingEntrypoint) {
329
+ if (config.entrypoint) {
330
+ throw new InvalidEntrypointError(config.entrypoint)
331
+ } else if (applications.length >= 1) {
332
+ throw new MissingEntrypointError()
268
333
  }
334
+ // If there are no applications, and no entrypoint it's an empty app.
335
+ // It won't start, but we should be able to parse and operate on it,
336
+ // like adding other applications.
337
+ }
269
338
 
270
- current.watch = false
339
+ config.applications = applications
340
+ config.web = undefined
341
+ config.services = undefined
342
+ config.logger ??= {}
343
+
344
+ if (production) {
345
+ // Any value below 10 is considered as "immediate restart" and won't be processed via setTimeout or similar
346
+ // Important: do not use 2 otherwise ajv will convert to boolean `true`
347
+ config.restartOnError = 2
348
+ } else {
349
+ if (config.restartOnError === true) {
350
+ config.restartOnError = 5000
351
+ } else if (config.restartOnError < 0) {
352
+ config.restartOnError = 0
353
+ }
271
354
  }
272
- }
273
355
 
274
- module.exports = {
275
- parseInspectorOptions,
276
- platformaticRuntime,
277
- wrapConfigInRuntimeConfig
356
+ // Auto-detect and add pprof capture if available
357
+ autoDetectPprofCapture(config)
358
+
359
+ return config
278
360
  }