@platformatic/basic 3.0.0-alpha.6 → 3.0.0-rc.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 CHANGED
@@ -98,6 +98,7 @@ export interface PlatformaticBasicConfig {
98
98
  };
99
99
  startTimeout?: number;
100
100
  restartOnError?: boolean | number;
101
+ exitOnUnhandledErrors?: boolean;
101
102
  gracefulShutdown?: {
102
103
  runtime: number | string;
103
104
  application: number | string;
package/index.d.ts CHANGED
@@ -2,12 +2,6 @@ export interface StartOptions {
2
2
  listen?: boolean
3
3
  }
4
4
 
5
- export interface Dependency {
6
- id: string
7
- url?: string
8
- local: boolean
9
- }
10
-
11
5
  export type BaseContext = Partial<{
12
6
  applicationId: string
13
7
  isEntrypoint: boolean
@@ -75,7 +69,6 @@ export class BaseCapability<Config = Record<string, any>, Options = BaseOptions>
75
69
  body: object
76
70
  }>
77
71
  log (options: { message: string; level: string }): Promise<void>
78
- getBootstrapDependencies (): Promise<Dependency[]>
79
72
  getWatchConfig (): Promise<{
80
73
  enabled: boolean
81
74
  path: string
package/lib/capability.js CHANGED
@@ -1,4 +1,11 @@
1
- import { buildPinoOptions, deepmerge, executeWithTimeout, kMetadata, kTimeout } from '@platformatic/foundation'
1
+ import {
2
+ buildPinoOptions,
3
+ deepmerge,
4
+ executeWithTimeout,
5
+ kHandledError,
6
+ kMetadata,
7
+ kTimeout
8
+ } from '@platformatic/foundation'
2
9
  import { client, collectMetrics, ensureMetricsGroup } from '@platformatic/metrics'
3
10
  import { parseCommandString } from 'execa'
4
11
  import { spawn } from 'node:child_process'
@@ -14,16 +21,44 @@ import { ChildManager } from './worker/child-manager.js'
14
21
  const kITC = Symbol.for('plt.runtime.itc')
15
22
 
16
23
  export class BaseCapability extends EventEmitter {
17
- childManager
18
- subprocess
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
19
49
  subprocessForceClose
20
50
  subprocessTerminationSignal
51
+ logger
52
+ metricsRegistr
53
+
21
54
  #subprocessStarted
22
55
  #metricsCollected
56
+ #pendingDependenciesWaits
23
57
 
24
58
  constructor (type, version, root, config, context, standardStreams = {}) {
25
59
  super()
26
60
 
61
+ this.status = ''
27
62
  this.type = type
28
63
  this.version = version
29
64
  this.root = root
@@ -42,7 +77,7 @@ export class BaseCapability extends EventEmitter {
42
77
  this.basePath = null
43
78
  this.isEntrypoint = this.context.isEntrypoint
44
79
  this.isProduction = this.context.isProduction
45
- this.#metricsCollected = false
80
+ this.dependencies = this.context.dependencies ?? []
46
81
  this.customHealthCheck = null
47
82
  this.customReadinessCheck = null
48
83
  this.clientWs = null
@@ -51,11 +86,11 @@ export class BaseCapability extends EventEmitter {
51
86
  this.stderr = standardStreams?.stderr ?? process.stderr
52
87
  this.subprocessForceClose = false
53
88
  this.subprocessTerminationSignal = 'SIGINT'
54
-
55
89
  this.logger = this._initializeLogger()
56
90
 
57
91
  // Setup globals
58
92
  this.registerGlobals({
93
+ capability: this,
59
94
  applicationId: this.applicationId,
60
95
  workerId: this.workerId,
61
96
  logLevel: this.logger.level,
@@ -79,10 +114,25 @@ export class BaseCapability extends EventEmitter {
79
114
  this.metricsRegistry = new client.Registry()
80
115
  this.registerGlobals({ prometheus: { client, registry: this.metricsRegistry } })
81
116
  }
117
+
118
+ this.#metricsCollected = false
119
+ this.#pendingDependenciesWaits = new Set()
82
120
  }
83
121
 
84
- init () {
85
- return this.updateContext()
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'
86
136
  }
87
137
 
88
138
  updateContext (_context) {
@@ -93,8 +143,12 @@ export class BaseCapability extends EventEmitter {
93
143
  throw new Error('BaseCapability.start must be overriden by the subclasses')
94
144
  }
95
145
 
96
- stop () {
97
- throw new Error('BaseCapability.stop must be overriden by the subclasses')
146
+ async stop () {
147
+ if (this.#pendingDependenciesWaits.size > 0) {
148
+ await Promise.allSettled(this.#pendingDependenciesWaits)
149
+ }
150
+
151
+ this.status = 'stopped'
98
152
  }
99
153
 
100
154
  build () {
@@ -110,6 +164,106 @@ export class BaseCapability extends EventEmitter {
110
164
  throw new Error('BaseCapability.inject must be overriden by the subclasses')
111
165
  }
112
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
+
113
267
  getUrl () {
114
268
  return this.url
115
269
  }
@@ -145,7 +299,7 @@ export class BaseCapability extends EventEmitter {
145
299
  }
146
300
 
147
301
  async getInfo () {
148
- return { type: this.type, version: this.version }
302
+ return { type: this.type, version: this.version, dependencies: this.dependencies }
149
303
  }
150
304
 
151
305
  getDispatchFunc () {
@@ -304,6 +458,11 @@ export class BaseCapability extends EventEmitter {
304
458
  this.basePath = path
305
459
  })
306
460
 
461
+ this.childManager.on('event', event => {
462
+ globalThis[kITC].notify('event', event)
463
+ this.emit('application:worker:event', config)
464
+ })
465
+
307
466
  // This is not really important for the URL but sometimes it also a sign
308
467
  // that the process has been replaced and thus we need to update the client WebSocket
309
468
  this.childManager.on('url', (url, clientWs) => {
@@ -389,6 +548,7 @@ export class BaseCapability extends EventEmitter {
389
548
  isEntrypoint: this.isEntrypoint,
390
549
  runtimeBasePath: this.runtimeConfig?.basePath ?? null,
391
550
  wantsAbsoluteUrls: meta.gateway?.wantsAbsoluteUrls ?? false,
551
+ exitOnUnhandledErrors: this.runtimeConfig.exitOnUnhandledErrors ?? true,
392
552
  /* c8 ignore next 2 - else */
393
553
  port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true,
394
554
  host: (this.isEntrypoint ? this.serverConfig?.hostname : undefined) ?? true,
@@ -559,6 +719,13 @@ export class BaseCapability extends EventEmitter {
559
719
  globalThis.platformatic.onHttpStatsSize = (url, val) => {
560
720
  httpStatsSizeMetric.set({ dispatcher_stats_url: url }, val)
561
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)
562
729
  }
563
730
 
564
731
  async #invalidateHttpCache (opts = {}) {
@@ -112,7 +112,11 @@ export class ChildProcess extends ITC {
112
112
 
113
113
  this.listen()
114
114
  this.#setupLogger()
115
- this.#setupHandlers()
115
+
116
+ if (globalThis.platformatic.exitOnUnhandledErrors) {
117
+ this.#setupHandlers()
118
+ }
119
+
116
120
  this.#setupServer()
117
121
  this.#setupInterceptors()
118
122
 
@@ -282,6 +286,13 @@ export class ChildProcess extends ITC {
282
286
  globalThis.platformatic.onHttpStatsSize = (url, val) => {
283
287
  httpStatsSizeMetric.set({ dispatcher_stats_url: url }, val)
284
288
  }
289
+
290
+ const activeResourcesEventLoopMetric = new client.Gauge({
291
+ name: 'active_resources_event_loop',
292
+ help: 'Number of active resources keeping the event loop alive',
293
+ registers: [registry]
294
+ })
295
+ globalThis.platformatic.onActiveResourcesEventLoop = val => activeResourcesEventLoopMetric.set(val)
285
296
  }
286
297
 
287
298
  async #getMetrics ({ format } = {}) {
@@ -397,6 +408,14 @@ export class ChildProcess extends ITC {
397
408
 
398
409
  process.on('uncaughtException', handleUnhandled.bind(this, 'uncaught exception'))
399
410
  process.on('unhandledRejection', handleUnhandled.bind(this, 'unhandled rejection'))
411
+
412
+ process.on('newListener', event => {
413
+ if (event === 'uncaughtException' || event === 'unhandledRejection') {
414
+ this.#logger.warn(
415
+ `A listener has been added for the "process.${event}" event. This listener will be never triggered as Watt default behavior will kill the process before.\n To disable this behavior, set "exitOnUnhandledErrors" to false in the runtime config.`
416
+ )
417
+ }
418
+ })
400
419
  }
401
420
 
402
421
  #notifyConfig (config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/basic",
3
- "version": "3.0.0-alpha.6",
3
+ "version": "3.0.0-rc.1",
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/itc": "3.0.0-alpha.6",
29
- "@platformatic/foundation": "3.0.0-alpha.6",
30
- "@platformatic/metrics": "3.0.0-alpha.6",
31
- "@platformatic/telemetry": "3.0.0-alpha.6"
28
+ "@platformatic/foundation": "3.0.0-rc.1",
29
+ "@platformatic/itc": "3.0.0-rc.1",
30
+ "@platformatic/metrics": "3.0.0-rc.1",
31
+ "@platformatic/telemetry": "3.0.0-rc.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "cleaner-spec-reporter": "^0.5.0",
@@ -47,7 +47,7 @@
47
47
  "test": "node --test --test-reporter=cleaner-spec-reporter --test-concurrency=1 --test-timeout=2000000 test/*.test.js test/**/*.test.js",
48
48
  "gen-schema": "node lib/schema.js > schema.json",
49
49
  "gen-types": "json2ts > config.d.ts < schema.json",
50
- "build": "pnpm run gen-schema && pnpm run gen-types",
50
+ "build": "npm run gen-schema && npm run gen-types",
51
51
  "lint": "eslint"
52
52
  }
53
53
  }
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/basic/3.0.0-alpha.6.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/basic/3.0.0-rc.1.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Basic Config",
5
5
  "type": "object",
@@ -175,6 +175,13 @@
175
175
  },
176
176
  "additionalProperties": false
177
177
  },
178
+ "dependencies": {
179
+ "type": "array",
180
+ "items": {
181
+ "type": "string"
182
+ },
183
+ "default": []
184
+ },
178
185
  "arguments": {
179
186
  "type": "array",
180
187
  "items": {
@@ -556,6 +563,10 @@
556
563
  }
557
564
  ]
558
565
  },
566
+ "exitOnUnhandledErrors": {
567
+ "default": true,
568
+ "type": "boolean"
569
+ },
559
570
  "gracefulShutdown": {
560
571
  "type": "object",
561
572
  "properties": {
@@ -1,59 +0,0 @@
1
- import { ensureLoggableError } from '@platformatic/foundation'
2
- import { generateRequest, sanitize } from '@platformatic/itc/lib/index.js'
3
- import { once } from 'node:events'
4
- import { platform } from 'node:os'
5
- import { workerData } from 'node:worker_threads'
6
- import build from 'pino-abstract-transport'
7
- import { WebSocket } from 'ws'
8
- import { getSocketPath } from './child-manager.js'
9
-
10
- /* c8 ignore next 5 */
11
- function logDirectError (message, error) {
12
- process._rawDebug(`Logger thread for child process of application ${workerData.id} ${message}.`, {
13
- error: ensureLoggableError(error)
14
- })
15
- }
16
-
17
- /* c8 ignore next 4 */
18
- function handleUnhandled (type, error) {
19
- logDirectError(`threw an ${type}`, error)
20
- process.exit(6)
21
- }
22
-
23
- process.on('uncaughtException', handleUnhandled.bind(null, 'uncaught exception'))
24
- process.on('unhandledRejection', handleUnhandled.bind(null, 'unhandled rejection'))
25
-
26
- export default async function () {
27
- try {
28
- /* c8 ignore next */
29
- const protocol = platform() === 'win32' ? 'ws+unix:' : 'ws+unix://'
30
- const socket = new WebSocket(`${protocol}${getSocketPath(process.env.PLT_MANAGER_ID)}`)
31
-
32
- await once(socket, 'open')
33
-
34
- // Do not process responses but empty the socket inbound queue
35
- socket.on('message', () => {})
36
-
37
- /* c8 ignore next 3 */
38
- socket.on('error', error => {
39
- logDirectError('threw a socket error', error)
40
- })
41
-
42
- return build(
43
- async function (source) {
44
- for await (const obj of source) {
45
- socket.send(JSON.stringify(sanitize(generateRequest('log', { logs: [obj] }))))
46
- }
47
- },
48
- {
49
- close (_, cb) {
50
- socket.close()
51
- cb()
52
- }
53
- }
54
- )
55
- /* c8 ignore next 3 */
56
- } catch (error) {
57
- logDirectError('threw a connection error', error)
58
- }
59
- }