@platformatic/runtime 3.64.0 → 3.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -200,6 +200,40 @@ export interface RuntimeMetadata {
200
200
  platformaticVersion: string
201
201
  }
202
202
 
203
+ export interface WorkerLifecycleEvent {
204
+ application: string
205
+ worker: number
206
+ workersCount?: number
207
+ }
208
+
209
+ export interface ProfileCapturedEvent extends WorkerLifecycleEvent {
210
+ id: string
211
+ type: 'cpu' | 'heap'
212
+ timestamp: number
213
+ sampleCount: number | null
214
+ }
215
+
216
+ export interface RuntimeHealthSignal {
217
+ type: string
218
+ max?: number
219
+ mean?: number
220
+ p99?: number
221
+ timestamp?: number
222
+ [key: string]: unknown
223
+ }
224
+
225
+ export interface WorkerCurrentHealth {
226
+ elu: number
227
+ heapUsed?: number | null
228
+ heapTotal?: number | null
229
+ }
230
+
231
+ export interface HealthMetricsEvent extends WorkerLifecycleEvent {
232
+ id: string
233
+ currentHealth: WorkerCurrentHealth | null
234
+ healthSignals: RuntimeHealthSignal[]
235
+ }
236
+
203
237
  export declare class ManagementClient {
204
238
  constructor (allowedOperations?: string[])
205
239
 
@@ -400,6 +434,21 @@ export declare class Runtime extends EventEmitter {
400
434
  // create()/start().
401
435
  setApplicationConfigPatch (applicationId: string, patch: Array<Record<string, unknown>>): void
402
436
  removeApplicationConfigPatch (applicationId: string): void
437
+
438
+ // Typed worker lifecycle events. Catch-all overloads preserve EventEmitter's
439
+ // open event surface so listeners for other runtime events still type-check.
440
+ on (event: 'application:worker:started', listener: (event: WorkerLifecycleEvent) => void): this
441
+ on (event: 'application:worker:exited', listener: (event: WorkerLifecycleEvent) => void): this
442
+ on (event: 'application:worker:profile:captured', listener: (event: ProfileCapturedEvent) => void): this
443
+ on (event: 'application:worker:health:metrics', listener: (event: HealthMetricsEvent) => void): this
444
+ on (event: string | symbol, listener: (...args: any[]) => void): this
445
+
446
+ off (event: 'application:worker:started', listener: (event: WorkerLifecycleEvent) => void): this
447
+ off (event: 'application:worker:exited', listener: (event: WorkerLifecycleEvent) => void): this
448
+ off (event: 'application:worker:profile:captured', listener: (event: ProfileCapturedEvent) => void): this
449
+ off (event: 'application:worker:health:metrics', listener: (event: HealthMetricsEvent) => void): this
450
+ off (event: string | symbol, listener: (...args: any[]) => void): this
451
+
403
452
  getSchedulerJobs (): SchedulerJob[]
404
453
  pauseSchedulerJob (name: string): Promise<SchedulerJob>
405
454
  resumeSchedulerJob (name: string): Promise<SchedulerJob>
package/index.js CHANGED
@@ -69,6 +69,8 @@ export async function loadConfiguration (configOrRoot, sourceOrConfig, context)
69
69
  })
70
70
 
71
71
  if (config.envfile) {
72
+ // The context is optional, so it might not have been provided at all
73
+ context ??= {}
72
74
  context.envFile = config.envfile
73
75
  }
74
76
 
