@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 +1 -1
- package/index.js +18 -1
- package/lib/base.js +160 -1
- package/lib/errors.js +13 -0
- package/lib/schema.js +38 -30
- package/lib/utils.js +30 -1
- package/lib/worker/child-manager.js +169 -43
- package/lib/worker/child-process.js +117 -35
- package/lib/worker/child-transport.js +54 -0
- package/lib/worker/listeners.js +51 -0
- package/package.json +10 -7
- package/schema.json +1 -1
- package/lib/worker/server-listener.js +0 -29
- package/test/helper.js +0 -166
package/eslint.config.js
CHANGED
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/
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
#
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
this.emit('exit')
|
|
33
|
-
})
|
|
83
|
+
async listen () {
|
|
84
|
+
super.listen()
|
|
34
85
|
|
|
35
|
-
|
|
86
|
+
if (!isWindows) {
|
|
87
|
+
await createDirectory(dirname(this.#socketPath))
|
|
36
88
|
}
|
|
37
89
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
this.#
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
this.#
|
|
189
|
+
_setupListener (listener) {
|
|
190
|
+
this.#listener = listener
|
|
58
191
|
}
|
|
59
192
|
|
|
60
193
|
_createClosePromise () {
|
|
61
|
-
return once(this.#
|
|
194
|
+
return once(this.#server, 'exit')
|
|
62
195
|
}
|
|
63
196
|
|
|
64
197
|
_close () {
|
|
65
|
-
this.#
|
|
66
|
-
this.#child.kill('SIGKILL')
|
|
198
|
+
this.#server.close()
|
|
67
199
|
}
|
|
68
200
|
|
|
69
|
-
#
|
|
70
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
return promise
|
|
140
|
+
return once(this.#socket, 'close')
|
|
108
141
|
}
|
|
109
142
|
|
|
110
143
|
_close () {
|
|
111
|
-
|
|
112
|
-
this.#child.removeListener('message', this.#listener)
|
|
144
|
+
this.#socket.close()
|
|
113
145
|
}
|
|
114
146
|
|
|
115
147
|
#setupLogger () {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
23
|
-
"@platformatic/
|
|
24
|
-
"@platformatic/
|
|
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
|
-
"
|
|
39
|
-
"@platformatic/
|
|
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.
|
|
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
|
-
}
|