@platformatic/basic 3.33.0 → 3.34.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/config.d.ts CHANGED
@@ -123,6 +123,10 @@ export interface PlatformaticBasicConfig {
123
123
  gracefulShutdown?: {
124
124
  runtime: number | string;
125
125
  application: number | string;
126
+ /**
127
+ * Add Connection: close header to HTTP responses during graceful shutdown
128
+ */
129
+ closeConnections?: boolean;
126
130
  };
127
131
  health?: {
128
132
  enabled?: boolean | string;
@@ -286,6 +290,23 @@ export interface PlatformaticBasicConfig {
286
290
  */
287
291
  serviceVersion?: string;
288
292
  };
293
+ /**
294
+ * Custom labels to add to HTTP metrics (http_request_all_duration_seconds). Each label extracts its value from an HTTP request header.
295
+ */
296
+ httpCustomLabels?: {
297
+ /**
298
+ * The label name to use in metrics
299
+ */
300
+ name: string;
301
+ /**
302
+ * The HTTP request header to extract the value from
303
+ */
304
+ header: string;
305
+ /**
306
+ * Default value when header is missing (defaults to "unknown")
307
+ */
308
+ default?: string;
309
+ }[];
289
310
  };
290
311
  telemetry?: {
291
312
  enabled?: boolean | string;
package/lib/capability.js CHANGED
@@ -60,6 +60,7 @@ export class BaseCapability extends EventEmitter {
60
60
  #metricsCollected
61
61
  #pendingDependenciesWaits
62
62
  #reuseTcpPortsSubscribers
63
+ #closing
63
64
 
64
65
  constructor (type, version, root, config, context, standardStreams = {}) {
65
66
  super()
@@ -97,6 +98,16 @@ export class BaseCapability extends EventEmitter {
97
98
  // True by default, can be overridden in subclasses. If false, it takes precedence over the runtime configuration
98
99
  this.exitOnUnhandledErrors = true
99
100
 
101
+ // Track if graceful shutdown is in progress
102
+ this.#closing = false
103
+
104
+ // Listen for controller 'stopping' event to initiate graceful shutdown early
105
+ if (this.context.controller) {
106
+ this.context.controller.once('stopping', () => {
107
+ this.setClosing()
108
+ })
109
+ }
110
+
100
111
  // Setup globals
101
112
  this.registerGlobals({
102
113
  capability: this,
@@ -433,6 +444,21 @@ export class BaseCapability extends EventEmitter {
433
444
  }
434
445
  }
435
446
 
447
+ get closing () {
448
+ return this.#closing
449
+ }
450
+
451
+ setClosing () {
452
+ if (this.#closing) return
453
+ this.#closing = true
454
+ this.emit('closing')
455
+
456
+ // Forward to child process if using childManager
457
+ if (this.childManager && this.clientWs) {
458
+ this.childManager.send(this.clientWs, 'setClosing', {}).catch(() => {})
459
+ }
460
+ }
461
+
436
462
  async buildWithCommand (command, basePath, opts = {}) {
437
463
  const { loader, scripts, context, disableChildManager } = opts
438
464
 
@@ -610,6 +636,11 @@ export class BaseCapability extends EventEmitter {
610
636
  this.emit('application:worker:event:' + event.event, event.payload)
611
637
  })
612
638
 
639
+ // Forward health signals from child process to runtime
640
+ childManager.on('healthSignals', ({ workerId, signals }) => {
641
+ globalThis[kITC]?.send('sendHealthSignals', { workerId, signals })
642
+ })
643
+
613
644
  // This is not really important for the URL but sometimes it also a sign
614
645
  // that the process has been replaced and thus we need to update the client WebSocket
615
646
  childManager.on('url', (url, clientWs) => {
@@ -619,13 +650,19 @@ export class BaseCapability extends EventEmitter {
619
650
  }
620
651
 
621
652
  async spawn (command) {
622
- const [executable, ...args] = parseCommandString(command)
653
+ let [executable, ...args] = parseCommandString(command)
623
654
  const hasChainedCommands = command.includes('&&') || command.includes('||') || command.includes(';')
624
655
 
656
+ // Use the current Node.js executable instead of relying on PATH lookup
657
+ // This ensures subprocess uses the same Node.js version as the parent
658
+ if (executable === 'node') {
659
+ executable = process.execPath
660
+ }
661
+
625
662
  /* c8 ignore next 3 */
626
663
  const subprocess =
627
664
  platform() === 'win32'
628
- ? spawn(command, { cwd: this.root, shell: true, windowsVerbatimArguments: true })
665
+ ? spawn(command.replace(/^node\b/, process.execPath), { cwd: this.root, shell: true, windowsVerbatimArguments: true })
629
666
  : spawn(executable, args, { cwd: this.root, shell: hasChainedCommands })
630
667
 
631
668
  subprocess.stdout.setEncoding('utf8')
@@ -10,9 +10,10 @@ import diagnosticChannel, { tracingChannel } from 'node:diagnostics_channel'
10
10
  import { EventEmitter, once } from 'node:events'
11
11
  import { readFile } from 'node:fs/promises'
12
12
  import { ServerResponse } from 'node:http'
13
- import { register } from 'node:module'
13
+ import { createRequire, register } from 'node:module'
14
14
  import { hostname, platform, tmpdir } from 'node:os'
15
15
  import { basename, join, resolve } from 'node:path'
16
+ import { Duplex } from 'node:stream'
16
17
  import { fileURLToPath } from 'node:url'
17
18
  import pino from 'pino'
18
19
  import { Agent, Pool, setGlobalDispatcher } from 'undici'
@@ -76,6 +77,8 @@ export class ChildProcess extends ITC {
76
77
  #logger
77
78
  #metricsRegistry
78
79
  #pendingMessages
80
+ #replStream
81
+ #lastELU
79
82
 
80
83
  constructor (executable) {
81
84
  super({
@@ -91,6 +94,22 @@ export class ChildProcess extends ITC {
91
94
  getMetrics: (...args) => {
92
95
  return this.#getMetrics(...args)
93
96
  },
97
+ startRepl: () => {
98
+ return this.#startRepl()
99
+ },
100
+ replInput: ({ data }) => {
101
+ return this.#replInput(data)
102
+ },
103
+ replClose: () => {
104
+ return this.#replClose()
105
+ },
106
+ getHealth: () => {
107
+ return this.#getHealth()
108
+ },
109
+ sendHealthSignals: ({ workerId, signals }) => {
110
+ // Forward health signals to the parent (ChildManager)
111
+ this.notify('healthSignals', { workerId, signals })
112
+ },
94
113
  close: signal => {
95
114
  let handled = false
96
115
 
@@ -113,6 +132,10 @@ export class ChildProcess extends ITC {
113
132
  }
114
133
 
115
134
  return handled
135
+ },
136
+ setClosing: () => {
137
+ globalThis.platformatic.closing = true
138
+ globalThis.platformatic.events.emit('closing')
116
139
  }
117
140
  }
118
141
  })
@@ -149,6 +172,9 @@ export class ChildProcess extends ITC {
149
172
  prometheus: { client, registry: this.#metricsRegistry },
150
173
  notifyConfig: this.#notifyConfig.bind(this)
151
174
  })
175
+
176
+ // Initialize health signals API for child processes
177
+ this.#initHealthSignalsApi()
152
178
  }
153
179
 
154
180
  registerGlobals (globals) {
@@ -335,6 +361,118 @@ export class ChildProcess extends ITC {
335
361
  return res
336
362
  }
337
363
 
364
+ #startRepl () {
365
+ // Dynamically load node:repl to avoid loading it when not needed
366
+ // (since it pulls in domain, which is quite expensive as it monkey patches EventEmitter)
367
+ const repl = createRequire(import.meta.url)('node:repl')
368
+
369
+ // Create a duplex stream that sends output via notify
370
+ const replStream = new Duplex({
371
+ read () {},
372
+ write: (chunk, encoding, callback) => {
373
+ this.notify('repl:output', { data: chunk.toString() })
374
+ callback()
375
+ }
376
+ })
377
+
378
+ this.#replStream = replStream
379
+
380
+ // Start the REPL with the stream
381
+ const replServer = repl.start({
382
+ prompt: `${globalThis.platformatic.applicationId}> `,
383
+ input: replStream,
384
+ output: replStream,
385
+ terminal: false,
386
+ useColors: true,
387
+ ignoreUndefined: true,
388
+ preview: false
389
+ })
390
+
391
+ // Expose useful context - note that in subprocess mode, app/capability may not be available
392
+ replServer.context.platformatic = globalThis.platformatic
393
+ replServer.context.config = globalThis.platformatic.config
394
+ replServer.context.logger = globalThis.platformatic.logger
395
+
396
+ replServer.on('exit', () => {
397
+ this.notify('repl:exit', {})
398
+ this.#replStream = null
399
+ })
400
+
401
+ return { started: true }
402
+ }
403
+
404
+ #replInput (data) {
405
+ if (this.#replStream) {
406
+ this.#replStream.push(data)
407
+ }
408
+ }
409
+
410
+ #replClose () {
411
+ if (this.#replStream) {
412
+ this.#replStream.push(null)
413
+ this.#replStream = null
414
+ }
415
+ }
416
+
417
+ #getHealth () {
418
+ const currentELU = performance.eventLoopUtilization()
419
+ const elu = performance.eventLoopUtilization(currentELU, this.#lastELU).utilization
420
+ this.#lastELU = currentELU
421
+
422
+ const { heapUsed, heapTotal } = process.memoryUsage()
423
+
424
+ return {
425
+ elu,
426
+ heapUsed,
427
+ heapTotal
428
+ }
429
+ }
430
+
431
+ #initHealthSignalsApi () {
432
+ const queue = []
433
+ let isSending = false
434
+ let promise = null
435
+ const timeout = 1000
436
+
437
+ const sendHealthSignal = async (signal) => {
438
+ if (typeof signal !== 'object') {
439
+ throw new Error('Health signal must be an object')
440
+ }
441
+ if (typeof signal.type !== 'string') {
442
+ throw new Error('Health signal type must be a string')
443
+ }
444
+ if (!signal.timestamp || typeof signal.timestamp !== 'number') {
445
+ signal.timestamp = Date.now()
446
+ }
447
+
448
+ queue.push(signal)
449
+
450
+ if (!isSending) {
451
+ isSending = true
452
+ promise = new Promise((resolve, reject) => {
453
+ setTimeout(async () => {
454
+ isSending = false
455
+ try {
456
+ const signals = queue.splice(0)
457
+ this.notify('healthSignals', {
458
+ workerId: globalThis.platformatic.workerId,
459
+ signals
460
+ })
461
+ } catch (err) {
462
+ reject(err)
463
+ return
464
+ }
465
+ resolve()
466
+ }, timeout)
467
+ })
468
+ }
469
+
470
+ return promise
471
+ }
472
+
473
+ globalThis.platformatic.sendHealthSignal = sendHealthSignal
474
+ }
475
+
338
476
  #setupLogger () {
339
477
  disablePinoDirectWrite()
340
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/basic",
3
- "version": "3.33.0",
3
+ "version": "3.34.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -25,10 +25,10 @@
25
25
  "split2": "^4.2.0",
26
26
  "undici": "^7.0.0",
27
27
  "ws": "^8.18.0",
28
- "@platformatic/foundation": "3.33.0",
29
- "@platformatic/itc": "3.33.0",
30
- "@platformatic/metrics": "3.33.0",
31
- "@platformatic/telemetry": "3.33.0"
28
+ "@platformatic/metrics": "3.34.0",
29
+ "@platformatic/telemetry": "3.34.0",
30
+ "@platformatic/foundation": "3.34.0",
31
+ "@platformatic/itc": "3.34.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "cleaner-spec-reporter": "^0.5.0",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/basic/3.33.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/basic/3.34.0.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Basic Config",
5
5
  "type": "object",
@@ -753,6 +753,11 @@
753
753
  }
754
754
  ],
755
755
  "default": 10000
756
+ },
757
+ "closeConnections": {
758
+ "type": "boolean",
759
+ "default": true,
760
+ "description": "Add Connection: close header to HTTP responses during graceful shutdown"
756
761
  }
757
762
  },
758
763
  "default": {},
@@ -1293,6 +1298,32 @@
1293
1298
  "endpoint"
1294
1299
  ],
1295
1300
  "additionalProperties": false
1301
+ },
1302
+ "httpCustomLabels": {
1303
+ "type": "array",
1304
+ "description": "Custom labels to add to HTTP metrics (http_request_all_duration_seconds). Each label extracts its value from an HTTP request header.",
1305
+ "items": {
1306
+ "type": "object",
1307
+ "properties": {
1308
+ "name": {
1309
+ "type": "string",
1310
+ "description": "The label name to use in metrics"
1311
+ },
1312
+ "header": {
1313
+ "type": "string",
1314
+ "description": "The HTTP request header to extract the value from"
1315
+ },
1316
+ "default": {
1317
+ "type": "string",
1318
+ "description": "Default value when header is missing (defaults to \"unknown\")"
1319
+ }
1320
+ },
1321
+ "required": [
1322
+ "name",
1323
+ "header"
1324
+ ],
1325
+ "additionalProperties": false
1326
+ }
1296
1327
  }
1297
1328
  },
1298
1329
  "additionalProperties": false