@platformatic/runtime 3.64.0 → 3.65.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(
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
@@ -990,45 +1009,45 @@ export class Runtime extends EventEmitter {
990
1009
 
991
1010
  try {
992
1011
  // 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
1012
+ // workers round-trips to them when ensureStarted is set, and the profile
1013
+ // pulls do too — both hang if a worker event loop is blocked. Attach
995
1014
  // a noop handler so that a late settlement after the timeout does not
996
1015
  // 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
- })()
1016
+ const pull = this.#pullLastProfiles(id, options, ensureStarted)
1006
1017
  pull.catch(() => {})
1007
1018
 
1008
1019
  const outcome = await executeWithTimeout(pull, timeout, kTimeout)
1009
1020
 
1010
1021
  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)
1022
+ let best = null
1023
+
1024
+ for (const { service, result } of outcome) {
1025
+ // An older capture module which does not support includeTimestamp
1026
+ // returns the raw profile.
1027
+ const value = result instanceof Uint8Array
1028
+ ? { profile: result, timestamp: null, sampleCount: null }
1029
+ : { sampleCount: null, ...result }
1030
+
1031
+ // A strictly newer live window supersedes the preserved overload
1032
+ // profile: prune it so the preserved copy naturally expires once the
1033
+ // worker is healthy again and its profiles are being consumed.
1034
+ if (value.timestamp != null) {
1035
+ const key = `${service[kApplicationId]}:${service[kWorkerId]}:${type}`
1036
+ const preserved = this.#lastOverloadProfiles.get(key)
1037
+
1038
+ if (preserved && preserved.timestamp < value.timestamp) {
1039
+ this.#lastOverloadProfiles.delete(key)
1040
+ }
1041
+ }
1042
+
1043
+ // For an application-level id the newest window across the workers
1044
+ // wins, mirroring the preserved overload profile fallback below.
1045
+ if (!best || (value.timestamp != null && (best.timestamp == null || value.timestamp > best.timestamp))) {
1046
+ best = value
1028
1047
  }
1029
1048
  }
1030
1049
 
1031
- return { ...value, preserved: false }
1050
+ return { ...best, preserved: false }
1032
1051
  }
1033
1052
 
1034
1053
  // The worker event loop is not responding (e.g. it is hard-blocked).
@@ -1057,6 +1076,62 @@ export class Runtime extends EventEmitter {
1057
1076
  throw error
1058
1077
  }
1059
1078
 
1079
+ // Pulls the last profile from the addressed worker, or from every worker of
1080
+ // the application when no explicit worker index is given: the application
1081
+ // "last profile" is the newest window among its workers, so one arbitrary
1082
+ // worker cannot answer for all of them. Per-worker failures with fallback
1083
+ // codes are ignored as long as at least one worker yields a profile.
1084
+ async #pullLastProfiles (id, options, ensureStarted) {
1085
+ const pullOptions = { ...options, includeTimestamp: true, includeSampleCount: true }
1086
+
1087
+ const pullWorker = async service => {
1088
+ const result = await sendViaITC(service, 'getLastProfile', pullOptions)
1089
+ return { service, result }
1090
+ }
1091
+
1092
+ if (/^.+:\d+$/.test(id)) {
1093
+ return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
1094
+ }
1095
+
1096
+ if (!this.#applications.has(id)) {
1097
+ throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
1098
+ }
1099
+
1100
+ const keys = this.#workers.getKeys(id)
1101
+
1102
+ // No worker is currently registered: resolve the id as usual so that the
1103
+ // canonical error is raised.
1104
+ if (keys.length === 0) {
1105
+ return [await pullWorker(await this.#getApplicationById(id, ensureStarted))]
1106
+ }
1107
+
1108
+ const settled = await Promise.allSettled(
1109
+ keys.map(async key => {
1110
+ const service = await this.#getWorkerByIdOrNext(id, key.split(':')[1], ensureStarted)
1111
+ return pullWorker(service)
1112
+ })
1113
+ )
1114
+
1115
+ const profiles = []
1116
+ let firstError
1117
+
1118
+ for (const outcome of settled) {
1119
+ if (outcome.status === 'fulfilled') {
1120
+ profiles.push(outcome.value)
1121
+ } else if (!kLastProfileFallbackCodes.has(outcome.reason?.code)) {
1122
+ throw outcome.reason
1123
+ } else {
1124
+ firstError ??= outcome.reason
1125
+ }
1126
+ }
1127
+
1128
+ if (profiles.length === 0) {
1129
+ throw firstError
1130
+ }
1131
+
1132
+ return profiles
1133
+ }
1134
+
1060
1135
  // The final profile of an overload pause is pushed by the worker and
1061
1136
  // preserved in the main thread (see the profile:overload listener), so that
1062
1137
  // the evidence of what saturated a worker survives the worker being blocked
@@ -2433,7 +2508,10 @@ export class Runtime extends EventEmitter {
2433
2508
  codeRangeSizeMb
2434
2509
  },
2435
2510
  inspectorOptions,
2436
- dirname: this.#root
2511
+ dirname: this.#root,
2512
+ // Keys of the worker environment which only come from an env file of the runtime:
2513
+ // the env file of the application is allowed to override those.
2514
+ envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
2437
2515
  },
2438
2516
  argv: applicationConfig.arguments,
2439
2517
  execArgv,
@@ -4276,13 +4354,15 @@ export class Runtime extends EventEmitter {
4276
4354
  for (const extension of extensions) {
4277
4355
  const { path, options } = typeof extension === 'string' ? { path: extension } : extension
4278
4356
 
4279
- let setup
4357
+ let imported
4280
4358
  try {
4281
- setup = (await import(pathToFileURL(path))).default
4359
+ imported = await import(pathToFileURL(path))
4282
4360
  } catch (e) {
4283
4361
  throw new FailedToLoadExtensionError(path, e.message, { cause: e })
4284
4362
  }
4285
4363
 
4364
+ const setup = resolveExtensionSetup(imported)
4365
+
4286
4366
  if (typeof setup !== 'function') {
4287
4367
  throw new InvalidExtensionError(path)
4288
4368
  }
@@ -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.65.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.65.0",
43
+ "@platformatic/gateway": "3.65.0",
44
+ "@platformatic/node": "3.65.0",
45
+ "@platformatic/service": "3.65.0",
46
+ "@platformatic/db": "3.65.0",
47
+ "@platformatic/sql-graphql": "3.65.0",
48
+ "@platformatic/sql-mapper": "3.65.0",
49
+ "@platformatic/wattpm-pprof-capture": "3.65.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.65.0",
81
+ "@platformatic/generators": "3.65.0",
82
+ "@platformatic/globals": "3.65.0",
83
+ "@platformatic/itc": "3.65.0",
84
+ "@platformatic/telemetry": "3.65.0",
85
+ "@platformatic/metrics": "3.65.0",
86
+ "@platformatic/foundation": "3.65.0"
87
87
  },
88
88
  "engines": {
89
89
  "node": ">=22.19.0"
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.65.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Runtime Config",
5
5
  "type": "object",