package/lib/errors.js CHANGED
@@ -178,7 +178,7 @@ export const FailedToStopExtensionError = createError(
178
178
 
179
179
  export const InvalidExtensionError = createError(
180
180
  `${ERROR_PREFIX}_INVALID_EXTENSION`,
181
- 'The extension "%s" must export a setup function as its default export'
181
+ 'The extension "%s" must export a setup function as its default export or as a named "setup" export'
182
182
  )
183
183
 
184
184
  export const ReservedITCHandlerNameError = createError(
@@ -18,6 +18,53 @@ import { prepareApplication } from './config.js'
18
18
 
19
19
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
20
20
 
21
+ const loggerLevels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace']
22
+
23
+ /*
24
+ Endpoints which are frequently polled by clients (the CLI, the dashboard or any other
25
+ monitoring tool) do not log the request/response pair to avoid flooding the runtime logs.
26
+ */
27
+ const quiet = { logLevel: 'warn' }
28
+
29
+ /*
30
+ The management API is closed after the runtime has been closed (see the "closed" event handler
31
+ in startManagementApi), so that the POST /stop endpoint can reply. By then the runtime logger
32
+ has been destroyed and writing to its destination throws.
33
+
34
+ To avoid that, the logger is never captured: it is resolved - and its children are recreated -
35
+ on each call, so that logging becomes a no-op as soon as the runtime replaces its logger with
36
+ the abstract one.
37
+ */
38
+ function createRuntimeLoggerProxy (getParent, bindings, options) {
39
+ let parent
40
+ let logger
41
+
42
+ function resolve () {
43
+ const current = getParent()
44
+
45
+ if (current !== parent) {
46
+ parent = current
47
+ logger = bindings ? parent.child(bindings, options) : parent
48
+ }
49
+
50
+ return logger
51
+ }
52
+
53
+ const proxy = {
54
+ child (childBindings, childOptions) {
55
+ return createRuntimeLoggerProxy(resolve, childBindings, childOptions)
56
+ }
57
+ }
58
+
59
+ for (const level of loggerLevels) {
60
+ proxy[level] = function (...args) {
61
+ return resolve()[level](...args)
62
+ }
63
+ }
64
+
65
+ return proxy
66
+ }
67
+
21
68
  export async function managementApiPlugin (app, opts) {
22
69
  app.register(fastifyAccepts)
23
70
 
@@ -43,16 +90,16 @@ export async function managementApiPlugin (app, opts) {
43
90
  return removed
44
91
  }
45
92
 
46
- app.get('/status', async () => {
93
+ app.get('/status', quiet, async () => {
47
94
  const status = runtime.getRuntimeStatus()
48
95
  return { status }
49
96
  })
50
97
 
51
- app.get('/metadata', async () => {
98
+ app.get('/metadata', quiet, async () => {
52
99
  return runtime.getRuntimeMetadata()
53
100
  })
54
101
 
55
- app.get('/config', async request => {
102
+ app.get('/config', quiet, async request => {
56
103
  const metadata = request.query.metadata === 'true'
57
104
  const rawConfig = await runtime.getRuntimeConfig(metadata)
58
105
 
@@ -64,7 +111,7 @@ export async function managementApiPlugin (app, opts) {
64
111
  return rawConfig
65
112
  })
66
113
 
67
- app.get('/env', async () => {
114
+ app.get('/env', quiet, async () => {
68
115
  return { ...process.env, ...runtime.getRuntimeEnv() }
69
116
  })
70
117
 
@@ -96,7 +143,7 @@ export async function managementApiPlugin (app, opts) {
96
143
  return runtime.runSchedulerJob(request.params.name)
97
144
  })
98
145
 
99
- app.get('/applications', async () => {
146
+ app.get('/applications', quiet, async () => {
100
147
  return runtime.getApplications()
101
148
  })
102
149
 
@@ -144,9 +191,9 @@ export async function managementApiPlugin (app, opts) {
144
191
  return deleteApplications(request.body, reply)
145
192
  })
146
193
 
147
- app.get('/applications/:id', async request => {
194
+ app.get('/applications/:id', quiet, async request => {
148
195
  const { id } = request.params
149
- app.log.debug('get application details', { id })
196
+ app.log.debug({ id }, 'get application details')
150
197
  return runtime.getApplicationDetails(id)
151
198
  })
152
199
 
@@ -154,45 +201,45 @@ export async function managementApiPlugin (app, opts) {
154
201
  return deleteApplications([request.params.id], reply)
155
202
  })
156
203
 
157
- app.get('/applications/:id/config', async request => {
204
+ app.get('/applications/:id/config', quiet, async request => {
158
205
  const { id } = request.params
159
- app.log.debug('get application config', { id })
206
+ app.log.debug({ id }, 'get application config')
160
207
  return runtime.getApplicationConfig(id)
161
208
  })
162
209
 
163
- app.get('/applications/:id/env', async request => {
210
+ app.get('/applications/:id/env', quiet, async request => {
164
211
  const { id } = request.params
165
- app.log.debug('get application config', { id })
212
+ app.log.debug({ id }, 'get application env')
166
213
  return runtime.getApplicationEnv(id)
167
214
  })
168
215
 
169
- app.get('/applications/:id/openapi-schema', async request => {
216
+ app.get('/applications/:id/openapi-schema', quiet, async request => {
170
217
  const { id } = request.params
171
- app.log.debug('get openapi-schema', { id })
218
+ app.log.debug({ id }, 'get openapi-schema')
172
219
  return runtime.getApplicationOpenapiSchema(id)
173
220
  })
174
221
 
175
- app.get('/applications/:id/graphql-schema', async request => {
222
+ app.get('/applications/:id/graphql-schema', quiet, async request => {
176
223
  const { id } = request.params
177
- app.log.debug('get graphql-schema', { id })
224
+ app.log.debug({ id }, 'get graphql-schema')
178
225
  return runtime.getApplicationGraphqlSchema(id)
179
226
  })
180
227
 
181
228
  app.post('/applications/:id/start', async request => {
182
229
  const { id } = request.params
183
- app.log.debug('start application', { id })
230
+ app.log.debug({ id }, 'start application')
184
231
  await runtime.startApplication(id)
185
232
  })
186
233
 
187
234
  app.post('/applications/:id/stop', async request => {
188
235
  const { id } = request.params
189
- app.log.debug('stop application', { id })
236
+ app.log.debug({ id }, 'stop application')
190
237
  await runtime.stopApplication(id)
191
238
  })
192
239
 
193
- app.all('/applications/:id/proxy/*', async (request, reply) => {
240
+ app.all('/applications/:id/proxy/*', quiet, async (request, reply) => {
194
241
  const { id, '*': requestUrl } = request.params
195
- app.log.debug('proxy request', { id, requestUrl })
242
+ app.log.debug({ id, requestUrl }, 'proxy request')
196
243
 
197
244
  delete request.headers.connection
198
245
  delete request.headers['content-length']
@@ -217,7 +264,7 @@ export async function managementApiPlugin (app, opts) {
217
264
 
218
265
  app.post('/applications/:id/pprof/start', async (request, reply) => {
219
266
  const { id } = request.params
220
- app.log.debug('start profiling', { id })
267
+ app.log.debug({ id }, 'start profiling')
221
268
 
222
269
  const options = request.body || {}
223
270
  const result = await runtime.startApplicationProfiling(id, options)
@@ -226,7 +273,7 @@ export async function managementApiPlugin (app, opts) {
226
273
 
227
274
  app.post('/applications/:id/pprof/stop', async (request, reply) => {
228
275
  const { id } = request.params
229
- app.log.debug('stop profiling', { id })
276
+ app.log.debug({ id }, 'stop profiling')
230
277
 
231
278
  const options = request.body || {}
232
279
  const profileData = await runtime.stopApplicationProfiling(id, options)
@@ -248,14 +295,14 @@ export async function managementApiPlugin (app, opts) {
248
295
 
249
296
  app.post('/applications/:id/heap-snapshot', async (request, reply) => {
250
297
  const { id } = request.params
251
- app.log.debug('take heap snapshot', { id })
298
+ app.log.debug({ id }, 'take heap snapshot')
252
299
 
253
300
  const stream = await runtime.takeApplicationHeapSnapshot(id)
254
301
  reply.type('application/octet-stream').send(stream)
255
302
  return reply
256
303
  })
257
304
 
258
- app.get('/metrics', { logLevel: 'debug' }, async (req, reply) => {
305
+ app.get('/metrics', quiet, async (req, reply) => {
259
306
  const config = await runtime.getRuntimeConfig()
260
307
 
261
308
  if (config.metrics?.enabled === false) {
@@ -280,7 +327,7 @@ export async function managementApiPlugin (app, opts) {
280
327
  })
281
328
 
282
329
  // TODO: Remove in next major version - deprecated endpoint
283
- app.get('/metrics/live', { websocket: true }, async socket => {
330
+ app.get('/metrics/live', { ...quiet, websocket: true }, async socket => {
284
331
  const config = await runtime.getRuntimeConfig()
285
332
 
286
333
  if (config.metrics?.enabled === false) {
@@ -326,7 +373,7 @@ export async function managementApiPlugin (app, opts) {
326
373
  socket.on('close', cleanup)
327
374
  })
328
375
 
329
- app.get('/logs/live', { websocket: true }, async socket => {
376
+ app.get('/logs/live', { ...quiet, websocket: true }, async socket => {
330
377
  runtime.addLoggerDestination(createWebSocketStream(socket))
331
378
  })
332
379
 
@@ -394,7 +441,10 @@ export async function startManagementApi (runtime, config) {
394
441
  socketPath = join(runtimePIDDir, 'socket')
395
442
  }
396
443
 
397
- const managementApi = fastify()
444
+ const managementApi = fastify({
445
+ loggerInstance: createRuntimeLoggerProxy(() => runtime.logger, { name: 'management-api' })
446
+ })
447
+
398
448
  managementApi.register(fastifyWebsocket)
399
449
  managementApi.register(managementApiPlugin, { runtime, prefix: '/api/v1' })
400
450
 
@@ -416,10 +466,29 @@ export async function startManagementApi (runtime, config) {
416
466
  */
417
467
  for (let i = 0; i < 5; i++) {
418
468
  try {
419
- await managementApi.listen({ path: socketPath })
469
+ // Fastify always logs listen() at info. The management API binds a private unix
470
+ // socket/pipe, so that message is noise and also breaks helpers that treat the first
471
+ // "listening at" log as the application URL. Downgrade it to debug for this call.
472
+ const { log } = managementApi
473
+ const originalInfo = log.info
474
+ log.info = function (...args) {
475
+ const text = typeof args[0] === 'string' ? args[0] : args[1]
476
+ if (typeof text === 'string' && text.startsWith('Server listening at')) {
477
+ return log.debug(...args)
478
+ }
479
+
480
+ return originalInfo.apply(this, args)
481
+ }
482
+
483
+ try {
484
+ await managementApi.listen({ path: socketPath })
485
+ } finally {
486
+ log.info = originalInfo
487
+ }
488
+
420
489
  break
421
490
  } catch (e) {
422
- if (i === 5) {
491
+ if (i === 4) {
423
492
  throw e
424
493
  }
425
494
 
package/lib/runtime.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  executeInParallel,
6
6
  executeWithTimeout,
7
7
  features,
8
+ kEnvFileFallbackKeys,
8
9
  kMetadata,
9
10
  kTimeout,
10
11
  parseMemorySize
@@ -146,6 +147,24 @@ function formatMetricValue (value) {
146
147
  return `${value}`
147
148
  }
148
149
 
150
+ // Resolves the setup function of a runtime extension out of its module namespace.
151
+ //
152
+ // The canonical form is a default export, but transpilers and bundlers emit
153
+ // faux ESM modules, in which the real exports are properties of `module.exports`
154
+ // and therefore only reachable via an additional `default` hop. The setup
155
+ // function can also be exported as a named `setup` export, at both levels.
156
+ function resolveExtensionSetup (imported) {
157
+ const candidates = [imported?.default, imported?.setup, imported?.default?.default, imported?.default?.setup]
158
+
159
+ for (const candidate of candidates) {
160
+ if (typeof candidate === 'function') {
161
+ return candidate
162
+ }
163
+ }
164
+
165
+ return null
166
+ }
167
+
149
168
  // Always run operations in parallel to avoid deadlocks when services have dependencies
150
169
  const DEFAULT_CONCURRENCY = availableParallelism() * 2
151
170
  const MAX_BOOTSTRAP_ATTEMPTS = 5
@@ -301,10 +320,6 @@ export class Runtime extends EventEmitter {
301
320
 
302
321
  const config = this.#config
303
322
 
304
- if (config.managementApi) {
305
- this.#managementApi = await startManagementApi(this, config.managementApi)
306
- }
307
-
308
323
  if (config.metrics) {
309
324
  // Use the configured application label name for metrics (defaults to 'applicationId')
310
325
  this.#metricsLabelName = config.metrics.applicationLabel || 'applicationId'
@@ -321,12 +336,17 @@ export class Runtime extends EventEmitter {
321
336
  collectProcessMetrics(this.#processMetricsRegistry)
322
337
  }
323
338
 
324
- // Create the logger before extensions and health/metrics servers so that both can use it.
339
+ // Create the logger before the management API, the extensions and the health/metrics servers
340
+ // so that all of them can use it.
325
341
  const [logger, destination, context] = await createLogger(config)
326
342
  this.logger = logger
327
343
  this.#loggerDestination = destination
328
344
  this.#loggerContext = context
329
345
 
346
+ if (config.managementApi) {
347
+ this.#managementApi = await startManagementApi(this, config.managementApi)
348
+ }
349
+
330
350
  await this.#startOpenTelemetryMetricsForwarder(config.metrics?.opentelemetry)
331
351
 
332
352
  this.#createWorkersBroadcastChannel()
@@ -990,45 +1010,45 @@ export class Runtime extends EventEmitter {
990
1010
 
991
1011
  try {
992
1012
  // Bound the whole retrieval with a single timeout budget: resolving the
993
- // worker round-trips to it when ensureStarted is set, and the profile
994
- // pull does too — both hang if the worker event loop is blocked. Attach
1013
+ // workers round-trips to them when ensureStarted is set, and the profile
1014
+ // pulls do too — both hang if a worker event loop is blocked. Attach
995
1015
  // a noop handler so that a late settlement after the timeout does not
996
1016
  // surface as an unhandled rejection.
997
- const pull = (async () => {
998
- const service = await this.#getApplicationById(id, ensureStarted)
999
- const result = await sendViaITC(service, 'getLastProfile', {
1000
- ...options,
1001
- includeTimestamp: true,
1002
- includeSampleCount: true
1003
- })
1004
- return { service, result }
1005
- })()
1017
+ const pull = this.#pullLastProfiles(id, options, ensureStarted)
1006
1018
  pull.catch(() => {})
1007
1019
 
1008
1020
  const outcome = await executeWithTimeout(pull, timeout, kTimeout)
1009
1021
 
1010
1022
  if (outcome !== kTimeout) {
1011
- const { service, result } = outcome
1012
-
1013
- // An older capture module which does not support includeTimestamp
1014
- // returns the raw profile.
1015
- const value = result instanceof Uint8Array
1016
- ? { profile: result, timestamp: null, sampleCount: null }
1017
- : { sampleCount: null, ...result }
1018
-
1019
- // A strictly newer live window supersedes the preserved overload
1020
- // profile: prune it so the preserved copy naturally expires once the
1021
- // worker is healthy again and its profiles are being consumed.
1022
- if (value.timestamp != null) {
1023
- const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
1024
- const preserved = this.#lastOverloadProfiles.get(key)
1025
-
1026
- if (preserved && preserved.timestamp < value.timestamp) {
1027
- this.#lastOverloadProfiles.delete(key)
1023
+ let best = null
1024
+
1025
+ for (const { service, result } of outcome) {
1026
+ // An older capture module which does not support includeTimestamp
1027
+ // returns the raw profile.
1028
+ const value = result instanceof Uint8Array
1029
+ ? { profile: result, timestamp: null, sampleCount: null }
1030
+ : { sampleCount: null, ...result }
1031
+
1032
+ // A strictly newer live window supersedes the preserved overload
1033
+ // profile: prune it so the preserved copy naturally expires once the
1034
+ // worker is healthy again and its profiles are being consumed.
1035
+ if (value.timestamp != null) {
1036
+ const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
1037
+ const preserved = this.#lastOverloadProfiles.get(key)
1038
+
1039
+ if (preserved && preserved.timestamp < value.timestamp) {
1040
+ this.#lastOverloadProfiles.delete(key)
1041
+ }
1042
+ }
1043
+
1044
+ // For an application-level id the newest window across the workers
1045
+ // wins, mirroring the preserved overload profile fallback below.
1046
+ if (!best || (value.timestamp != null && (best.timestamp == null || value.timestamp > best.timestamp))) {
1047
+ best = value
1028
1048
  }
1029
1049
  }
1030
1050
 
1031
- return { ...value, preserved: false }
1051
+ return { ...best, preserved: false }
1032
1052
  }
1033
1053
 
1034
1054
  // The worker event loop is not responding (e.g. it is hard-blocked).
@@ -1057,6 +1077,62 @@ export class Runtime extends EventEmitter {
1057
1077
  throw error
1058
1078
  }
1059
1079
 
1080
+ // Pulls the last profile from the addressed worker, or from every worker of
1081
+ // the application when no explicit worker index is given: the application
1082
+ // "last profile" is the newest window among its workers, so one arbitrary
1083
+ // worker cannot answer for all of them. Per-worker failures with fallback
1084
+ // codes are ignored as long as at least one worker yields a profile.
1085
+ async #pullLastProfiles (id, options, ensureStarted) {
1086
+ const pullOptions = { ...options, includeTimestamp: true, includeSampleCount: true }
1087
+
1088
+ const pullWorker = async service => {
1089
+ const result = await sendViaITC(service, 'getLastProfile', pullOptions)
1090
+ return { service, result }
1091
+ }
1092
+
1093
+ if (/^.+:\d+$/.test(id)) {
1094
+ return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
1095
+ }
1096
+
1097
+ if (!this.#applications.has(id)) {
1098
+ throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
1099
+ }
1100
+
1101
+ const keys = this.#workers.getKeys(id)
1102
+
1103
+ // No worker is currently registered: resolve the id as usual so that the
1104
+ // canonical error is raised.
1105
+ if (keys.length === 0) {
1106
+ return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
1107
+ }
1108
+
1109
+ const settled = await Promise.allSettled(
1110
+ keys.map(async key => {
1111
+ const service = await this.#getWorkerByIdOrNext(id, key.split(':')[1], ensureStarted)
1112
+ return pullWorker(service)
1113
+ })
1114
+ )
1115
+
1116
+ const profiles = []
1117
+ let firstError
1118
+
1119
+ for (const outcome of settled) {
1120
+ if (outcome.status === 'fulfilled') {
1121
+ profiles.push(outcome.value)
1122
+ } else if (!kLastProfileFallbackCodes.has(outcome.reason?.code)) {
1123
+ throw outcome.reason
1124
+ } else {
1125
+ firstError ??= outcome.reason
1126
+ }
1127
+ }
1128
+
1129
+ if (profiles.length === 0) {
1130
+ throw firstError
1131
+ }
1132
+
1133
+ return profiles
1134
+ }
1135
+
1060
1136
  // The final profile of an overload pause is pushed by the worker and
1061
1137
  // preserved in the main thread (see the profile:overload listener), so that
1062
1138
  // the evidence of what saturated a worker survives the worker being blocked
@@ -2433,7 +2509,10 @@ export class Runtime extends EventEmitter {
2433
2509
  codeRangeSizeMb
2434
2510
  },
2435
2511
  inspectorOptions,
2436
- dirname: this.#root
2512
+ dirname: this.#root,
2513
+ // Keys of the worker environment which only come from an env file of the runtime:
2514
+ // the env file of the application is allowed to override those.
2515
+ envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
2437
2516
  },
2438
2517
  argv: applicationConfig.arguments,
2439
2518
  execArgv,
@@ -2746,7 +2825,7 @@ export class Runtime extends EventEmitter {
2746
2825
  if (attempt === MAX_BOOTSTRAP_ATTEMPTS) {
2747
2826
  const error = new RuntimeAbortedError({ cause: e })
2748
2827
  error.message = `Unable to initialize the ${errorLabel}.`
2749
- throw e
2828
+ throw error
2750
2829
  }
2751
2830
 
2752
2831
  if (e.code !== 'PLT_RUNTIME_APPLICATION_WORKER_EXIT') {
@@ -4276,13 +4355,15 @@ export class Runtime extends EventEmitter {
4276
4355
  for (const extension of extensions) {
4277
4356
  const { path, options } = typeof extension === 'string' ? { path: extension } : extension
4278
4357
 
4279
- let setup
4358
+ let imported
4280
4359
  try {
4281
- setup = (await import(pathToFileURL(path))).default
4360
+ imported = await import(pathToFileURL(path))
4282
4361
  } catch (e) {
4283
4362
  throw new FailedToLoadExtensionError(path, e.message, { cause: e })
4284
4363
  }
4285
4364
 
4365
+ const setup = resolveExtensionSetup(imported)
4366
+
4286
4367
  if (typeof setup !== 'function') {
4287
4368
  throw new InvalidExtensionError(path)
4288
4369
  }
@@ -24,7 +24,7 @@ import { resolve } from 'node:path'
24
24
  import { getActiveResourcesInfo } from 'node:process'
25
25
  import { workerData } from 'node:worker_threads'
26
26
  import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
27
- import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
27
+ import { ApplicationAlreadyStartedError, exitCodes, RuntimeNotStartedError } from '../errors.js'
28
28
  import { getApplicationUrl } from '../utils.js'
29
29
  import { markAsPlatformaticDispatcher, refreshGlobalDispatcher } from './interceptors.js'
30
30
 
@@ -43,7 +43,7 @@ function handleUnhandled (app, event, listeners, timeout, err, ...args) {
43
43
  logger.error({ err: ensureLoggableError(err) }, `The ${label} threw an ${event} event.`)
44
44
 
45
45
  // Give some time to the listeners, logger and ITC notifications to land before shutting down
46
- setTimeout(() => process.exit(1), timeout)
46
+ setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), timeout)
47
47
 
48
48
  for (const listener of listeners) {
49
49
  try {
package/lib/worker/itc.js CHANGED
@@ -167,6 +167,10 @@ export async function setupITC (controller, application, dispatcher, sharedConte
167
167
  }
168
168
  })
169
169
 
170
+ // ITC handlers run concurrently, so later start/stop requests must wait for
171
+ // the actual in-flight start operation rather than a controller event.
172
+ let controllerStartPromise
173
+
170
174
  const itc = new ITC({
171
175
  name: controller.applicationConfig.id + '-worker',
172
176
  port: parentPort,
@@ -175,14 +179,15 @@ export async function setupITC (controller, application, dispatcher, sharedConte
175
179
  const status = controller.getStatus()
176
180
 
177
181
  if (status === 'starting') {
178
- await once(controller, 'start')
182
+ await controllerStartPromise
179
183
  } else {
180
184
  // This gives a chance to a capability to perform custom logic
181
185
  const events = getEvents()
182
186
  events.emit('start')
183
187
 
184
188
  try {
185
- await controller.start()
189
+ controllerStartPromise = controller.start()
190
+ await controllerStartPromise
186
191
  } catch (e) {
187
192
  await controller.stop(true)
188
193
 
@@ -222,7 +227,7 @@ export async function setupITC (controller, application, dispatcher, sharedConte
222
227
  const status = controller.getStatus()
223
228
 
224
229
  if (!force && status === 'starting') {
225
- await once(controller, 'start')
230
+ await controllerStartPromise
226
231
  }
227
232
 
228
233
  if (force || status.startsWith('start')) {
@@ -11,12 +11,14 @@ import { addPinoInstrumentation } from '@platformatic/telemetry'
11
11
  import { Buffer } from 'node:buffer'
12
12
  import { subscribe } from 'node:diagnostics_channel'
13
13
  import { EventEmitter } from 'node:events'
14
+ import { readFile } from 'node:fs/promises'
14
15
  import { ServerResponse } from 'node:http'
15
16
  import inspector from 'node:inspector'
16
17
  import { hostname } from 'node:os'
17
18
  import { join, resolve } from 'node:path'
18
19
  import { setDefaultHighWaterMark } from 'node:stream'
19
20
  import { pathToFileURL } from 'node:url'
21
+ import { parseEnv } from 'node:util'
20
22
  import { threadId, workerData } from 'node:worker_threads'
21
23
  import pino from 'pino'
22
24
  import { install as installUndiciGlobals } from 'undici'
@@ -240,8 +242,21 @@ async function main () {
240
242
  const logger = getLogger()
241
243
  logger.debug({ envfile }, 'Loading envfile...')
242
244
 
245
+ // Note that process.loadEnvFile is not used here as it never overrides an already defined
246
+ // variable. The worker environment is seeded from the environment the runtime resolved, which
247
+ // might contain values coming from an env file of the runtime. Those are only defaults, so the
248
+ // env file of this application, which is more specific, is allowed to override them. Real
249
+ // environment variables are never overridden.
250
+ const envFileFallbackKeys = new Set(workerData.envFileFallbackKeys ?? [])
251
+
243
252
  try {
244
- process.loadEnvFile(envfile)
253
+ const applicationEnv = parseEnv(await readFile(envfile, 'utf-8'))
254
+
255
+ for (const [key, value] of Object.entries(applicationEnv)) {
256
+ if (!(key in process.env) || envFileFallbackKeys.has(key)) {
257
+ process.env[key] = value
258
+ }
259
+ }
245
260
  } catch {
246
261
  // Ignore if the file doesn't exist, similar to dotenv behavior
247
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "3.64.0",
3
+ "version": "3.66.0",
4
4
  "description": "Platformatic runtime",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -39,14 +39,14 @@
39
39
  "typescript": "^5.9.3",
40
40
  "undici-oidc-interceptor": "^0.9.0",
41
41
  "why-is-node-running": "^2.2.2",
42
- "@platformatic/db": "3.64.0",
43
- "@platformatic/composer": "3.64.0",
44
- "@platformatic/gateway": "3.64.0",
45
- "@platformatic/service": "3.64.0",
46
- "@platformatic/node": "3.64.0",
47
- "@platformatic/sql-graphql": "3.64.0",
48
- "@platformatic/wattpm-pprof-capture": "3.64.0",
49
- "@platformatic/sql-mapper": "3.64.0"
42
+ "@platformatic/composer": "3.66.0",
43
+ "@platformatic/gateway": "3.66.0",
44
+ "@platformatic/service": "3.66.0",
45
+ "@platformatic/sql-graphql": "3.66.0",
46
+ "@platformatic/db": "3.66.0",
47
+ "@platformatic/sql-mapper": "3.66.0",
48
+ "@platformatic/wattpm-pprof-capture": "3.66.0",
49
+ "@platformatic/node": "3.66.0"
50
50
  },
51
51
  "dependencies": {
52
52
  "@fastify/accepts": "^5.0.0",
@@ -77,13 +77,13 @@
77
77
  "undici": "^7.27.2",
78
78
  "undici-thread-interceptor": "^1.3.1",
79
79
  "ws": "^8.16.0",
80
- "@platformatic/foundation": "3.64.0",
81
- "@platformatic/globals": "3.64.0",
82
- "@platformatic/generators": "3.64.0",
83
- "@platformatic/itc": "3.64.0",
84
- "@platformatic/basic": "3.64.0",
85
- "@platformatic/metrics": "3.64.0",
86
- "@platformatic/telemetry": "3.64.0"
80
+ "@platformatic/basic": "3.66.0",
81
+ "@platformatic/foundation": "3.66.0",
82
+ "@platformatic/itc": "3.66.0",
83
+ "@platformatic/metrics": "3.66.0",
84
+ "@platformatic/globals": "3.66.0",
85
+ "@platformatic/generators": "3.66.0",
86
+ "@platformatic/telemetry": "3.66.0"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19.0"
@@ -99,6 +99,7 @@
99
99
  "gen-schema": "node lib/schema.js > schema.json",
100
100
  "gen-types": "json2ts > config.d.ts < schema.json",
101
101
  "build": "npm run gen-schema && npm run gen-types",
102
- "lint": "eslint"
102
+ "lint": "eslint",
103
+ "lint:fix": "eslint --fix"
103
104
  }
104
105
  }
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.64.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/runtime/3.66.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",