@platformatic/basic 2.0.0-alpha.10

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/lib/schema.js ADDED
@@ -0,0 +1,80 @@
1
+ import { schemaComponents as utilsSchemaComponents } from '@platformatic/utils'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ export const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
5
+
6
+ const application = {
7
+ type: 'object',
8
+ properties: {
9
+ basePath: {
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
44
+ }
45
+ },
46
+ additionalProperties: false,
47
+ default: {}
48
+ }
49
+
50
+ const watch = {
51
+ anyOf: [
52
+ utilsSchemaComponents.watch,
53
+ {
54
+ type: 'boolean'
55
+ },
56
+ {
57
+ type: 'string'
58
+ }
59
+ ]
60
+ }
61
+
62
+ export const schemaComponents = { application, watch }
63
+
64
+ export const schema = {
65
+ $id: `https://schemas.platformatic.dev/@platformatic/basic/${packageJson.version}.json`,
66
+ $schema: 'http://json-schema.org/draft-07/schema#',
67
+ title: 'Platformatic Stackable',
68
+ type: 'object',
69
+ properties: {
70
+ $schema: {
71
+ type: 'string'
72
+ }
73
+ },
74
+ additionalProperties: true
75
+ }
76
+
77
+ /* c8 ignore next 3 */
78
+ if (process.argv[1] === import.meta.filename) {
79
+ console.log(JSON.stringify(schema, null, 2))
80
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,73 @@
1
+ import { createRequire } from 'node:module'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { request } from 'undici'
4
+
5
+ export function getServerUrl (server) {
6
+ const { family, address, port } = server.address()
7
+
8
+ return new URL(family === 'IPv6' ? `http://[${address}]:${port}` : `http://${address}:${port}`).origin
9
+ }
10
+
11
+ export async function injectViaRequest (baseUrl, injectParams, onInject) {
12
+ const url = new URL(injectParams.url, baseUrl).href
13
+ const requestParams = { method: injectParams.method, headers: injectParams.headers }
14
+
15
+ if (injectParams.body) {
16
+ const body = injectParams.body
17
+ requestParams.body = typeof body === 'object' ? JSON.stringify(body) : body
18
+ }
19
+
20
+ try {
21
+ const { statusCode, headers, body } = await request(url, requestParams)
22
+
23
+ const rawPayload = Buffer.from(await body.arrayBuffer())
24
+ const payload = rawPayload.toString()
25
+ const response = { statusCode, headers, body: payload, payload, rawPayload }
26
+
27
+ if (onInject) {
28
+ return onInject(null, response)
29
+ }
30
+
31
+ return response
32
+ } catch (error) {
33
+ if (onInject) {
34
+ onInject(error)
35
+ return
36
+ }
37
+
38
+ throw error
39
+ }
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
+
56
+ // This is to avoid common path/URL problems on Windows
57
+ export function importFile (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('/') ? '' : '/'}` : '/'
73
+ }
@@ -0,0 +1,219 @@
1
+ import { ITC, generateNotification } from '@platformatic/itc'
2
+ import { createDirectory, errors } from '@platformatic/utils'
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'
9
+ import { workerData } from 'node:worker_threads'
10
+ import { request } from 'undici'
11
+ import { WebSocketServer } from 'ws'
12
+ import { exitCodes } from '../errors.js'
13
+ import { ensureFileUrl } from '../utils.js'
14
+
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
+ }
35
+
36
+ export class ChildManager extends ITC {
37
+ #id
38
+ #loader
39
+ #context
40
+ #scripts
41
+ #logger
42
+ #server
43
+ #socketPath
44
+ #clients
45
+ #requests
46
+ #currentClient
47
+ #listener
48
+ #originalNodeOptions
49
+ #dataPath
50
+
51
+ constructor (opts) {
52
+ let { loader, context, scripts, handlers, ...itcOpts } = opts
53
+
54
+ context ??= {}
55
+ scripts ??= []
56
+
57
+ super({
58
+ name: 'child-manager',
59
+ ...itcOpts,
60
+ handlers: {
61
+ log: message => {
62
+ return this.#log(message)
63
+ },
64
+ fetch: request => {
65
+ return this.#fetch(request)
66
+ },
67
+ ...itcOpts.handlers
68
+ }
69
+ })
70
+
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
+ }
82
+
83
+ async listen () {
84
+ super.listen()
85
+
86
+ if (!isWindows) {
87
+ await createDirectory(dirname(this.#socketPath))
88
+ }
89
+
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
+ })
104
+
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
+ })
121
+ }
122
+
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()
159
+ }
160
+
161
+ async eject () {
162
+ process.env.NODE_OPTIONS = this.#originalNodeOptions
163
+ process.env.PLT_MANAGER_ID = ''
164
+ }
165
+
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
187
+ }
188
+
189
+ _setupListener (listener) {
190
+ this.#listener = listener
191
+ }
192
+
193
+ _createClosePromise () {
194
+ return once(this.#server, 'exit')
195
+ }
196
+
197
+ _close () {
198
+ this.#server.close()
199
+ }
200
+
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)) })
204
+ }
205
+
206
+ async #fetch (opts) {
207
+ const { statusCode, headers, body } = await request(opts)
208
+
209
+ const rawPayload = Buffer.from(await body.arrayBuffer())
210
+ const payload = rawPayload.toString()
211
+
212
+ return { statusCode, headers, body: payload, payload, rawPayload }
213
+ }
214
+
215
+ #handleUnexpectedError (error, message, exitCode) {
216
+ this.#logger.error({ err: errors.ensureLoggableError(error) }, message)
217
+ process.exit(exitCode)
218
+ }
219
+ }
@@ -0,0 +1,232 @@
1
+ import { ITC } from '@platformatic/itc'
2
+ import { createPinoWritable, errors } from '@platformatic/utils'
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'
10
+ import pino from 'pino'
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'
16
+
17
+ function createInterceptor (itc) {
18
+ return function (dispatch) {
19
+ return async (opts, handler) => {
20
+ let url = opts.origin
21
+ if (!(url instanceof URL)) {
22
+ url = new URL(opts.path, url)
23
+ }
24
+
25
+ // Other URLs are handled normally
26
+ if (!url.hostname.endsWith('.plt.local')) {
27
+ return dispatch(opts, handler)
28
+ }
29
+
30
+ const headers = {
31
+ ...opts?.headers
32
+ }
33
+
34
+ delete headers.connection
35
+ delete headers['transfer-encoding']
36
+ headers.host = url.host
37
+
38
+ const requestOpts = {
39
+ ...opts,
40
+ headers
41
+ }
42
+ delete requestOpts.dispatcher
43
+
44
+ itc
45
+ .send('fetch', requestOpts)
46
+ .then(res => {
47
+ if (res.rawPayload && !Buffer.isBuffer(res.rawPayload)) {
48
+ res.rawPayload = Buffer.from(res.rawPayload.data)
49
+ }
50
+
51
+ const headers = []
52
+ for (const [key, value] of Object.entries(res.headers)) {
53
+ if (Array.isArray(value)) {
54
+ for (const v of value) {
55
+ headers.push(key)
56
+ headers.push(v)
57
+ }
58
+ } else {
59
+ headers.push(key)
60
+ headers.push(value)
61
+ }
62
+ }
63
+
64
+ handler.onHeaders(res.statusCode, headers, () => {}, res.statusMessage)
65
+ handler.onData(res.rawPayload)
66
+ handler.onComplete([])
67
+ })
68
+ .catch(e => {
69
+ handler.onError(new Error(e.message))
70
+ })
71
+
72
+ return true
73
+ }
74
+ }
75
+ }
76
+
77
+ export class ChildProcess extends ITC {
78
+ #listener
79
+ #socket
80
+ #child
81
+ #logger
82
+ #pendingMessages
83
+
84
+ constructor () {
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 = []
90
+
91
+ this.listen()
92
+ this.#setupLogger()
93
+ this.#setupHandlers()
94
+ this.#setupServer()
95
+ this.#setupInterceptors()
96
+
97
+ this.on('close', signal => {
98
+ process.kill(process.pid, signal)
99
+ })
100
+ }
101
+
102
+ _setupListener (listener) {
103
+ this.#listener = 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
+ })
128
+ }
129
+
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))
137
+ }
138
+
139
+ _createClosePromise () {
140
+ return once(this.#socket, 'close')
141
+ }
142
+
143
+ _close () {
144
+ this.#socket.close()
145
+ }
146
+
147
+ #setupLogger () {
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
+ }
165
+ }
166
+
167
+ #setupServer () {
168
+ const subscribers = {
169
+ asyncStart ({ options }) {
170
+ const port = globalThis.platformatic.port
171
+
172
+ if (port !== false) {
173
+ options.port = typeof port === 'number' ? port : 0
174
+ }
175
+ },
176
+ asyncEnd: ({ server }) => {
177
+ tracingChannel('net.server.listen').unsubscribe(subscribers)
178
+
179
+ const { family, address, port } = server.address()
180
+ const url = new URL(family === 'IPv6' ? `http://[${address}]:${port}` : `http://${address}:${port}`).origin
181
+
182
+ this.notify('url', url)
183
+ },
184
+ error: error => {
185
+ tracingChannel('net.server.listen').unsubscribe(subscribers)
186
+ this.notify('error', error)
187
+ }
188
+ }
189
+
190
+ tracingChannel('net.server.listen').subscribe(subscribers)
191
+ }
192
+
193
+ #setupInterceptors () {
194
+ setGlobalDispatcher(getGlobalDispatcher().compose(createInterceptor(this)))
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
+ }
211
+ }
212
+
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
+ }