@platformatic/basic 2.7.0 → 2.8.0-alpha.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/index.js CHANGED
@@ -110,7 +110,8 @@ async function buildStackable (opts) {
110
110
  const serviceRoot = relative(process.cwd(), opts.context.directory)
111
111
  if (
112
112
  !hadConfig &&
113
- !existsSync(resolve(serviceRoot, 'platformatic.json') || existsSync(resolve(serviceRoot, 'watt.json')))
113
+ !existsSync(resolve(serviceRoot, 'platformatic.json') || existsSync(resolve(serviceRoot, 'watt.json'))) &&
114
+ opts.context.worker?.count > 1
114
115
  ) {
115
116
  const logger = pino({
116
117
  level: opts.context.serverConfig?.logger?.level ?? 'warn',
@@ -119,7 +120,7 @@ async function buildStackable (opts) {
119
120
 
120
121
  logger.warn(
121
122
  [
122
- `Platformatic has auto-detected that service ${opts.context.serviceId} ${autodetectDescription}.\n`,
123
+ `Platformatic has auto-detected that service "${opts.context.serviceId}" ${autodetectDescription}.\n`,
123
124
  `We suggest you create a platformatic.json or watt.json file in the folder ${serviceRoot} with the "$schema" `,
124
125
  `property set to "https://schemas.platformatic.dev/${toImport}/${packageJson.version}.json".`
125
126
  ].join('')
package/lib/base.js CHANGED
@@ -1,12 +1,12 @@
1
- import { deepmerge } from '@platformatic/utils'
2
1
  import { collectMetrics } from '@platformatic/metrics'
2
+ import { deepmerge, executeWithTimeout } from '@platformatic/utils'
3
3
  import { parseCommandString } from 'execa'
4
4
  import { spawn } from 'node:child_process'
5
5
  import { once } from 'node:events'
6
- import { workerData } from 'node:worker_threads'
7
6
  import { existsSync } from 'node:fs'
8
- import { platform } from 'node:os'
7
+ import { hostname, platform } from 'node:os'
9
8
  import { pathToFileURL } from 'node:url'
9
+ import { workerData } from 'node:worker_threads'
10
10
  import pino from 'pino'
11
11
  import split2 from 'split2'
12
12
  import { NonZeroExitCode } from './errors.js'
@@ -19,9 +19,12 @@ export class BaseStackable {
19
19
  #subprocessStarted
20
20
 
21
21
  constructor (type, version, options, root, configManager) {
22
+ options.context.worker ??= { count: 1, index: 0 }
23
+
22
24
  this.type = type
23
25
  this.version = version
24
- this.id = options.context.serviceId
26
+ this.serviceId = options.context.serviceId
27
+ this.workerId = options.context.worker.count > 1 ? options.context.worker.index : undefined
25
28
  this.telemetryConfig = options.context.telemetryConfig
26
29
  this.options = options
27
30
  this.root = root
@@ -39,16 +42,26 @@ export class BaseStackable {
39
42
 
40
43
  // Setup the logger
41
44
  const pinoOptions = {
42
- level: this.serverConfig?.logger?.level ?? 'trace'
45
+ level: this.configManager.current?.logger?.level ?? this.serverConfig?.logger?.level ?? 'trace'
46
+ }
47
+
48
+ if (this.serviceId) {
49
+ pinoOptions.name = this.serviceId
43
50
  }
44
51
 
45
- if (this.id) {
46
- pinoOptions.name = this.id
52
+ if (typeof options.context.worker?.index !== 'undefined') {
53
+ pinoOptions.base = { pid: process.pid, hostname: hostname(), worker: this.workerId }
47
54
  }
55
+
48
56
  this.logger = pino(pinoOptions)
49
57
 
50
58
  // Setup globals
51
59
  this.registerGlobals({
60
+ serviceId: this.serviceId,
61
+ workerId: this.workerId,
62
+ logLevel: this.logger.level,
63
+ // Always use URL to avoid serialization problem in Windows
64
+ root: pathToFileURL(this.root).toString(),
52
65
  setOpenapiSchema: this.setOpenapiSchema.bind(this),
53
66
  setGraphqlSchema: this.setGraphqlSchema.bind(this),
54
67
  setBasePath: this.setBasePath.bind(this)
@@ -241,9 +254,26 @@ export class BaseStackable {
241
254
  this.#subprocessStarted = false
242
255
  const exitPromise = once(this.subprocess, 'exit')
243
256
 
244
- this.childManager.close(this.subprocessTerminationSignal ?? 'SIGINT')
245
- this.subprocess.kill(this.subprocessTerminationSignal ?? 'SIGINT')
257
+ // Attempt graceful close on the process
258
+ this.childManager.notify(this.clientWs, 'close')
259
+
260
+ // If the process hasn't exited in 10 seconds, kill it in the polite way
261
+ /* c8 ignore next 10 */
262
+ const res = await executeWithTimeout(exitPromise, 10000)
263
+ if (res === 'timeout') {
264
+ this.subprocess.kill(this.subprocessTerminationSignal ?? 'SIGINT')
265
+
266
+ // If the process hasn't exited in 10 seconds, kill it the hard way
267
+ const res = await executeWithTimeout(exitPromise, 10000)
268
+ if (res === 'timeout') {
269
+ this.subprocess.kill('SIGKILL')
270
+ }
271
+ }
272
+
246
273
  await exitPromise
274
+
275
+ // Close the manager
276
+ this.childManager.close()
247
277
  }
248
278
 
249
279
  getChildManager () {
@@ -270,14 +300,16 @@ export class BaseStackable {
270
300
 
271
301
  if (this.childManager && this.clientWs) {
272
302
  await this.childManager.send(this.clientWs, 'collectMetrics', {
273
- serviceId: this.id,
303
+ serviceId: this.serviceId,
304
+ workerId: this.workerId,
274
305
  metricsConfig
275
306
  })
276
307
  return
277
308
  }
278
309
 
279
310
  const { registry, startHttpTimer, endHttpTimer } = await collectMetrics(
280
- this.id,
311
+ this.serviceId,
312
+ this.workerId,
281
313
  metricsConfig
282
314
  )
283
315
 
@@ -294,9 +326,7 @@ export class BaseStackable {
294
326
 
295
327
  if (!this.metricsRegistry) return null
296
328
 
297
- return format === 'json'
298
- ? await this.metricsRegistry.getMetricsAsJSON()
299
- : await this.metricsRegistry.metrics()
329
+ return format === 'json' ? await this.metricsRegistry.getMetricsAsJSON() : await this.metricsRegistry.metrics()
300
330
  }
301
331
 
302
332
  getMeta () {
@@ -312,6 +342,8 @@ export class BaseStackable {
312
342
 
313
343
  return {
314
344
  id: this.id,
345
+ serviceId: this.serviceId,
346
+ workerId: this.workerId,
315
347
  // Always use URL to avoid serialization problem in Windows
316
348
  root: pathToFileURL(this.root).toString(),
317
349
  basePath,
@@ -1,4 +1,4 @@
1
- import { ITC, generateNotification } from '@platformatic/itc'
1
+ import { ITC } from '@platformatic/itc'
2
2
  import { createDirectory, ensureLoggableError } from '@platformatic/utils'
3
3
  import { once } from 'node:events'
4
4
  import { rm, writeFile } from 'node:fs/promises'
@@ -128,16 +128,11 @@ export class ChildManager extends ITC {
128
128
  })
129
129
  }
130
130
 
131
- async close (signal) {
131
+ async close () {
132
132
  if (this.#dataPath) {
133
133
  await rm(this.#dataPath, { force: true })
134
134
  }
135
135
 
136
- for (const client of this.#clients) {
137
- this.#currentClient = client
138
- this._send(generateNotification('close', signal))
139
- }
140
-
141
136
  this.#server?.close()
142
137
  super.close()
143
138
  }
@@ -195,6 +190,11 @@ export class ChildManager extends ITC {
195
190
  return super.send(name, message)
196
191
  }
197
192
 
193
+ notify (client, name, message) {
194
+ this.#currentClient = client
195
+ return super.notify(name, message)
196
+ }
197
+
198
198
  _send (message, stringify = true) {
199
199
  if (!this.#currentClient) {
200
200
  this.#currentClient = this.#requests.get(message.reqId)
@@ -1,12 +1,13 @@
1
1
  import { ITC } from '@platformatic/itc'
2
+ import { collectMetrics } from '@platformatic/metrics'
2
3
  import { setupNodeHTTPTelemetry } from '@platformatic/telemetry'
3
4
  import { createPinoWritable, ensureLoggableError } from '@platformatic/utils'
4
- import { collectMetrics } from '@platformatic/metrics'
5
- import { tracingChannel } from 'node:diagnostics_channel'
6
- import { once } from 'node:events'
5
+ import diagnosticChannel, { tracingChannel } from 'node:diagnostics_channel'
6
+ import { EventEmitter, once } from 'node:events'
7
7
  import { readFile } from 'node:fs/promises'
8
+ import { ServerResponse } from 'node:http'
8
9
  import { register } from 'node:module'
9
- import { platform, tmpdir } from 'node:os'
10
+ import { hostname, platform, tmpdir } from 'node:os'
10
11
  import { basename, resolve } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
12
13
  import { isMainThread } from 'node:worker_threads'
@@ -15,9 +16,9 @@ import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
15
16
  import { WebSocket } from 'ws'
16
17
  import { exitCodes } from '../errors.js'
17
18
  import { importFile } from '../utils.js'
18
- import { getSocketPath, isWindows } from './child-manager.js'
19
- import diagnosticChannel from 'node:diagnostics_channel'
20
- import { ServerResponse } from 'node:http'
19
+ import { getSocketPath } from './child-manager.js'
20
+
21
+ const windowsNpmExecutables = ['npm-prefix.js', 'npm-cli.js']
21
22
 
22
23
  function createInterceptor (itc) {
23
24
  return function (dispatch) {
@@ -97,7 +98,7 @@ export class ChildProcess extends ITC {
97
98
  },
98
99
  getMetrics: (...args) => {
99
100
  return this.#getMetrics(...args)
100
- },
101
+ }
101
102
  }
102
103
  })
103
104
 
@@ -113,8 +114,11 @@ export class ChildProcess extends ITC {
113
114
  this.#setupServer()
114
115
  this.#setupInterceptors()
115
116
 
116
- this.on('close', signal => {
117
- process.kill(process.pid, signal)
117
+ this.on('close', () => {
118
+ if (!globalThis.platformatic.events.emit('close')) {
119
+ // No user event, just exit without errors
120
+ process.exit(0)
121
+ }
118
122
  })
119
123
  }
120
124
 
@@ -167,17 +171,16 @@ export class ChildProcess extends ITC {
167
171
  this.#socket.close()
168
172
  }
169
173
 
170
- async #collectMetrics ({ serviceId, metricsConfig }) {
171
- const { registry } = await collectMetrics(serviceId, metricsConfig)
174
+ async #collectMetrics ({ serviceId, workerId, metricsConfig }) {
175
+ const { registry } = await collectMetrics(serviceId, workerId, metricsConfig)
172
176
  this.#metricsRegistry = registry
173
177
  }
174
178
 
175
179
  async #getMetrics ({ format } = {}) {
176
180
  if (!this.#metricsRegistry) return null
177
181
 
178
- const res = format === 'json'
179
- ? await this.#metricsRegistry.getMetricsAsJSON()
180
- : await this.#metricsRegistry.metrics()
182
+ const res =
183
+ format === 'json' ? await this.#metricsRegistry.getMetricsAsJSON() : await this.#metricsRegistry.metrics()
181
184
 
182
185
  return res
183
186
  }
@@ -185,20 +188,30 @@ export class ChildProcess extends ITC {
185
188
  #setupLogger () {
186
189
  // Since this is executed by user code, make sure we only override this in the main thread
187
190
  // The rest will be intercepted by the BaseStackable.
191
+ const pinoOptions = {
192
+ level: 'info',
193
+ name: globalThis.platformatic.serviceId
194
+ }
195
+
196
+ if (typeof globalThis.platformatic.workerId !== 'undefined') {
197
+ pinoOptions.base = {
198
+ pid: process.pid,
199
+ hostname: hostname(),
200
+ worker: parseInt(globalThis.platformatic.workerId)
201
+ }
202
+ }
203
+
188
204
  if (isMainThread) {
189
- this.#logger = pino({
190
- level: 'info',
191
- name: globalThis.platformatic.id,
192
- transport: {
193
- target: new URL('./child-transport.js', import.meta.url).toString(),
194
- options: { id: globalThis.platformatic.id }
195
- }
196
- })
205
+ pinoOptions.transport = {
206
+ target: new URL('./child-transport.js', import.meta.url).toString()
207
+ }
208
+
209
+ this.#logger = pino(pinoOptions)
197
210
 
198
211
  Reflect.defineProperty(process, 'stdout', { value: createPinoWritable(this.#logger, 'info') })
199
212
  Reflect.defineProperty(process, 'stderr', { value: createPinoWritable(this.#logger, 'error', true) })
200
213
  } else {
201
- this.#logger = pino({ level: 'info', name: globalThis.platformatic.id })
214
+ this.#logger = pino(pinoOptions)
202
215
  }
203
216
  }
204
217
 
@@ -262,11 +275,13 @@ export class ChildProcess extends ITC {
262
275
  }
263
276
 
264
277
  #setupHandlers () {
278
+ const errorLabel =
279
+ typeof globalThis.platformatic.workerId !== 'undefined'
280
+ ? `worker ${globalThis.platformatic.workerId} of the service "${globalThis.platformatic.serviceId}"`
281
+ : `service "${globalThis.platformatic.serviceId}"`
282
+
265
283
  function handleUnhandled (type, err) {
266
- this.#logger.error(
267
- { err: ensureLoggableError(err) },
268
- `Child process for service ${globalThis.platformatic.id} threw an ${type}.`
269
- )
284
+ this.#logger.error({ err: ensureLoggableError(err) }, `Child process for the ${errorLabel} threw an ${type}.`)
270
285
 
271
286
  // Give some time to the logger and ITC notifications to land before shutting down
272
287
  setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), 100)
@@ -304,10 +319,7 @@ function stripBasePath (basePath) {
304
319
 
305
320
  if (headers) {
306
321
  for (const key in headers) {
307
- if (
308
- key.toLowerCase() === 'location' &&
309
- !headers[key].startsWith(basePath)
310
- ) {
322
+ if (key.toLowerCase() === 'location' && !headers[key].startsWith(basePath)) {
311
323
  headers[key] = basePath + headers[key]
312
324
  }
313
325
  }
@@ -328,10 +340,16 @@ function stripBasePath (basePath) {
328
340
  }
329
341
 
330
342
  async function main () {
343
+ const executable = basename(process.argv[1] ?? '')
344
+ if (!isMainThread || windowsNpmExecutables.includes(executable)) {
345
+ return
346
+ }
347
+
331
348
  const dataPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${process.env.PLT_MANAGER_ID}.json`)
332
349
  const { data, loader, scripts } = JSON.parse(await readFile(dataPath))
333
350
 
334
351
  globalThis.platformatic = data
352
+ globalThis.platformatic.events = new EventEmitter()
335
353
 
336
354
  if (data.root && isMainThread) {
337
355
  process.chdir(fileURLToPath(data.root))
@@ -348,7 +366,4 @@ async function main () {
348
366
  globalThis[Symbol.for('plt.children.itc')] = new ChildProcess()
349
367
  }
350
368
 
351
- /* c8 ignore next 3 */
352
- if (!isWindows || basename(process.argv.at(-1)) !== 'npm-prefix.js') {
353
- await main()
354
- }
369
+ await main()
@@ -23,7 +23,7 @@ function handleUnhandled (type, error) {
23
23
  process.on('uncaughtException', handleUnhandled.bind(null, 'uncaught exception'))
24
24
  process.on('unhandledRejection', handleUnhandled.bind(null, 'unhandled rejection'))
25
25
 
26
- export default async function (opts) {
26
+ export default async function () {
27
27
  try {
28
28
  /* c8 ignore next */
29
29
  const protocol = platform() === 'win32' ? 'ws+unix:' : 'ws+unix://'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/basic",
3
- "version": "2.7.0",
3
+ "version": "2.8.0-alpha.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -23,11 +23,11 @@
23
23
  "split2": "^4.2.0",
24
24
  "undici": "^6.19.5",
25
25
  "ws": "^8.18.0",
26
- "@platformatic/config": "2.7.0",
27
- "@platformatic/itc": "2.7.0",
28
- "@platformatic/telemetry": "2.7.0",
29
- "@platformatic/metrics": "2.7.0",
30
- "@platformatic/utils": "2.7.0"
26
+ "@platformatic/config": "2.8.0-alpha.1",
27
+ "@platformatic/itc": "2.8.0-alpha.1",
28
+ "@platformatic/metrics": "2.8.0-alpha.1",
29
+ "@platformatic/telemetry": "2.8.0-alpha.1",
30
+ "@platformatic/utils": "2.8.0-alpha.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "borp": "^0.18.0",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/basic/2.7.0.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/basic/2.8.0-alpha.1.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Stackable",
5
5
  "type": "object",