@platformatic/basic 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.
- package/config.d.ts +321 -1
- package/eslint.config.js +1 -3
- package/index.d.ts +81 -0
- package/index.js +4 -155
- package/lib/capability.js +734 -0
- package/lib/config.js +81 -0
- package/lib/creation.js +9 -0
- package/lib/errors.js +1 -1
- package/lib/modules.js +101 -0
- package/lib/schema.js +26 -6
- package/lib/utils.js +4 -2
- package/lib/worker/child-manager.js +82 -37
- package/lib/worker/child-process.js +320 -93
- package/lib/worker/listeners.js +10 -4
- package/package.json +19 -18
- package/schema.json +1277 -2
- package/lib/base.js +0 -276
- package/lib/worker/child-transport.js +0 -59
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPinoOptions,
|
|
3
|
+
deepmerge,
|
|
4
|
+
executeWithTimeout,
|
|
5
|
+
kHandledError,
|
|
6
|
+
kMetadata,
|
|
7
|
+
kTimeout
|
|
8
|
+
} from '@platformatic/foundation'
|
|
9
|
+
import { client, collectMetrics, ensureMetricsGroup } from '@platformatic/metrics'
|
|
10
|
+
import { parseCommandString } from 'execa'
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import EventEmitter, { once } from 'node:events'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { platform } from 'node:os'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
import { workerData } from 'node:worker_threads'
|
|
17
|
+
import pino from 'pino'
|
|
18
|
+
import { NonZeroExitCode } from './errors.js'
|
|
19
|
+
import { cleanBasePath } from './utils.js'
|
|
20
|
+
import { ChildManager } from './worker/child-manager.js'
|
|
21
|
+
const kITC = Symbol.for('plt.runtime.itc')
|
|
22
|
+
|
|
23
|
+
export class BaseCapability extends EventEmitter {
|
|
24
|
+
status
|
|
25
|
+
type
|
|
26
|
+
version
|
|
27
|
+
root
|
|
28
|
+
config
|
|
29
|
+
context
|
|
30
|
+
standardStreams
|
|
31
|
+
|
|
32
|
+
applicationId
|
|
33
|
+
workerId
|
|
34
|
+
telemetryConfig
|
|
35
|
+
serverConfig
|
|
36
|
+
openapiSchema
|
|
37
|
+
graphqlSchema
|
|
38
|
+
connectionString
|
|
39
|
+
basePath
|
|
40
|
+
isEntrypoint
|
|
41
|
+
isProduction
|
|
42
|
+
dependencies
|
|
43
|
+
customHealthCheck
|
|
44
|
+
customReadinessCheck
|
|
45
|
+
clientWs
|
|
46
|
+
runtimeConfig
|
|
47
|
+
stdout
|
|
48
|
+
stderr
|
|
49
|
+
subprocessForceClose
|
|
50
|
+
subprocessTerminationSignal
|
|
51
|
+
logger
|
|
52
|
+
metricsRegistr
|
|
53
|
+
|
|
54
|
+
#subprocessStarted
|
|
55
|
+
#metricsCollected
|
|
56
|
+
#pendingDependenciesWaits
|
|
57
|
+
|
|
58
|
+
constructor (type, version, root, config, context, standardStreams = {}) {
|
|
59
|
+
super()
|
|
60
|
+
|
|
61
|
+
this.status = ''
|
|
62
|
+
this.type = type
|
|
63
|
+
this.version = version
|
|
64
|
+
this.root = root
|
|
65
|
+
this.config = config
|
|
66
|
+
this.context = context ?? {}
|
|
67
|
+
this.context.worker ??= { count: 1, index: 0 }
|
|
68
|
+
this.standardStreams = standardStreams
|
|
69
|
+
|
|
70
|
+
this.applicationId = this.context.applicationId
|
|
71
|
+
this.workerId = this.context.worker.count > 1 ? this.context.worker.index : undefined
|
|
72
|
+
this.telemetryConfig = this.context.telemetryConfig
|
|
73
|
+
this.serverConfig = deepmerge(this.context.serverConfig ?? {}, config.server ?? {})
|
|
74
|
+
this.openapiSchema = null
|
|
75
|
+
this.graphqlSchema = null
|
|
76
|
+
this.connectionString = null
|
|
77
|
+
this.basePath = null
|
|
78
|
+
this.isEntrypoint = this.context.isEntrypoint
|
|
79
|
+
this.isProduction = this.context.isProduction
|
|
80
|
+
this.dependencies = this.context.dependencies ?? []
|
|
81
|
+
this.customHealthCheck = null
|
|
82
|
+
this.customReadinessCheck = null
|
|
83
|
+
this.clientWs = null
|
|
84
|
+
this.runtimeConfig = deepmerge(this.context?.runtimeConfig ?? {}, workerData?.config ?? {})
|
|
85
|
+
this.stdout = standardStreams?.stdout ?? process.stdout
|
|
86
|
+
this.stderr = standardStreams?.stderr ?? process.stderr
|
|
87
|
+
this.subprocessForceClose = false
|
|
88
|
+
this.subprocessTerminationSignal = 'SIGINT'
|
|
89
|
+
this.logger = this._initializeLogger()
|
|
90
|
+
|
|
91
|
+
// Setup globals
|
|
92
|
+
this.registerGlobals({
|
|
93
|
+
capability: this,
|
|
94
|
+
applicationId: this.applicationId,
|
|
95
|
+
workerId: this.workerId,
|
|
96
|
+
logLevel: this.logger.level,
|
|
97
|
+
// Always use URL to avoid serialization problem in Windows
|
|
98
|
+
root: pathToFileURL(this.root).toString(),
|
|
99
|
+
setOpenapiSchema: this.setOpenapiSchema.bind(this),
|
|
100
|
+
setGraphqlSchema: this.setGraphqlSchema.bind(this),
|
|
101
|
+
setConnectionString: this.setConnectionString.bind(this),
|
|
102
|
+
setBasePath: this.setBasePath.bind(this),
|
|
103
|
+
runtimeBasePath: this.runtimeConfig?.basePath ?? null,
|
|
104
|
+
invalidateHttpCache: this.#invalidateHttpCache.bind(this),
|
|
105
|
+
setCustomHealthCheck: this.setCustomHealthCheck.bind(this),
|
|
106
|
+
setCustomReadinessCheck: this.setCustomReadinessCheck.bind(this),
|
|
107
|
+
notifyConfig: this.notifyConfig.bind(this),
|
|
108
|
+
logger: this.logger
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (globalThis.platformatic.prometheus) {
|
|
112
|
+
this.metricsRegistry = globalThis.platformatic.prometheus.registry
|
|
113
|
+
} else {
|
|
114
|
+
this.metricsRegistry = new client.Registry()
|
|
115
|
+
this.registerGlobals({ prometheus: { client, registry: this.metricsRegistry } })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.#metricsCollected = false
|
|
119
|
+
this.#pendingDependenciesWaits = new Set()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async init () {
|
|
123
|
+
if (this.status) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Wait for explicit dependencies to start
|
|
128
|
+
await this.waitForDependenciesStart(this.dependencies)
|
|
129
|
+
|
|
130
|
+
if (this.status === 'stopped') {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await this.updateContext()
|
|
135
|
+
this.status = 'init'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
updateContext (_context) {
|
|
139
|
+
// No-op by default
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
start () {
|
|
143
|
+
throw new Error('BaseCapability.start must be overriden by the subclasses')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async stop () {
|
|
147
|
+
if (this.#pendingDependenciesWaits.size > 0) {
|
|
148
|
+
await Promise.allSettled(this.#pendingDependenciesWaits)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.status = 'stopped'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
build () {
|
|
155
|
+
// No-op by default
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Alias for stop
|
|
159
|
+
close () {
|
|
160
|
+
return this.stop()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
inject () {
|
|
164
|
+
throw new Error('BaseCapability.inject must be overriden by the subclasses')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async waitForDependenciesStart (dependencies = []) {
|
|
168
|
+
if (!globalThis[kITC]) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pending = new Set(dependencies)
|
|
173
|
+
|
|
174
|
+
// Ask the runtime the status of the dependencies and don't wait if they are already started
|
|
175
|
+
const workers = await globalThis[kITC].send('getWorkers')
|
|
176
|
+
|
|
177
|
+
for (const worker of Object.values(workers)) {
|
|
178
|
+
if (this.dependencies.includes(worker.application) && worker.status === 'started') {
|
|
179
|
+
pending.delete(worker.application)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!pending.size) {
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.logger.info({ dependencies: Array.from(pending) }, 'Waiting for dependencies to start.')
|
|
188
|
+
|
|
189
|
+
const { promise, resolve, reject } = Promise.withResolvers()
|
|
190
|
+
|
|
191
|
+
function runtimeEventHandler ({ event, payload }) {
|
|
192
|
+
if (event !== 'application:worker:started') {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pending.delete(payload.application)
|
|
197
|
+
|
|
198
|
+
if (pending.size === 0) {
|
|
199
|
+
cleanupEvents()
|
|
200
|
+
resolve()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stopHandler () {
|
|
205
|
+
cleanupEvents()
|
|
206
|
+
|
|
207
|
+
const error = new Error('One of the service dependencies was unable to start.')
|
|
208
|
+
error.dependencies = dependencies
|
|
209
|
+
error[kHandledError] = true
|
|
210
|
+
reject(error)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const cleanupEvents = () => {
|
|
214
|
+
globalThis[kITC].removeListener('runtime:event', runtimeEventHandler)
|
|
215
|
+
this.context.controller.removeListener('stopping', stopHandler)
|
|
216
|
+
this.#pendingDependenciesWaits.delete(promise)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
globalThis[kITC].on('runtime:event', runtimeEventHandler)
|
|
220
|
+
this.context.controller.on('stopping', stopHandler)
|
|
221
|
+
this.#pendingDependenciesWaits.add(promise)
|
|
222
|
+
|
|
223
|
+
return promise
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async waitForDependentsStop (dependents = []) {
|
|
227
|
+
if (!globalThis[kITC]) {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const pending = new Set(dependents)
|
|
232
|
+
|
|
233
|
+
// Ask the runtime the status of the dependencies and don't wait if they are already stopped
|
|
234
|
+
const workers = await globalThis[kITC].send('getWorkers')
|
|
235
|
+
|
|
236
|
+
for (const worker of Object.values(workers)) {
|
|
237
|
+
if (this.dependencies.includes(worker.application) && worker.status === 'started') {
|
|
238
|
+
pending.delete(worker.application)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!pending.size) {
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.logger.info({ dependents: Array.from(pending) }, 'Waiting for dependents to stop.')
|
|
247
|
+
|
|
248
|
+
const { promise, resolve } = Promise.withResolvers()
|
|
249
|
+
|
|
250
|
+
function runtimeEventHandler ({ event, payload }) {
|
|
251
|
+
if (event !== 'application:worker:stopped') {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
pending.delete(payload.application)
|
|
256
|
+
|
|
257
|
+
if (pending.size === 0) {
|
|
258
|
+
globalThis[kITC].removeListener('runtime:event', runtimeEventHandler)
|
|
259
|
+
resolve()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
globalThis[kITC].on('runtime:event', runtimeEventHandler)
|
|
264
|
+
return promise
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
getUrl () {
|
|
268
|
+
return this.url
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async getConfig (includeMeta = false) {
|
|
272
|
+
if (includeMeta) {
|
|
273
|
+
return this.config
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { [kMetadata]: _, ...config } = this.config
|
|
277
|
+
return config
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async getEnv () {
|
|
281
|
+
return this.config[kMetadata].env
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async getWatchConfig () {
|
|
285
|
+
const config = this.config
|
|
286
|
+
|
|
287
|
+
const enabled = config.watch?.enabled !== false
|
|
288
|
+
|
|
289
|
+
if (!enabled) {
|
|
290
|
+
return { enabled, path: this.root }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
enabled,
|
|
295
|
+
path: this.root,
|
|
296
|
+
allow: config.watch?.allow,
|
|
297
|
+
ignore: config.watch?.ignore
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async getInfo () {
|
|
302
|
+
return { type: this.type, version: this.version, dependencies: this.dependencies }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
getDispatchFunc () {
|
|
306
|
+
return this
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async getDispatchTarget () {
|
|
310
|
+
return this.getUrl() ?? (await this.getDispatchFunc())
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
getMeta () {
|
|
314
|
+
return {
|
|
315
|
+
gateway: {
|
|
316
|
+
wantsAbsoluteUrls: false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async getMetrics ({ format } = {}) {
|
|
322
|
+
if (this.childManager && this.clientWs) {
|
|
323
|
+
return this.childManager.send(this.clientWs, 'getMetrics', { format })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return format === 'json' ? await this.metricsRegistry.getMetricsAsJSON() : await this.metricsRegistry.metrics()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async getOpenapiSchema () {
|
|
330
|
+
return this.openapiSchema
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async getGraphqlSchema () {
|
|
334
|
+
return this.graphqlSchema
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
setOpenapiSchema (schema) {
|
|
338
|
+
this.openapiSchema = schema
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setGraphqlSchema (schema) {
|
|
342
|
+
this.graphqlSchema = schema
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
setCustomHealthCheck (fn) {
|
|
346
|
+
this.customHealthCheck = fn
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setCustomReadinessCheck (fn) {
|
|
350
|
+
this.customReadinessCheck = fn
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async getCustomHealthCheck () {
|
|
354
|
+
if (!this.customHealthCheck) {
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
return await this.customHealthCheck()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async getCustomReadinessCheck () {
|
|
361
|
+
if (!this.customReadinessCheck) {
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
return await this.customReadinessCheck()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
setConnectionString (connectionString) {
|
|
368
|
+
this.connectionString = connectionString
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
setBasePath (basePath) {
|
|
372
|
+
this.basePath = basePath
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async log ({ message, level }) {
|
|
376
|
+
const logLevel = level ?? 'info'
|
|
377
|
+
this.logger[logLevel](message)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
registerGlobals (globals) {
|
|
381
|
+
globalThis.platformatic = Object.assign(globalThis.platformatic ?? {}, globals)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
verifyOutputDirectory (path) {
|
|
385
|
+
if (this.isProduction && !existsSync(path)) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Cannot access directory '${path}'. Please run the 'build' command before running in production mode.`
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async buildWithCommand (command, basePath, opts = {}) {
|
|
393
|
+
const { loader, scripts, context, disableChildManager } = opts
|
|
394
|
+
|
|
395
|
+
if (Array.isArray(command)) {
|
|
396
|
+
command = command.join(' ')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.logger.debug(`Executing "${command}" ...`)
|
|
400
|
+
|
|
401
|
+
const baseContext = await this.getChildManagerContext(basePath)
|
|
402
|
+
this.childManager = disableChildManager
|
|
403
|
+
? null
|
|
404
|
+
: new ChildManager({
|
|
405
|
+
logger: this.logger,
|
|
406
|
+
loader,
|
|
407
|
+
scripts,
|
|
408
|
+
context: { ...baseContext, isBuilding: true, ...context }
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
await this.childManager?.inject()
|
|
413
|
+
|
|
414
|
+
const subprocess = await this.spawn(command)
|
|
415
|
+
const [exitCode] = await once(subprocess, 'exit')
|
|
416
|
+
|
|
417
|
+
if (exitCode !== 0) {
|
|
418
|
+
const error = new NonZeroExitCode(exitCode)
|
|
419
|
+
error.exitCode = exitCode
|
|
420
|
+
throw error
|
|
421
|
+
}
|
|
422
|
+
} finally {
|
|
423
|
+
await this.childManager?.eject()
|
|
424
|
+
await this.childManager?.close()
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async startWithCommand (command, loader, scripts) {
|
|
429
|
+
const config = this.config
|
|
430
|
+
const basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
|
|
431
|
+
|
|
432
|
+
const context = await this.getChildManagerContext(basePath)
|
|
433
|
+
this.childManager = new ChildManager({
|
|
434
|
+
logger: this.logger,
|
|
435
|
+
loader,
|
|
436
|
+
context,
|
|
437
|
+
scripts
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
this.childManager.on('config', config => {
|
|
441
|
+
this.subprocessConfig = config
|
|
442
|
+
this.notifyConfig(config)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
this.childManager.on('connectionString', connectionString => {
|
|
446
|
+
this.connectionString = connectionString
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
this.childManager.on('openapiSchema', schema => {
|
|
450
|
+
this.openapiSchema = schema
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
this.childManager.on('graphqlSchema', schema => {
|
|
454
|
+
this.graphqlSchema = schema
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
this.childManager.on('basePath', path => {
|
|
458
|
+
this.basePath = path
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
this.childManager.on('event', event => {
|
|
462
|
+
globalThis[kITC].notify('event', event)
|
|
463
|
+
this.emit('application:worker:event', config)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// This is not really important for the URL but sometimes it also a sign
|
|
467
|
+
// that the process has been replaced and thus we need to update the client WebSocket
|
|
468
|
+
this.childManager.on('url', (url, clientWs) => {
|
|
469
|
+
this.url = url
|
|
470
|
+
this.clientWs = clientWs
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await this.childManager.inject()
|
|
475
|
+
this.subprocess = await this.spawn(command)
|
|
476
|
+
this.#subprocessStarted = true
|
|
477
|
+
} catch (e) {
|
|
478
|
+
this.childManager.close()
|
|
479
|
+
throw new Error(`Cannot execute command "${command}": executable not found`)
|
|
480
|
+
} finally {
|
|
481
|
+
await this.childManager.eject()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// If the process exits prematurely, terminate the thread with the same code
|
|
485
|
+
this.subprocess.on('exit', code => {
|
|
486
|
+
if (this.#subprocessStarted && typeof code === 'number' && code !== 0) {
|
|
487
|
+
this.childManager.close()
|
|
488
|
+
process.exit(code)
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const [url, clientWs] = await once(this.childManager, 'url')
|
|
493
|
+
this.url = url
|
|
494
|
+
this.clientWs = clientWs
|
|
495
|
+
|
|
496
|
+
await this._collectMetrics()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async stopCommand () {
|
|
500
|
+
const exitTimeout = this.runtimeConfig.gracefulShutdown.application
|
|
501
|
+
|
|
502
|
+
this.#subprocessStarted = false
|
|
503
|
+
const exitPromise = once(this.subprocess, 'exit')
|
|
504
|
+
|
|
505
|
+
// Attempt graceful close on the process
|
|
506
|
+
const handled = await this.childManager.send(this.clientWs, 'close', this.subprocessTerminationSignal)
|
|
507
|
+
|
|
508
|
+
if (!handled && this.subprocessForceClose) {
|
|
509
|
+
this.subprocess.kill(this.subprocessTerminationSignal)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// If the process hasn't exited in X seconds, kill it in the polite way
|
|
513
|
+
/* c8 ignore next 10 */
|
|
514
|
+
const res = await executeWithTimeout(exitPromise, exitTimeout)
|
|
515
|
+
|
|
516
|
+
if (res === kTimeout) {
|
|
517
|
+
this.subprocess.kill(this.subprocessTerminationSignal)
|
|
518
|
+
|
|
519
|
+
// If the process hasn't exited in X seconds, kill it the hard way
|
|
520
|
+
const res = await executeWithTimeout(exitPromise, exitTimeout)
|
|
521
|
+
if (res === kTimeout) {
|
|
522
|
+
this.subprocess.kill('SIGKILL')
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await exitPromise
|
|
527
|
+
|
|
528
|
+
// Close the manager
|
|
529
|
+
await this.childManager.close()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
getChildManager () {
|
|
533
|
+
return this.childManager
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async getChildManagerContext (basePath) {
|
|
537
|
+
const meta = await this.getMeta()
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
id: this.id,
|
|
541
|
+
config: this.config,
|
|
542
|
+
applicationId: this.applicationId,
|
|
543
|
+
workerId: this.workerId,
|
|
544
|
+
// Always use URL to avoid serialization problem in Windows
|
|
545
|
+
root: pathToFileURL(this.root).toString(),
|
|
546
|
+
basePath,
|
|
547
|
+
logLevel: this.logger.level,
|
|
548
|
+
isEntrypoint: this.isEntrypoint,
|
|
549
|
+
runtimeBasePath: this.runtimeConfig?.basePath ?? null,
|
|
550
|
+
wantsAbsoluteUrls: meta.gateway?.wantsAbsoluteUrls ?? false,
|
|
551
|
+
exitOnUnhandledErrors: this.runtimeConfig.exitOnUnhandledErrors ?? true,
|
|
552
|
+
/* c8 ignore next 2 - else */
|
|
553
|
+
port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true,
|
|
554
|
+
host: (this.isEntrypoint ? this.serverConfig?.hostname : undefined) ?? true,
|
|
555
|
+
telemetryConfig: this.telemetryConfig
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async spawn (command) {
|
|
560
|
+
const [executable, ...args] = parseCommandString(command)
|
|
561
|
+
const hasChainedCommands = command.includes('&&') || command.includes('||') || command.includes(';')
|
|
562
|
+
|
|
563
|
+
/* c8 ignore next 3 */
|
|
564
|
+
const subprocess =
|
|
565
|
+
platform() === 'win32'
|
|
566
|
+
? spawn(command, { cwd: this.root, shell: true, windowsVerbatimArguments: true })
|
|
567
|
+
: spawn(executable, args, { cwd: this.root, shell: hasChainedCommands })
|
|
568
|
+
|
|
569
|
+
subprocess.stdout.setEncoding('utf8')
|
|
570
|
+
subprocess.stderr.setEncoding('utf8')
|
|
571
|
+
|
|
572
|
+
subprocess.stdout.pipe(this.stdout, { end: false })
|
|
573
|
+
subprocess.stderr.pipe(this.stderr, { end: false })
|
|
574
|
+
|
|
575
|
+
// Wait for the process to be started
|
|
576
|
+
await new Promise((resolve, reject) => {
|
|
577
|
+
subprocess.on('spawn', resolve)
|
|
578
|
+
subprocess.on('error', reject)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
return subprocess
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
notifyConfig (config) {
|
|
585
|
+
this.emit('config', config)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
_initializeLogger () {
|
|
589
|
+
const loggerOptions = deepmerge(this.runtimeConfig?.logger ?? {}, this.config?.logger ?? {})
|
|
590
|
+
const pinoOptions = buildPinoOptions(
|
|
591
|
+
loggerOptions,
|
|
592
|
+
this.serverConfig?.logger,
|
|
593
|
+
this.applicationId,
|
|
594
|
+
this.workerId,
|
|
595
|
+
this.context,
|
|
596
|
+
this.root
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
return pino(pinoOptions, this.standardStreams?.stdout)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async _collectMetrics () {
|
|
603
|
+
if (this.#metricsCollected) {
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.#metricsCollected = true
|
|
608
|
+
|
|
609
|
+
if (this.context.metricsConfig === false) {
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
await this.#collectMetrics()
|
|
614
|
+
this.#setHttpCacheMetrics()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async #collectMetrics () {
|
|
618
|
+
const metricsConfig = {
|
|
619
|
+
defaultMetrics: true,
|
|
620
|
+
httpMetrics: true,
|
|
621
|
+
...this.context.metricsConfig
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (this.childManager && this.clientWs) {
|
|
625
|
+
await this.childManager.send(this.clientWs, 'collectMetrics', {
|
|
626
|
+
applicationId: this.applicationId,
|
|
627
|
+
workerId: this.workerId,
|
|
628
|
+
metricsConfig
|
|
629
|
+
})
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
await collectMetrics(this.applicationId, this.workerId, metricsConfig, this.metricsRegistry)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#setHttpCacheMetrics () {
|
|
637
|
+
const { client, registry } = globalThis.platformatic.prometheus
|
|
638
|
+
|
|
639
|
+
// Metrics already registered, no need to register them again
|
|
640
|
+
if (ensureMetricsGroup(registry, 'http.cache')) {
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const cacheHitMetric = new client.Counter({
|
|
645
|
+
name: 'http_cache_hit_count',
|
|
646
|
+
help: 'Number of http cache hits',
|
|
647
|
+
registers: [registry]
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
const cacheMissMetric = new client.Counter({
|
|
651
|
+
name: 'http_cache_miss_count',
|
|
652
|
+
help: 'Number of http cache misses',
|
|
653
|
+
registers: [registry]
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
globalThis.platformatic.onHttpCacheHit = () => {
|
|
657
|
+
cacheHitMetric.inc()
|
|
658
|
+
}
|
|
659
|
+
globalThis.platformatic.onHttpCacheMiss = () => {
|
|
660
|
+
cacheMissMetric.inc()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const httpStatsFreeMetric = new client.Gauge({
|
|
664
|
+
name: 'http_client_stats_free',
|
|
665
|
+
help: 'Number of free (idle) http clients (sockets)',
|
|
666
|
+
labelNames: ['dispatcher_stats_url'],
|
|
667
|
+
registers: [registry]
|
|
668
|
+
})
|
|
669
|
+
globalThis.platformatic.onHttpStatsFree = (url, val) => {
|
|
670
|
+
httpStatsFreeMetric.set({ dispatcher_stats_url: url }, val)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const httpStatsConnectedMetric = new client.Gauge({
|
|
674
|
+
name: 'http_client_stats_connected',
|
|
675
|
+
help: 'Number of open socket connections',
|
|
676
|
+
labelNames: ['dispatcher_stats_url'],
|
|
677
|
+
registers: [registry]
|
|
678
|
+
})
|
|
679
|
+
globalThis.platformatic.onHttpStatsConnected = (url, val) => {
|
|
680
|
+
httpStatsConnectedMetric.set({ dispatcher_stats_url: url }, val)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const httpStatsPendingMetric = new client.Gauge({
|
|
684
|
+
name: 'http_client_stats_pending',
|
|
685
|
+
help: 'Number of pending requests across all clients',
|
|
686
|
+
labelNames: ['dispatcher_stats_url'],
|
|
687
|
+
registers: [registry]
|
|
688
|
+
})
|
|
689
|
+
globalThis.platformatic.onHttpStatsPending = (url, val) => {
|
|
690
|
+
httpStatsPendingMetric.set({ dispatcher_stats_url: url }, val)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const httpStatsQueuedMetric = new client.Gauge({
|
|
694
|
+
name: 'http_client_stats_queued',
|
|
695
|
+
help: 'Number of queued requests across all clients',
|
|
696
|
+
labelNames: ['dispatcher_stats_url'],
|
|
697
|
+
registers: [registry]
|
|
698
|
+
})
|
|
699
|
+
globalThis.platformatic.onHttpStatsQueued = (url, val) => {
|
|
700
|
+
httpStatsQueuedMetric.set({ dispatcher_stats_url: url }, val)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const httpStatsRunningMetric = new client.Gauge({
|
|
704
|
+
name: 'http_client_stats_running',
|
|
705
|
+
help: 'Number of currently active requests across all clients',
|
|
706
|
+
labelNames: ['dispatcher_stats_url'],
|
|
707
|
+
registers: [registry]
|
|
708
|
+
})
|
|
709
|
+
globalThis.platformatic.onHttpStatsRunning = (url, val) => {
|
|
710
|
+
httpStatsRunningMetric.set({ dispatcher_stats_url: url }, val)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const httpStatsSizeMetric = new client.Gauge({
|
|
714
|
+
name: 'http_client_stats_size',
|
|
715
|
+
help: 'Number of active, pending, or queued requests across all clients',
|
|
716
|
+
labelNames: ['dispatcher_stats_url'],
|
|
717
|
+
registers: [registry]
|
|
718
|
+
})
|
|
719
|
+
globalThis.platformatic.onHttpStatsSize = (url, val) => {
|
|
720
|
+
httpStatsSizeMetric.set({ dispatcher_stats_url: url }, val)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const activeResourcesEventLoopMetric = new client.Gauge({
|
|
724
|
+
name: 'active_resources_event_loop',
|
|
725
|
+
help: 'Number of active resources keeping the event loop alive',
|
|
726
|
+
registers: [registry]
|
|
727
|
+
})
|
|
728
|
+
globalThis.platformatic.onActiveResourcesEventLoop = val => activeResourcesEventLoopMetric.set(val)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async #invalidateHttpCache (opts = {}) {
|
|
732
|
+
await globalThis[kITC].send('invalidateHttpCache', opts)
|
|
733
|
+
}
|
|
734
|
+
}
|