@platformatic/basic 2.0.0-alpha.7 → 2.0.0-alpha.8

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/eslint.config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import neostandard from 'neostandard'
2
2
 
3
3
  export default neostandard({
4
- ignores: ['test/tmp/version.js', '**/.next'],
4
+ ignores: ['**/.next'],
5
5
  })
package/index.js CHANGED
@@ -7,18 +7,35 @@ import pino from 'pino'
7
7
  import { packageJson, schema } from './lib/schema.js'
8
8
  import { importFile } from './lib/utils.js'
9
9
 
10
+ function isImportFailedError (error, pkg) {
11
+ if (error.code !== 'ERR_MODULE_NOT_FOUND') {
12
+ return false
13
+ }
14
+
15
+ const match = error.message.match(/Cannot find package '(.+)' imported from (.+)/)
16
+ return match?.[1] === pkg
17
+ }
18
+
10
19
  async function importStackablePackage (opts, pkg, autodetectDescription) {
11
20
  try {
12
21
  try {
13
22
  // Try regular import
14
23
  return await import(pkg)
15
24
  } catch (e) {
25
+ if (!isImportFailedError(e, pkg)) {
26
+ throw e
27
+ }
28
+
16
29
  // Scope to the service
17
30
  const require = createRequire(resolve(opts.context.directory, 'index.js'))
18
31
  const imported = require.resolve(pkg)
19
32
  return await importFile(imported)
20
33
  }
21
34
  } catch (e) {
35
+ if (!isImportFailedError(e, pkg)) {
36
+ throw e
37
+ }
38
+
22
39
  const rootFolder = relative(process.cwd(), workerData.dirname)
23
40
 
24
41
  let errorMessage = `Unable to import package, "${pkg}". Please add it as a dependency `
@@ -120,4 +137,4 @@ export * as errors from './lib/errors.js'
120
137
  export { schema, schemaComponents } from './lib/schema.js'
121
138
  export * from './lib/utils.js'
122
139
  export * from './lib/worker/child-manager.js'
123
- export * from './lib/worker/server-listener.js'
140
+ export * from './lib/worker/listeners.js'
package/lib/base.js CHANGED
@@ -1,15 +1,32 @@
1
+ import { parseCommandString } from 'execa'
2
+ import { spawn } from 'node:child_process'
3
+ import { once } from 'node:events'
4
+ import { existsSync } from 'node:fs'
5
+ import { platform } from 'node:os'
6
+ import { pathToFileURL } from 'node:url'
1
7
  import pino from 'pino'
8
+ import split2 from 'split2'
9
+ import { NonZeroExitCode } from './errors.js'
10
+ import { cleanBasePath } from './utils.js'
11
+ import { ChildManager } from './worker/child-manager.js'
2
12
 
3
13
  export class BaseStackable {
14
+ #childManager
15
+ #subprocess
16
+ #subprocessStarted
17
+
4
18
  constructor (type, version, options, root, configManager) {
5
19
  this.type = type
6
20
  this.version = version
7
21
  this.id = options.context.serviceId
22
+ this.options = options
8
23
  this.root = root
9
24
  this.configManager = configManager
10
25
  this.serverConfig = options.context.serverConfig
11
26
  this.openapiSchema = null
12
27
  this.getGraphqlSchema = null
28
+ this.isEntrypoint = options.context.isEntrypoint
29
+ this.isProduction = options.context.isProduction
13
30
 
14
31
  // Setup the logger
15
32
  const pinoOptions = {
@@ -24,7 +41,8 @@ export class BaseStackable {
24
41
  // Setup globals
25
42
  this.registerGlobals({
26
43
  setOpenapiSchema: this.setOpenapiSchema.bind(this),
27
- setGraphqlSchema: this.setGraphqlSchema.bind(this)
44
+ setGraphqlSchema: this.setGraphqlSchema.bind(this),
45
+ setServicePrefix: this.setServicePrefix.bind(this)
28
46
  })
29
47
  }
30
48
 
@@ -80,6 +98,10 @@ export class BaseStackable {
80
98
  this.graphqlSchema = schema
81
99
  }
82
100
 
101
+ setServicePrefix (prefix) {
102
+ this.servicePrefix = prefix
103
+ }
104
+
83
105
  async log ({ message, level }) {
84
106
  const logLevel = level ?? 'info'
85
107
  this.logger[logLevel](message)
@@ -88,4 +110,141 @@ export class BaseStackable {
88
110
  registerGlobals (globals) {
89
111
  globalThis.platformatic = Object.assign(globalThis.platformatic ?? {}, globals)
90
112
  }
113
+
114
+ verifyOutputDirectory (path) {
115
+ if (this.isProduction && !existsSync(path)) {
116
+ throw new Error(
117
+ `Cannot access directory '${path}'. Please run the 'build' command before running in production mode.`
118
+ )
119
+ }
120
+ }
121
+
122
+ async buildWithCommand (command, basePath, loader) {
123
+ if (Array.isArray(command)) {
124
+ command = command.join(' ')
125
+ }
126
+
127
+ this.logger.debug(`Executing "${command}" ...`)
128
+
129
+ this.#childManager = new ChildManager({
130
+ logger: this.logger,
131
+ loader,
132
+ context: {
133
+ id: this.id,
134
+ // Always use URL to avoid serialization problem in Windows
135
+ root: pathToFileURL(this.root).toString(),
136
+ basePath,
137
+ logLevel: this.logger.level,
138
+ port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true
139
+ }
140
+ })
141
+
142
+ try {
143
+ await this.#childManager.inject()
144
+
145
+ const subprocess = this.spawn(command)
146
+
147
+ // Wait for the process to be started
148
+ await new Promise((resolve, reject) => {
149
+ subprocess.on('spawn', resolve)
150
+ subprocess.on('error', reject)
151
+ })
152
+
153
+ // Route anything not catched by child process logger to the logger manually
154
+ subprocess.stdout.pipe(split2()).on('data', line => {
155
+ this.logger.info(line)
156
+ })
157
+
158
+ subprocess.stderr.pipe(split2()).on('data', line => {
159
+ this.logger.error(line)
160
+ })
161
+
162
+ const [exitCode] = await once(subprocess, 'exit')
163
+
164
+ if (exitCode !== 0) {
165
+ const error = new NonZeroExitCode(exitCode)
166
+ error.exitCode = exitCode
167
+ throw error
168
+ }
169
+ } finally {
170
+ await this.#childManager.eject()
171
+ await this.#childManager.close()
172
+ }
173
+ }
174
+
175
+ async startWithCommand (command, loader) {
176
+ const config = this.configManager.current
177
+ const basePath = config.application?.basePath ? cleanBasePath(config.application?.basePath) : ''
178
+
179
+ this.#childManager = new ChildManager({
180
+ logger: this.logger,
181
+ loader,
182
+ context: {
183
+ id: this.id,
184
+ // Always use URL to avoid serialization problem in Windows
185
+ root: pathToFileURL(this.root).toString(),
186
+ basePath,
187
+ logLevel: this.logger.level,
188
+ port: (this.isEntrypoint ? this.serverConfig?.port || 0 : undefined) ?? true
189
+ }
190
+ })
191
+
192
+ this.#childManager.on('config', config => {
193
+ this.subprocessConfig = config
194
+ })
195
+
196
+ try {
197
+ await this.#childManager.inject()
198
+
199
+ this.subprocess = this.spawn(command)
200
+
201
+ // Route anything not catched by child process logger to the logger manually
202
+ this.subprocess.stdout.pipe(split2()).on('data', line => {
203
+ this.logger.info(line)
204
+ })
205
+
206
+ this.subprocess.stderr.pipe(split2()).on('data', line => {
207
+ this.logger.error(line)
208
+ })
209
+
210
+ // Wait for the process to be started
211
+ await new Promise((resolve, reject) => {
212
+ this.subprocess.on('spawn', resolve)
213
+ this.subprocess.on('error', reject)
214
+ })
215
+
216
+ this.#subprocessStarted = true
217
+ } catch (e) {
218
+ throw new Error(`Cannot execute command "${command}": executable not found`)
219
+ } finally {
220
+ await this.#childManager.eject()
221
+ }
222
+
223
+ // // If the process exits prematurely, terminate the thread with the same code
224
+ this.subprocess.on('exit', code => {
225
+ if (this.#subprocessStarted && typeof code === 'number' && code !== 0) {
226
+ process.exit(code)
227
+ }
228
+ })
229
+
230
+ const [url] = await once(this.#childManager, 'url')
231
+ this.url = url
232
+ }
233
+
234
+ async stopCommand () {
235
+ this.#subprocessStarted = false
236
+ const exitPromise = once(this.subprocess, 'exit')
237
+
238
+ this.#childManager.close(this.subprocessTerminationSignal ?? 'SIGINT')
239
+ this.subprocess.kill(this.subprocessTerminationSignal ?? 'SIGINT')
240
+ await exitPromise
241
+ }
242
+
243
+ spawn (command) {
244
+ const [executable, ...args] = parseCommandString(command)
245
+
246
+ return platform() === 'win32'
247
+ ? spawn(command, { cwd: this.root, shell: true, windowsVerbatimArguments: true })
248
+ : spawn(executable, args, { cwd: this.root })
249
+ }
91
250
  }
package/lib/errors.js CHANGED
@@ -2,7 +2,20 @@ import createError from '@fastify/error'
2
2
 
3
3
  const ERROR_PREFIX = 'PLT_BASIC'
4
4
 
5
+ export const exitCodes = {
6
+ MANAGER_MESSAGE_HANDLING_FAILED: 11,
7
+ MANAGER_SOCKET_ERROR: 11,
8
+ PROCESS_UNHANDLED_ERROR: 20,
9
+ PROCESS_MESSAGE_HANDLING_FAILED: 21,
10
+ PROCESS_SOCKET_ERROR: 22
11
+ }
12
+
5
13
  export const UnsupportedVersion = createError(
6
14
  `${ERROR_PREFIX}_UNSUPPORTED_VERSION`,
7
15
  '%s version %s is not supported. Please use version %s.'
8
16
  )
17
+
18
+ export const NonZeroExitCode = createError(
19
+ `${ERROR_PREFIX}_NON_ZERO_EXIT_CODE`,
20
+ 'Process exit with non zero exit code %d.'
21
+ )
package/lib/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { schemas } from '@platformatic/utils'
1
+ import { schemaComponents as utilsSchemaComponents } from '@platformatic/utils'
2
2
  import { readFileSync } from 'node:fs'
3
3
 
4
4
  export const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
@@ -8,14 +8,48 @@ const application = {
8
8
  properties: {
9
9
  basePath: {
10
10
  type: 'string'
11
+ },
12
+ outputDirectory: {
13
+ type: 'string',
14
+ default: 'dist'
15
+ },
16
+ include: {
17
+ type: 'array',
18
+ items: {
19
+ type: 'string'
20
+ },
21
+ default: ['dist']
22
+ },
23
+ commands: {
24
+ type: 'object',
25
+ properties: {
26
+ install: {
27
+ type: 'string',
28
+ default: 'npm ci --omit-dev'
29
+ },
30
+ // All the following options purposely don't have a default so
31
+ // that stackables can detect if the user explicitly set them.
32
+ build: {
33
+ type: 'string'
34
+ },
35
+ development: {
36
+ type: 'string'
37
+ },
38
+ production: {
39
+ type: 'string'
40
+ }
41
+ },
42
+ default: {},
43
+ additionalProperties: false
11
44
  }
12
45
  },
13
- additionalProperties: false
46
+ additionalProperties: false,
47
+ default: {}
14
48
  }
15
49
 
16
50
  const watch = {
17
51
  anyOf: [
18
- schemas.watch,
52
+ utilsSchemaComponents.watch,
19
53
  {
20
54
  type: 'boolean'
21
55
  },
@@ -25,33 +59,7 @@ const watch = {
25
59
  ]
26
60
  }
27
61
 
28
- const deploy = {
29
- type: 'object',
30
- properties: {
31
- include: {
32
- type: 'array',
33
- items: {
34
- type: 'string'
35
- },
36
- default: ['dist']
37
- },
38
- buildCommand: {
39
- type: 'string',
40
- default: 'npm run build'
41
- },
42
- installCommand: {
43
- type: 'string',
44
- default: 'npm ci --omit-dev'
45
- },
46
- startCommand: {
47
- type: 'string',
48
- default: 'npm run start'
49
- }
50
- },
51
- default: {}
52
- }
53
-
54
- export const schemaComponents = { application, deploy, watch }
62
+ export const schemaComponents = { application, watch }
55
63
 
56
64
  export const schema = {
57
65
  $id: `https://schemas.platformatic.dev/@platformatic/basic/${packageJson.version}.json`,
package/lib/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createRequire } from 'node:module'
1
2
  import { pathToFileURL } from 'node:url'
2
3
  import { request } from 'undici'
3
4
 
@@ -38,7 +39,35 @@ export async function injectViaRequest (baseUrl, injectParams, onInject) {
38
39
  }
39
40
  }
40
41
 
42
+ export function ensureFileUrl (pathOrUrl) {
43
+ if (!pathOrUrl) {
44
+ return pathOrUrl
45
+ }
46
+
47
+ pathOrUrl = pathOrUrl.toString()
48
+
49
+ if (pathOrUrl.startsWith('file://')) {
50
+ return pathOrUrl
51
+ }
52
+
53
+ return pathToFileURL(pathOrUrl)
54
+ }
55
+
41
56
  // This is to avoid common path/URL problems on Windows
42
57
  export function importFile (path) {
43
- return import(pathToFileURL(path))
58
+ return import(ensureFileUrl(path))
59
+ }
60
+
61
+ export function resolvePackage (root, pkg) {
62
+ const require = createRequire(root)
63
+
64
+ return require.resolve(pkg, { paths: [root, ...require.main.paths] })
65
+ }
66
+
67
+ export function cleanBasePath (basePath) {
68
+ return basePath ? `/${basePath}`.replaceAll(/\/+/g, '/').replace(/\/$/, '') : '/'
69
+ }
70
+
71
+ export function ensureTrailingSlash (basePath) {
72
+ return basePath ? `${basePath}${basePath.endsWith('/') ? '' : '/'}` : '/'
44
73
  }
@@ -1,85 +1,206 @@
1
- import { ITC } from '@platformatic/itc'
2
- import { subscribe, unsubscribe } from 'node:diagnostics_channel'
1
+ import { ITC, generateNotification } from '@platformatic/itc'
2
+ import { createDirectory, errors } from '@platformatic/utils'
3
3
  import { once } from 'node:events'
4
+ import { rm, writeFile } from 'node:fs/promises'
5
+ import { createServer } from 'node:http'
6
+ import { register } from 'node:module'
7
+ import { platform, tmpdir } from 'node:os'
8
+ import { dirname, resolve } from 'node:path'
4
9
  import { workerData } from 'node:worker_threads'
5
10
  import { request } from 'undici'
11
+ import { WebSocketServer } from 'ws'
12
+ import { exitCodes } from '../errors.js'
13
+ import { ensureFileUrl } from '../utils.js'
6
14
 
7
- export const childProcessWorkerFile = new URL('./child-process.js', import.meta.url)
15
+ export const isWindows = platform() === 'win32'
16
+
17
+ // In theory we could use the context.id to namespace even more, but due to
18
+ // UNIX socket length limitation on MacOS, we don't.
19
+ function generateChildrenId (context) {
20
+ return [process.pid, Date.now()].join('-')
21
+ }
22
+
23
+ export function getSocketPath (id) {
24
+ let socketPath = null
25
+ if (platform() === 'win32') {
26
+ socketPath = `\\\\.\\pipe\\plt-${id}`
27
+ } else {
28
+ // As stated in https://nodejs.org/dist/latest-v20.x/docs/api/net.html#identifying-paths-for-ipc-connections,
29
+ // Node will take care of deleting the file for us
30
+ socketPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${id}.socket`)
31
+ }
32
+
33
+ return socketPath
34
+ }
8
35
 
9
36
  export class ChildManager extends ITC {
10
- #child
37
+ #id
38
+ #loader
39
+ #context
40
+ #scripts
41
+ #logger
42
+ #server
43
+ #socketPath
44
+ #clients
45
+ #requests
46
+ #currentClient
11
47
  #listener
12
- #injectedNodeOptions
13
48
  #originalNodeOptions
49
+ #dataPath
50
+
51
+ constructor (opts) {
52
+ let { loader, context, scripts, handlers, ...itcOpts } = opts
53
+
54
+ context ??= {}
55
+ scripts ??= []
14
56
 
15
- constructor ({ loader, context }) {
16
57
  super({
58
+ name: 'child-manager',
59
+ ...itcOpts,
17
60
  handlers: {
18
- log (message) {
19
- workerData.loggingPort.postMessage(JSON.parse(message))
61
+ log: message => {
62
+ return this.#log(message)
20
63
  },
21
64
  fetch: request => {
22
65
  return this.#fetch(request)
23
- }
66
+ },
67
+ ...itcOpts.handlers
24
68
  }
25
69
  })
26
70
 
27
- const childHandler = ({ process: child }) => {
28
- unsubscribe('child_process', childHandler)
71
+ this.#id = generateChildrenId(context)
72
+ this.#loader = loader
73
+ this.#context = context
74
+ this.#scripts = scripts
75
+ this.#originalNodeOptions = process.env.NODE_OPTIONS
76
+ this.#logger = globalThis.platformatic.logger
77
+ this.#server = createServer()
78
+ this.#socketPath ??= getSocketPath(this.#id)
79
+ this.#clients = new Set()
80
+ this.#requests = new Map()
81
+ }
29
82
 
30
- this.#child = child
31
- this.#child.once('exit', () => {
32
- this.emit('exit')
33
- })
83
+ async listen () {
84
+ super.listen()
34
85
 
35
- this.listen()
86
+ if (!isWindows) {
87
+ await createDirectory(dirname(this.#socketPath))
36
88
  }
37
89
 
38
- subscribe('child_process', childHandler)
90
+ const wssServer = new WebSocketServer({ server: this.#server })
91
+
92
+ wssServer.on('connection', ws => {
93
+ this.#clients.add(ws)
94
+
95
+ ws.on('message', raw => {
96
+ try {
97
+ const message = JSON.parse(raw)
98
+ this.#requests.set(message.reqId, ws)
99
+ this.#listener(message)
100
+ } catch (error) {
101
+ this.#handleUnexpectedError(error, 'Handling a message failed.', exitCodes.MANAGER_MESSAGE_HANDLING_FAILED)
102
+ }
103
+ })
39
104
 
40
- this.#prepareChildEnvironment(loader, context)
105
+ ws.on('close', () => {
106
+ this.#clients.delete(ws)
107
+ })
108
+
109
+ ws.on('error', error => {
110
+ this.#handleUnexpectedError(
111
+ error,
112
+ 'Error while communicating with the children process.',
113
+ exitCodes.MANAGER_SOCKET_ERROR
114
+ )
115
+ })
116
+ })
117
+
118
+ return new Promise((resolve, reject) => {
119
+ this.#server.listen({ path: this.#socketPath }, resolve).on('error', reject)
120
+ })
41
121
  }
42
122
 
43
- inject () {
44
- process.env.NODE_OPTIONS = this.#injectedNodeOptions
123
+ async close (signal) {
124
+ await rm(this.#dataPath)
125
+
126
+ for (const client of this.#clients) {
127
+ this.#currentClient = client
128
+ this._send(generateNotification('close', signal))
129
+ }
130
+
131
+ super.close()
132
+ }
133
+
134
+ async inject () {
135
+ await this.listen()
136
+
137
+ // Serialize data into a JSON file for the stackable to use
138
+ this.#dataPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${this.#id}.json`)
139
+ await createDirectory(dirname(this.#dataPath))
140
+
141
+ // We write all the data to a JSON file
142
+ await writeFile(
143
+ this.#dataPath,
144
+ JSON.stringify(
145
+ {
146
+ data: this.#context,
147
+ loader: ensureFileUrl(this.#loader),
148
+ scripts: this.#scripts.map(s => ensureFileUrl(s))
149
+ },
150
+ null,
151
+ 2
152
+ ),
153
+ 'utf-8'
154
+ )
155
+
156
+ process.env.PLT_MANAGER_ID = this.#id
157
+ process.env.NODE_OPTIONS =
158
+ `--import="${new URL('./child-process.js', import.meta.url)}" ${process.env.NODE_OPTIONS ?? ''}`.trim()
45
159
  }
46
160
 
47
- eject () {
161
+ async eject () {
48
162
  process.env.NODE_OPTIONS = this.#originalNodeOptions
163
+ process.env.PLT_MANAGER_ID = ''
49
164
  }
50
165
 
51
- _setupListener (listener) {
52
- this.#listener = listener
53
- this.#child.on('message', this.#listener)
166
+ register () {
167
+ register(this.#loader, { data: this.#context })
168
+ }
169
+
170
+ send (client, name, message) {
171
+ this.#currentClient = client
172
+ super.send(name, message)
173
+ }
174
+
175
+ _send (message) {
176
+ if (!this.#currentClient) {
177
+ this.#currentClient = this.#requests.get(message.reqId)
178
+ this.#requests.delete(message.reqId)
179
+
180
+ if (!this.#currentClient) {
181
+ return
182
+ }
183
+ }
184
+
185
+ this.#currentClient.send(JSON.stringify(message))
186
+ this.#currentClient = null
54
187
  }
55
188
 
56
- _send (request) {
57
- this.#child.send(request)
189
+ _setupListener (listener) {
190
+ this.#listener = listener
58
191
  }
59
192
 
60
193
  _createClosePromise () {
61
- return once(this.#child, 'exit')
194
+ return once(this.#server, 'exit')
62
195
  }
63
196
 
64
197
  _close () {
65
- this.#child.removeListener('message', this.#listener)
66
- this.#child.kill('SIGKILL')
198
+ this.#server.close()
67
199
  }
68
200
 
69
- #prepareChildEnvironment (loader, context) {
70
- this.#originalNodeOptions = process.env.NODE_OPTIONS
71
-
72
- const loaderScript = `
73
- import { register } from 'node:module';
74
- globalThis.platformatic=${JSON.stringify(context).replaceAll('"', '\\"')};
75
- register('${loader}',{ data: globalThis.platformatic });
76
- `
77
-
78
- this.#injectedNodeOptions = [
79
- `--import="data:text/javascript,${loaderScript.replaceAll(/\n/g, '')}"`,
80
- `--import=${childProcessWorkerFile}`,
81
- process.env.NODE_OPTIONS ?? ''
82
- ].join(' ')
201
+ #log (message) {
202
+ const logs = Array.isArray(message.logs) ? message.logs : [message.logs]
203
+ workerData.loggingPort.postMessage({ logs: logs.map(m => JSON.stringify(m)) })
83
204
  }
84
205
 
85
206
  async #fetch (opts) {
@@ -90,4 +211,9 @@ export class ChildManager extends ITC {
90
211
 
91
212
  return { statusCode, headers, body: payload, payload, rawPayload }
92
213
  }
214
+
215
+ #handleUnexpectedError (error, message, exitCode) {
216
+ this.#logger.error({ err: errors.ensureLoggableError(error) }, message)
217
+ process.exit(exitCode)
218
+ }
93
219
  }
@@ -1,8 +1,18 @@
1
1
  import { ITC } from '@platformatic/itc'
2
- import { createPinoWritable, DestinationWritable, withResolvers } from '@platformatic/utils'
2
+ import { createPinoWritable, errors } from '@platformatic/utils'
3
3
  import { tracingChannel } from 'node:diagnostics_channel'
4
+ import { once } from 'node:events'
5
+ import { readFile } from 'node:fs/promises'
6
+ import { register } from 'node:module'
7
+ import { platform, tmpdir } from 'node:os'
8
+ import { basename, resolve } from 'node:path'
9
+ import { isMainThread } from 'node:worker_threads'
4
10
  import pino from 'pino'
5
11
  import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
12
+ import { WebSocket } from 'ws'
13
+ import { exitCodes } from '../errors.js'
14
+ import { importFile } from '../utils.js'
15
+ import { getSocketPath, isWindows } from './child-manager.js'
6
16
 
7
17
  function createInterceptor (itc) {
8
18
  return function (dispatch) {
@@ -18,7 +28,7 @@ function createInterceptor (itc) {
18
28
  }
19
29
 
20
30
  const headers = {
21
- ...opts?.headers,
31
+ ...opts?.headers
22
32
  }
23
33
 
24
34
  delete headers.connection
@@ -27,7 +37,7 @@ function createInterceptor (itc) {
27
37
 
28
38
  const requestOpts = {
29
39
  ...opts,
30
- headers,
40
+ headers
31
41
  }
32
42
  delete requestOpts.dispatcher
33
43
 
@@ -64,66 +74,104 @@ function createInterceptor (itc) {
64
74
  }
65
75
  }
66
76
 
67
- class ChildProcessWritable extends DestinationWritable {
68
- #itc
69
-
70
- constructor (options) {
71
- const { itc, ...opts } = options
72
-
73
- super(opts)
74
- this.#itc = itc
75
- }
76
-
77
- _send (message) {
78
- this.#itc.send('log', JSON.stringify(message))
79
- }
80
- }
81
-
82
- class ChildProcess extends ITC {
77
+ export class ChildProcess extends ITC {
83
78
  #listener
79
+ #socket
84
80
  #child
85
81
  #logger
82
+ #pendingMessages
86
83
 
87
84
  constructor () {
88
- super({})
85
+ super({ throwOnMissingHandler: false, name: `${process.env.PLT_MANAGER_ID}-child-process` })
86
+
87
+ const protocol = platform() === 'win32' ? 'ws+unix:' : 'ws+unix://'
88
+ this.#socket = new WebSocket(`${protocol}${getSocketPath(process.env.PLT_MANAGER_ID)}`)
89
+ this.#pendingMessages = []
89
90
 
90
91
  this.listen()
91
92
  this.#setupLogger()
93
+ this.#setupHandlers()
92
94
  this.#setupServer()
93
95
  this.#setupInterceptors()
96
+
97
+ this.on('close', signal => {
98
+ process.kill(process.pid, signal)
99
+ })
94
100
  }
95
101
 
96
102
  _setupListener (listener) {
97
103
  this.#listener = listener
98
- process.on('message', this.#listener)
104
+
105
+ this.#socket.on('open', () => {
106
+ // Never hang the process on this socket.
107
+ this.#socket._socket.unref()
108
+
109
+ for (const message of this.#pendingMessages) {
110
+ this.#socket.send(message)
111
+ }
112
+ })
113
+
114
+ this.#socket.on('message', message => {
115
+ try {
116
+ this.#listener(JSON.parse(message))
117
+ } catch (error) {
118
+ this.#logger.error({ err: errors.ensureLoggableError(error) }, 'Handling a message failed.')
119
+ process.exit(exitCodes.PROCESS_MESSAGE_HANDLING_FAILED)
120
+ }
121
+ })
122
+
123
+ this.#socket.on('error', error => {
124
+ process._rawDebug(error)
125
+ // There is nothing to log here as the connection with the parent thread is lost. Exit with a special code
126
+ process.exit(exitCodes.PROCESS_SOCKET_ERROR)
127
+ })
99
128
  }
100
129
 
101
- _send (request) {
102
- process.send(request)
130
+ _send (message) {
131
+ if (this.#socket.readyState === WebSocket.CONNECTING) {
132
+ this.#pendingMessages.push(JSON.stringify(message))
133
+ return
134
+ }
135
+
136
+ this.#socket.send(JSON.stringify(message))
103
137
  }
104
138
 
105
139
  _createClosePromise () {
106
- const { promise } = withResolvers()
107
- return promise
140
+ return once(this.#socket, 'close')
108
141
  }
109
142
 
110
143
  _close () {
111
- process.kill(process.pid, 'SIGKILL')
112
- this.#child.removeListener('message', this.#listener)
144
+ this.#socket.close()
113
145
  }
114
146
 
115
147
  #setupLogger () {
116
- const destination = new ChildProcessWritable({ itc: this })
117
- this.#logger = pino({ level: 'info', ...globalThis.platformatic.logger }, destination)
118
-
119
- Reflect.defineProperty(process, 'stdout', { value: createPinoWritable(this.#logger, 'info') })
120
- Reflect.defineProperty(process, 'stderr', { value: createPinoWritable(this.#logger, 'error') })
148
+ // Since this is executed by user code, make sure we only override this in the main thread
149
+ // The rest will be intercepted by the BaseStackable.
150
+ if (isMainThread) {
151
+ this.#logger = pino({
152
+ level: 'info',
153
+ name: globalThis.platformatic.id,
154
+ transport: {
155
+ target: new URL('./child-transport.js', import.meta.url).toString(),
156
+ options: { id: globalThis.platformatic.id }
157
+ }
158
+ })
159
+
160
+ Reflect.defineProperty(process, 'stdout', { value: createPinoWritable(this.#logger, 'info') })
161
+ Reflect.defineProperty(process, 'stderr', { value: createPinoWritable(this.#logger, 'error') })
162
+ } else {
163
+ this.#logger = pino({ level: 'info', name: globalThis.platformatic.id })
164
+ }
121
165
  }
122
166
 
123
167
  #setupServer () {
124
168
  const subscribers = {
125
169
  asyncStart ({ options }) {
126
- options.port = 0
170
+ const port = globalThis.platformatic.port
171
+
172
+ if (port !== false) {
173
+ options.port = typeof port === 'number' ? port : 0
174
+ }
127
175
  },
128
176
  asyncEnd: ({ server }) => {
129
177
  tracingChannel('net.server.listen').unsubscribe(subscribers)
@@ -136,7 +184,7 @@ class ChildProcess extends ITC {
136
184
  error: error => {
137
185
  tracingChannel('net.server.listen').unsubscribe(subscribers)
138
186
  this.notify('error', error)
139
- },
187
+ }
140
188
  }
141
189
 
142
190
  tracingChannel('net.server.listen').subscribe(subscribers)
@@ -145,6 +193,40 @@ class ChildProcess extends ITC {
145
193
  #setupInterceptors () {
146
194
  setGlobalDispatcher(getGlobalDispatcher().compose(createInterceptor(this)))
147
195
  }
196
+
197
+ #setupHandlers () {
198
+ function handleUnhandled (type, err) {
199
+ process._rawDebug(globalThis.platformatic.id, err)
200
+ this.#logger.error(
201
+ { err: errors.ensureLoggableError(err) },
202
+ `Child process for service ${globalThis.platformatic.id} threw an ${type}.`
203
+ )
204
+
205
+ process.exit(exitCodes.PROCESS_UNHANDLED_ERROR)
206
+ }
207
+
208
+ process.on('uncaughtException', handleUnhandled.bind(this, 'uncaught exception'))
209
+ process.on('unhandledRejection', handleUnhandled.bind(this, 'unhandled rejection'))
210
+ }
148
211
  }
149
212
 
150
- globalThis[Symbol.for('plt.children.itc')] = new ChildProcess()
213
+ async function main () {
214
+ const dataPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${process.env.PLT_MANAGER_ID}.json`)
215
+ const { data, loader, scripts } = JSON.parse(await readFile(dataPath))
216
+
217
+ globalThis.platformatic = data
218
+
219
+ if (loader) {
220
+ register(loader, { data })
221
+ }
222
+
223
+ for (const script of scripts) {
224
+ await importFile(script)
225
+ }
226
+
227
+ globalThis[Symbol.for('plt.children.itc')] = new ChildProcess()
228
+ }
229
+
230
+ if (!isWindows || basename(process.argv.at(-1)) !== 'npm-prefix.js') {
231
+ await main()
232
+ }
@@ -0,0 +1,54 @@
1
+ import { generateRequest } from '@platformatic/itc'
2
+ import { errors } from '@platformatic/utils'
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
+ function logDirectError (message, error) {
11
+ process._rawDebug(`Logger thread for child process of service ${workerData.id} ${message}.`, {
12
+ error: errors.ensureLoggableError(error)
13
+ })
14
+ }
15
+
16
+ function handleUnhandled (type, error) {
17
+ logDirectError(`threw an ${type}`, error)
18
+ process.exit(6)
19
+ }
20
+
21
+ process.on('uncaughtException', handleUnhandled.bind(null, 'uncaught exception'))
22
+ process.on('unhandledRejection', handleUnhandled.bind(null, 'unhandled rejection'))
23
+
24
+ export default async function (opts) {
25
+ try {
26
+ const protocol = platform() === 'win32' ? 'ws+unix:' : 'ws+unix://'
27
+ const socket = new WebSocket(`${protocol}${getSocketPath(process.env.PLT_MANAGER_ID)}`)
28
+
29
+ await once(socket, 'open')
30
+
31
+ // Do not process responses but empty the socket inbound queue
32
+ socket.on('message', () => {})
33
+
34
+ socket.on('error', error => {
35
+ logDirectError('threw a socket error', error)
36
+ })
37
+
38
+ return build(
39
+ async function (source) {
40
+ for await (const obj of source) {
41
+ socket.send(JSON.stringify(generateRequest('log', { logs: [obj] })))
42
+ }
43
+ },
44
+ {
45
+ close (_, cb) {
46
+ socket.close()
47
+ cb()
48
+ }
49
+ }
50
+ )
51
+ } catch (error) {
52
+ logDirectError('threw a connection error', error)
53
+ }
54
+ }
@@ -0,0 +1,51 @@
1
+ import { withResolvers } from '@platformatic/utils'
2
+ import { subscribe, tracingChannel, unsubscribe } from 'node:diagnostics_channel'
3
+
4
+ export function createServerListener (overridePort = true) {
5
+ const { promise, resolve, reject } = withResolvers()
6
+
7
+ const subscribers = {
8
+ asyncStart ({ options }) {
9
+ if (overridePort !== false) {
10
+ options.port = typeof overridePort === 'number' ? overridePort : 0
11
+ }
12
+ },
13
+ asyncEnd ({ server }) {
14
+ resolve(server)
15
+ },
16
+ error (error) {
17
+ cancel()
18
+ reject(error)
19
+ }
20
+ }
21
+
22
+ function cancel () {
23
+ tracingChannel('net.server.listen').unsubscribe(subscribers)
24
+ }
25
+
26
+ tracingChannel('net.server.listen').subscribe(subscribers)
27
+ promise.finally(cancel)
28
+ promise.cancel = resolve.bind(null, null)
29
+
30
+ return promise
31
+ }
32
+
33
+ export function createChildProcessListener () {
34
+ const { promise, resolve } = withResolvers()
35
+
36
+ const handler = ({ process: child }) => {
37
+ unsubscribe('child_process', handler)
38
+ resolve(child)
39
+ }
40
+
41
+ function cancel () {
42
+ unsubscribe('child_process', handler)
43
+ }
44
+
45
+ subscribe('child_process', handler)
46
+
47
+ promise.finally(cancel)
48
+ promise.cancel = resolve.bind(null, null)
49
+
50
+ return promise
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/basic",
3
- "version": "2.0.0-alpha.7",
3
+ "version": "2.0.0-alpha.8",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -16,12 +16,16 @@
16
16
  "homepage": "https://github.com/platformatic/platformatic#readme",
17
17
  "dependencies": {
18
18
  "@fastify/error": "^4.0.0",
19
+ "execa": "^9.3.1",
19
20
  "pino": "^9.3.2",
21
+ "pino-abstract-transport": "^2.0.0",
20
22
  "semver": "^7.6.3",
23
+ "split2": "^4.2.0",
21
24
  "undici": "^6.19.5",
22
- "@platformatic/config": "2.0.0-alpha.7",
23
- "@platformatic/itc": "2.0.0-alpha.7",
24
- "@platformatic/utils": "2.0.0-alpha.7"
25
+ "ws": "^8.18.0",
26
+ "@platformatic/config": "2.0.0-alpha.8",
27
+ "@platformatic/itc": "2.0.0-alpha.8",
28
+ "@platformatic/utils": "2.0.0-alpha.8"
25
29
  },
26
30
  "devDependencies": {
27
31
  "borp": "^0.17.0",
@@ -35,9 +39,8 @@
35
39
  "react-dom": "^18.3.1",
36
40
  "typescript": "^5.5.4",
37
41
  "vite": "^5.4.0",
38
- "ws": "^8.18.0",
39
- "@platformatic/composer": "2.0.0-alpha.7",
40
- "@platformatic/service": "2.0.0-alpha.7"
42
+ "@platformatic/composer": "2.0.0-alpha.8",
43
+ "@platformatic/service": "2.0.0-alpha.8"
41
44
  },
42
45
  "scripts": {
43
46
  "gen-schema": "node lib/schema.js > schema.json",
package/schema.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$id": "https://schemas.platformatic.dev/@platformatic/basic/2.0.0-alpha.7.json",
2
+ "$id": "https://schemas.platformatic.dev/@platformatic/basic/2.0.0-alpha.8.json",
3
3
  "$schema": "http://json-schema.org/draft-07/schema#",
4
4
  "title": "Platformatic Stackable",
5
5
  "type": "object",
@@ -1,29 +0,0 @@
1
- import { withResolvers } from '@platformatic/utils'
2
- import { tracingChannel } from 'node:diagnostics_channel'
3
-
4
- export function createServerListener () {
5
- const { promise, resolve, reject } = withResolvers()
6
-
7
- const subscribers = {
8
- asyncStart ({ options }) {
9
- options.port = 0
10
- },
11
- asyncEnd ({ server }) {
12
- resolve(server)
13
- },
14
- error (error) {
15
- cancel()
16
- reject(error)
17
- },
18
- }
19
-
20
- function cancel () {
21
- tracingChannel('net.server.listen').unsubscribe(subscribers)
22
- }
23
-
24
- tracingChannel('net.server.listen').subscribe(subscribers)
25
- promise.finally(cancel)
26
- promise.cancel = resolve.bind(null, null)
27
-
28
- return promise
29
- }
package/test/helper.js DELETED
@@ -1,166 +0,0 @@
1
- import { createDirectory, withResolvers } from '@platformatic/utils'
2
- import { deepStrictEqual, ok, strictEqual } from 'node:assert'
3
- import { existsSync } from 'node:fs'
4
- import { readFile, symlink, writeFile } from 'node:fs/promises'
5
- import { dirname, resolve } from 'node:path'
6
- import { setTimeout as sleep } from 'node:timers/promises'
7
- import { Client, request } from 'undici'
8
- import WebSocket from 'ws'
9
- import { loadConfig } from '../../config/index.js'
10
- import { buildServer, platformaticRuntime } from '../../runtime/index.js'
11
-
12
- export { setTimeout as sleep } from 'node:timers/promises'
13
-
14
- const HMR_TIMEOUT = process.env.CI ? 20000 : 10000
15
- let hrmVersion = Date.now()
16
- export let fixturesDir
17
-
18
- export function setFixturesDir (directory) {
19
- fixturesDir = directory
20
- }
21
-
22
- // This is used to debug tests
23
- export function pause (url, timeout = 300000) {
24
- console.log(`Server is listening at ${url}`)
25
- return sleep(timeout)
26
- }
27
-
28
- export async function ensureDependency (directory, pkg, source) {
29
- const [namespace, name] = pkg.includes('/') ? pkg.split('/') : ['', pkg]
30
- const basedir = resolve(fixturesDir, directory, `node_modules/${namespace}`)
31
- const destination = resolve(basedir, name)
32
-
33
- await createDirectory(basedir)
34
- if (!existsSync(destination)) {
35
- await symlink(source, destination, 'dir')
36
- }
37
- }
38
-
39
- export async function createRuntime (t, path, packageRoot) {
40
- const configFile = resolve(fixturesDir, path)
41
-
42
- if (packageRoot) {
43
- const packageName = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf-8')).name
44
- const root = dirname(configFile)
45
- await ensureDependency(root, packageName, packageRoot)
46
- }
47
-
48
- const config = await loadConfig({}, ['-c', configFile], platformaticRuntime)
49
- const runtime = await buildServer(config.configManager.current)
50
- const url = await runtime.start()
51
-
52
- t.after(async () => {
53
- await runtime.close()
54
- })
55
-
56
- return { runtime, url }
57
- }
58
-
59
- export async function getLogs (app) {
60
- const client = new Client(
61
- {
62
- hostname: 'localhost',
63
- protocol: 'http:'
64
- },
65
- {
66
- socketPath: app.getManagementApiUrl(),
67
- keepAliveTimeout: 10,
68
- keepAliveMaxTimeout: 10
69
- }
70
- )
71
-
72
- // Wait for logs to be written
73
- await sleep(3000)
74
-
75
- const { statusCode, body } = await client.request({
76
- method: 'GET',
77
- path: '/api/v1/logs/all'
78
- })
79
-
80
- strictEqual(statusCode, 200)
81
-
82
- const rawLogs = await body.text()
83
-
84
- return rawLogs
85
- .trim()
86
- .split('\n')
87
- .filter(l => l)
88
- .map(m => JSON.parse(m))
89
- }
90
-
91
- export async function updateHMRVersion (versionFile) {
92
- versionFile ??= resolve(fixturesDir, '../tmp/version.js')
93
- await createDirectory(dirname(versionFile))
94
- await writeFile(versionFile, `export const version = ${hrmVersion++}\n`, 'utf-8')
95
- }
96
-
97
- export async function verifyJSONViaHTTP (baseUrl, path, expectedCode, expectedContent) {
98
- const { statusCode, body } = await request(baseUrl + path)
99
- strictEqual(statusCode, expectedCode)
100
- deepStrictEqual(await body.json(), expectedContent)
101
- }
102
-
103
- export async function verifyJSONViaInject (app, serviceId, method, url, expectedCode, expectedContent) {
104
- const { statusCode, body } = await app.inject(serviceId, { method, url })
105
- strictEqual(statusCode, expectedCode)
106
- deepStrictEqual(JSON.parse(body), expectedContent)
107
- }
108
-
109
- export async function verifyHTMLViaHTTP (baseUrl, path, contents) {
110
- const { statusCode, headers, body } = await request(baseUrl + path, { maxRedirections: 1 })
111
- const html = await body.text()
112
-
113
- deepStrictEqual(statusCode, 200)
114
- ok(headers['content-type']?.startsWith('text/html'))
115
-
116
- for (const content of contents) {
117
- ok(content instanceof RegExp ? content.test(html) : html.includes(content), content)
118
- }
119
- }
120
-
121
- export async function verifyHTMLViaInject (app, serviceId, url, contents) {
122
- const { statusCode, headers, body: html } = await app.inject(serviceId, { method: 'GET', url })
123
-
124
- deepStrictEqual(statusCode, 200)
125
- ok(headers['content-type'].startsWith('text/html'))
126
-
127
- for (const content of contents) {
128
- ok(content instanceof RegExp ? content.test(html) : html.includes(content), content)
129
- }
130
- }
131
-
132
- export async function verifyHMR (baseUrl, path, protocol, handler) {
133
- const connection = withResolvers()
134
- const reload = withResolvers()
135
- const ac = new AbortController()
136
- const timeout = sleep(HMR_TIMEOUT, 'timeout', { signal: ac.signal })
137
-
138
- const url = baseUrl.replace('http:', 'ws:') + path
139
- const webSocket = new WebSocket(url, protocol)
140
-
141
- webSocket.on('error', err => {
142
- clearTimeout(timeout)
143
- connection.reject(err)
144
- reload.reject(err)
145
- })
146
-
147
- webSocket.on('message', data => {
148
- handler(JSON.parse(data), connection.resolve, reload.resolve)
149
- })
150
-
151
- try {
152
- if ((await Promise.race([connection.promise, timeout])) === 'timeout') {
153
- throw new Error('Timeout while waiting for HMR connection')
154
- }
155
-
156
- await sleep(1000)
157
- await updateHMRVersion()
158
-
159
- if ((await Promise.race([reload.promise, timeout])) === 'timeout') {
160
- throw new Error('Timeout while waiting for HMR reload')
161
- }
162
- } finally {
163
- webSocket.terminate()
164
- ac.abort()
165
- }
166
- }