@platformatic/basic 2.6.1 → 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,11 +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
6
  import { existsSync } from 'node:fs'
7
- import { platform } from 'node:os'
7
+ import { hostname, platform } from 'node:os'
8
8
  import { pathToFileURL } from 'node:url'
9
+ import { workerData } from 'node:worker_threads'
9
10
  import pino from 'pino'
10
11
  import split2 from 'split2'
11
12
  import { NonZeroExitCode } from './errors.js'
@@ -18,9 +19,12 @@ export class BaseStackable {
18
19
  #subprocessStarted
19
20
 
20
21
  constructor (type, version, options, root, configManager) {
22
+ options.context.worker ??= { count: 1, index: 0 }
23
+
21
24
  this.type = type
22
25
  this.version = version
23
- this.id = options.context.serviceId
26
+ this.serviceId = options.context.serviceId
27
+ this.workerId = options.context.worker.count > 1 ? options.context.worker.index : undefined
24
28
  this.telemetryConfig = options.context.telemetryConfig
25
29
  this.options = options
26
30
  this.root = root
@@ -34,19 +38,30 @@ export class BaseStackable {
34
38
  this.startHttpTimer = null
35
39
  this.endHttpTimer = null
36
40
  this.clientWs = null
41
+ this.runtimeConfig = workerData?.config ?? null
37
42
 
38
43
  // Setup the logger
39
44
  const pinoOptions = {
40
- 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
41
50
  }
42
51
 
43
- if (this.id) {
44
- pinoOptions.name = this.id
52
+ if (typeof options.context.worker?.index !== 'undefined') {
53
+ pinoOptions.base = { pid: process.pid, hostname: hostname(), worker: this.workerId }
45
54
  }
55
+
46
56
  this.logger = pino(pinoOptions)
47
57
 
48
58
  // Setup globals
49
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(),
50
65
  setOpenapiSchema: this.setOpenapiSchema.bind(this),
51
66
  setGraphqlSchema: this.setGraphqlSchema.bind(this),
52
67
  setBasePath: this.setBasePath.bind(this)
@@ -134,20 +149,12 @@ export class BaseStackable {
134
149
 
135
150
  this.logger.debug(`Executing "${command}" ...`)
136
151
 
152
+ const context = await this.#getChildManagerContext(basePath)
137
153
  this.childManager = new ChildManager({
138
154
  logger: this.logger,
139
155
  loader,
140
156
  scripts,
141
- context: {
142
- id: this.id,
143
- // Always use URL to avoid serialization problem in Windows
144
- root: pathToFileURL(this.root).toString(),
145
- basePath,
146
- logLevel: this.logger.level,
147
- /* c8 ignore next 2 */
148
- port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true,
149
- host: (this.isEntrypoint ? this.serverConfig?.hostname : undefined) ?? true
150
- }
157
+ context
151
158
  })
152
159
 
153
160
  try {
@@ -188,20 +195,12 @@ export class BaseStackable {
188
195
  async startWithCommand (command, loader) {
189
196
  const config = this.configManager.current
190
197
  const basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
198
+
199
+ const context = await this.#getChildManagerContext(basePath)
191
200
  this.childManager = new ChildManager({
192
201
  logger: this.logger,
193
202
  loader,
194
- context: {
195
- id: this.id,
196
- // Always use URL to avoid serialization problem in Windows
197
- root: pathToFileURL(this.root).toString(),
198
- basePath,
199
- logLevel: this.logger.level,
200
- /* c8 ignore next 2 */
201
- port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true,
202
- host: (this.isEntrypoint ? this.serverConfig?.hostname : undefined) ?? true,
203
- telemetry: this.telemetryConfig
204
- }
203
+ context
205
204
  })
206
205
 
207
206
  this.childManager.on('config', config => {
@@ -255,9 +254,26 @@ export class BaseStackable {
255
254
  this.#subprocessStarted = false
256
255
  const exitPromise = once(this.subprocess, 'exit')
257
256
 
258
- this.childManager.close(this.subprocessTerminationSignal ?? 'SIGINT')
259
- 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
+
260
273
  await exitPromise
274
+
275
+ // Close the manager
276
+ this.childManager.close()
261
277
  }
262
278
 
263
279
  getChildManager () {
@@ -284,14 +300,16 @@ export class BaseStackable {
284
300
 
285
301
  if (this.childManager && this.clientWs) {
286
302
  await this.childManager.send(this.clientWs, 'collectMetrics', {
287
- serviceId: this.id,
303
+ serviceId: this.serviceId,
304
+ workerId: this.workerId,
288
305
  metricsConfig
289
306
  })
290
307
  return
291
308
  }
292
309
 
293
310
  const { registry, startHttpTimer, endHttpTimer } = await collectMetrics(
294
- this.id,
311
+ this.serviceId,
312
+ this.workerId,
295
313
  metricsConfig
296
314
  )
297
315
 
@@ -308,8 +326,35 @@ export class BaseStackable {
308
326
 
309
327
  if (!this.metricsRegistry) return null
310
328
 
311
- return format === 'json'
312
- ? await this.metricsRegistry.getMetricsAsJSON()
313
- : await this.metricsRegistry.metrics()
329
+ return format === 'json' ? await this.metricsRegistry.getMetricsAsJSON() : await this.metricsRegistry.metrics()
330
+ }
331
+
332
+ getMeta () {
333
+ return {
334
+ composer: {
335
+ wantsAbsoluteUrls: false
336
+ }
337
+ }
338
+ }
339
+
340
+ async #getChildManagerContext (basePath) {
341
+ const meta = await this.getMeta()
342
+
343
+ return {
344
+ id: this.id,
345
+ serviceId: this.serviceId,
346
+ workerId: this.workerId,
347
+ // Always use URL to avoid serialization problem in Windows
348
+ root: pathToFileURL(this.root).toString(),
349
+ basePath,
350
+ logLevel: this.logger.level,
351
+ isEntrypoint: this.isEntrypoint,
352
+ runtimeBasePath: this.runtimeConfig?.basePath ?? null,
353
+ wantsAbsoluteUrls: meta.composer?.wantsAbsoluteUrls ?? false,
354
+ /* c8 ignore next 2 */
355
+ port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true,
356
+ host: (this.isEntrypoint ? this.serverConfig?.hostname : undefined) ?? true,
357
+ telemetry: this.telemetryConfig
358
+ }
314
359
  }
315
360
  }
@@ -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,7 +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 { getSocketPath } from './child-manager.js'
20
+
21
+ const windowsNpmExecutables = ['npm-prefix.js', 'npm-cli.js']
19
22
 
20
23
  function createInterceptor (itc) {
21
24
  return function (dispatch) {
@@ -95,7 +98,7 @@ export class ChildProcess extends ITC {
95
98
  },
96
99
  getMetrics: (...args) => {
97
100
  return this.#getMetrics(...args)
98
- },
101
+ }
99
102
  }
100
103
  })
101
104
 
@@ -111,8 +114,11 @@ export class ChildProcess extends ITC {
111
114
  this.#setupServer()
112
115
  this.#setupInterceptors()
113
116
 
114
- this.on('close', signal => {
115
- 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
+ }
116
122
  })
117
123
  }
118
124
 
@@ -165,17 +171,16 @@ export class ChildProcess extends ITC {
165
171
  this.#socket.close()
166
172
  }
167
173
 
168
- async #collectMetrics ({ serviceId, metricsConfig }) {
169
- const { registry } = await collectMetrics(serviceId, metricsConfig)
174
+ async #collectMetrics ({ serviceId, workerId, metricsConfig }) {
175
+ const { registry } = await collectMetrics(serviceId, workerId, metricsConfig)
170
176
  this.#metricsRegistry = registry
171
177
  }
172
178
 
173
179
  async #getMetrics ({ format } = {}) {
174
180
  if (!this.#metricsRegistry) return null
175
181
 
176
- const res = format === 'json'
177
- ? await this.#metricsRegistry.getMetricsAsJSON()
178
- : await this.#metricsRegistry.metrics()
182
+ const res =
183
+ format === 'json' ? await this.#metricsRegistry.getMetricsAsJSON() : await this.#metricsRegistry.metrics()
179
184
 
180
185
  return res
181
186
  }
@@ -183,20 +188,30 @@ export class ChildProcess extends ITC {
183
188
  #setupLogger () {
184
189
  // Since this is executed by user code, make sure we only override this in the main thread
185
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
+
186
204
  if (isMainThread) {
187
- this.#logger = pino({
188
- level: 'info',
189
- name: globalThis.platformatic.id,
190
- transport: {
191
- target: new URL('./child-transport.js', import.meta.url).toString(),
192
- options: { id: globalThis.platformatic.id }
193
- }
194
- })
205
+ pinoOptions.transport = {
206
+ target: new URL('./child-transport.js', import.meta.url).toString()
207
+ }
208
+
209
+ this.#logger = pino(pinoOptions)
195
210
 
196
211
  Reflect.defineProperty(process, 'stdout', { value: createPinoWritable(this.#logger, 'info') })
197
212
  Reflect.defineProperty(process, 'stderr', { value: createPinoWritable(this.#logger, 'error', true) })
198
213
  } else {
199
- this.#logger = pino({ level: 'info', name: globalThis.platformatic.id })
214
+ this.#logger = pino(pinoOptions)
200
215
  }
201
216
  }
202
217
 
@@ -248,6 +263,11 @@ export class ChildProcess extends ITC {
248
263
  }
249
264
 
250
265
  tracingChannel('net.server.listen').subscribe(subscribers)
266
+
267
+ const { isEntrypoint, runtimeBasePath, wantsAbsoluteUrls } = globalThis.platformatic
268
+ if (isEntrypoint && runtimeBasePath && !wantsAbsoluteUrls) {
269
+ stripBasePath(runtimeBasePath)
270
+ }
251
271
  }
252
272
 
253
273
  #setupInterceptors () {
@@ -255,11 +275,13 @@ export class ChildProcess extends ITC {
255
275
  }
256
276
 
257
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
+
258
283
  function handleUnhandled (type, err) {
259
- this.#logger.error(
260
- { err: ensureLoggableError(err) },
261
- `Child process for service ${globalThis.platformatic.id} threw an ${type}.`
262
- )
284
+ this.#logger.error({ err: ensureLoggableError(err) }, `Child process for the ${errorLabel} threw an ${type}.`)
263
285
 
264
286
  // Give some time to the logger and ITC notifications to land before shutting down
265
287
  setTimeout(() => process.exit(exitCodes.PROCESS_UNHANDLED_ERROR), 100)
@@ -270,11 +292,64 @@ export class ChildProcess extends ITC {
270
292
  }
271
293
  }
272
294
 
295
+ function stripBasePath (basePath) {
296
+ const kBasePath = Symbol('kBasePath')
297
+
298
+ diagnosticChannel.subscribe('http.server.request.start', ({ request, response }) => {
299
+ if (request.url.startsWith(basePath)) {
300
+ request.url = request.url.slice(basePath.length)
301
+
302
+ if (request.url.charAt(0) !== '/') {
303
+ request.url = '/' + request.url
304
+ }
305
+
306
+ response[kBasePath] = basePath
307
+ }
308
+ })
309
+
310
+ const originWriteHead = ServerResponse.prototype.writeHead
311
+ const originSetHeader = ServerResponse.prototype.setHeader
312
+
313
+ ServerResponse.prototype.writeHead = function (statusCode, statusMessage, headers) {
314
+ if (this[kBasePath] !== undefined) {
315
+ if (headers === undefined && typeof statusMessage === 'object') {
316
+ headers = statusMessage
317
+ statusMessage = undefined
318
+ }
319
+
320
+ if (headers) {
321
+ for (const key in headers) {
322
+ if (key.toLowerCase() === 'location' && !headers[key].startsWith(basePath)) {
323
+ headers[key] = basePath + headers[key]
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ return originWriteHead.call(this, statusCode, statusMessage, headers)
330
+ }
331
+
332
+ ServerResponse.prototype.setHeader = function (name, value) {
333
+ if (this[kBasePath]) {
334
+ if (name.toLowerCase() === 'location' && !value.startsWith(basePath)) {
335
+ value = basePath + value
336
+ }
337
+ }
338
+ originSetHeader.call(this, name, value)
339
+ }
340
+ }
341
+
273
342
  async function main () {
343
+ const executable = basename(process.argv[1] ?? '')
344
+ if (!isMainThread || windowsNpmExecutables.includes(executable)) {
345
+ return
346
+ }
347
+
274
348
  const dataPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${process.env.PLT_MANAGER_ID}.json`)
275
349
  const { data, loader, scripts } = JSON.parse(await readFile(dataPath))
276
350
 
277
351
  globalThis.platformatic = data
352
+ globalThis.platformatic.events = new EventEmitter()
278
353
 
279
354
  if (data.root && isMainThread) {
280
355
  process.chdir(fileURLToPath(data.root))
@@ -291,7 +366,4 @@ async function main () {
291
366
  globalThis[Symbol.for('plt.children.itc')] = new ChildProcess()
292
367
  }
293
368
 
294
- /* c8 ignore next 3 */
295
- if (!isWindows || basename(process.argv.at(-1)) !== 'npm-prefix.js') {
296
- await main()
297
- }
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.6.1",
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.6.1",
27
- "@platformatic/itc": "2.6.1",
28
- "@platformatic/telemetry": "2.6.1",
29
- "@platformatic/metrics": "2.6.1",
30
- "@platformatic/utils": "2.6.1"
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.6.1.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",