@platformatic/basic 3.4.1 → 3.5.0

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/config.js ADDED
@@ -0,0 +1,81 @@
1
+ import {
2
+ listRecognizedConfigurationFiles,
3
+ NoConfigFileFoundError,
4
+ findConfigurationFile as utilsFindConfigurationFile
5
+ } from '@platformatic/foundation'
6
+ import jsonPatch from 'fast-json-patch'
7
+ import { stat } from 'node:fs/promises'
8
+ import { dirname, resolve as resolvePath } from 'node:path'
9
+ import { workerData } from 'node:worker_threads'
10
+
11
+ export async function findConfigurationFile (root, suffixes) {
12
+ const file = await utilsFindConfigurationFile(root, suffixes)
13
+
14
+ if (!file) {
15
+ const err = new NoConfigFileFoundError()
16
+ err.message = `No config file found in the directory ${root}. Please create one of the following files: ${listRecognizedConfigurationFiles(suffixes, ['json']).join(', ')}`
17
+
18
+ throw err
19
+ }
20
+
21
+ return resolvePath(root, file)
22
+ }
23
+
24
+ export async function resolve (fileOrDirectory, sourceOrConfig, suffixes) {
25
+ if (sourceOrConfig && typeof sourceOrConfig !== 'string') {
26
+ return {
27
+ root: fileOrDirectory,
28
+ source: sourceOrConfig
29
+ }
30
+ } else if (typeof fileOrDirectory === 'string' && typeof sourceOrConfig === 'string') {
31
+ return {
32
+ root: fileOrDirectory,
33
+ source: resolvePath(fileOrDirectory, sourceOrConfig)
34
+ }
35
+ }
36
+
37
+ try {
38
+ const fileInfo = await stat(fileOrDirectory)
39
+
40
+ if (fileInfo.isFile()) {
41
+ return {
42
+ root: dirname(fileOrDirectory),
43
+ source: fileOrDirectory
44
+ }
45
+ }
46
+ } catch {
47
+ // No-op
48
+ }
49
+
50
+ return {
51
+ root: fileOrDirectory,
52
+ source: await findConfigurationFile(fileOrDirectory, suffixes)
53
+ }
54
+ }
55
+
56
+ export async function transform (config) {
57
+ const patch = workerData?.applicationConfig?.configPatch
58
+
59
+ if (!config) {
60
+ return config
61
+ }
62
+
63
+ if (Array.isArray(patch)) {
64
+ config = jsonPatch.applyPatch(config, patch).newDocument
65
+ }
66
+
67
+ if (config.watch === undefined) {
68
+ config.watch = { enabled: workerData?.config?.watch ?? false }
69
+ } else if (typeof config.watch !== 'object') {
70
+ config.watch = { enabled: config.watch || false }
71
+ }
72
+
73
+ return config
74
+ }
75
+
76
+ export const validationOptions = {
77
+ useDefaults: true,
78
+ coerceTypes: true,
79
+ allErrors: true,
80
+ strict: false
81
+ }
@@ -0,0 +1,9 @@
1
+ import { resolve } from './config.js'
2
+ import { importCapabilityAndConfig } from './modules.js'
3
+
4
+ export async function create (fileOrDirectory, sourceOrConfig, context) {
5
+ const { root, source } = await resolve(fileOrDirectory, sourceOrConfig)
6
+ const { capability } = await importCapabilityAndConfig(root, source, context)
7
+
8
+ return capability.create(root, source, context)
9
+ }
package/lib/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import createError from '@fastify/error'
2
2
 
3
- const ERROR_PREFIX = 'PLT_BASIC'
3
+ export const ERROR_PREFIX = 'PLT_BASIC'
4
4
 
5
5
  export const exitCodes = {
6
6
  MANAGER_MESSAGE_HANDLING_FAILED: 11,
package/lib/modules.js ADDED
@@ -0,0 +1,101 @@
1
+ import { detectApplicationType, findConfigurationFile } from '@platformatic/foundation'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import { relative, resolve } from 'node:path'
5
+ import { workerData } from 'node:worker_threads'
6
+ import pino from 'pino'
7
+ import { packageJson } from './schema.js'
8
+ import { importFile } from './utils.js'
9
+
10
+ const importCapabilityPackageMarker = '__pltImportCapabilityPackage.js'
11
+
12
+ export function isImportFailedError (error, pkg) {
13
+ if (error.code !== 'ERR_MODULE_NOT_FOUND' && error.code !== 'MODULE_NOT_FOUND') {
14
+ return false
15
+ }
16
+
17
+ const match = error.message.match(/Cannot find package '(.+)' imported from (.+)/)
18
+
19
+ return match?.[1] === pkg || error.requireStack?.[0].endsWith(importCapabilityPackageMarker)
20
+ }
21
+
22
+ export async function importCapabilityPackage (directory, pkg) {
23
+ let imported
24
+ try {
25
+ try {
26
+ // Try regular import
27
+ imported = await import(pkg)
28
+ } catch (e) {
29
+ if (!isImportFailedError(e, pkg)) {
30
+ throw e
31
+ }
32
+
33
+ // Scope to the application
34
+ const require = createRequire(resolve(directory, importCapabilityPackageMarker))
35
+ const toImport = require.resolve(pkg)
36
+ imported = await importFile(toImport)
37
+ }
38
+ } catch (e) {
39
+ if (!isImportFailedError(e, pkg)) {
40
+ throw e
41
+ }
42
+
43
+ const applicationDirectory = workerData ? relative(workerData.dirname, directory) : directory
44
+ throw new Error(
45
+ `Unable to import package '${pkg}'. Please add it as a dependency in the package.json file in the folder ${applicationDirectory}.`
46
+ )
47
+ }
48
+
49
+ return imported.default ?? imported
50
+ }
51
+
52
+ export async function importCapabilityAndConfig (root, config, context) {
53
+ let rootPackageJson
54
+ try {
55
+ rootPackageJson = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf-8'))
56
+ } catch {
57
+ rootPackageJson = {}
58
+ }
59
+
60
+ const hadConfig = !!config
61
+
62
+ if (!config) {
63
+ config = await findConfigurationFile(root, 'application')
64
+ }
65
+
66
+ const appType = await detectApplicationType(root, rootPackageJson)
67
+
68
+ if (!appType) {
69
+ throw new Error(`Unable to detect application type in ${root}.`)
70
+ }
71
+
72
+ const { label, name: moduleName } = appType
73
+
74
+ if (context) {
75
+ const applicationRoot = relative(process.cwd(), root)
76
+
77
+ if (!hadConfig && context.applicationId && !(await findConfigurationFile(root)) && context.worker?.index === 0) {
78
+ const autodetectDescription =
79
+ moduleName === '@platformatic/node' ? 'is a generic Node.js application' : `is using ${label}`
80
+
81
+ const logger = pino({ level: context.serverConfig?.logger?.level ?? 'warn', name: context.applicationId })
82
+
83
+ logger.warn(`We have auto-detected that application "${context.applicationId}" ${autodetectDescription}.`)
84
+ logger.warn(
85
+ `We suggest you create a watt.json or a platformatic.json file in the folder ${applicationRoot} with the "$schema" property set to "https://schemas.platformatic.dev/${moduleName}/${packageJson.version}.json".`
86
+ )
87
+ logger.warn(`Also don't forget to add "${moduleName}" to the application dependencies.`)
88
+ logger.warn('You can also run "wattpm import" to do this automatically.\n')
89
+ }
90
+ }
91
+
92
+ const capability = await importCapabilityPackage(root, moduleName)
93
+
94
+ return {
95
+ capability,
96
+ config,
97
+ autodetectDescription:
98
+ moduleName === '@platformatic/node' ? 'is a generic Node.js application' : `is using ${label}`,
99
+ moduleName
100
+ }
101
+ }
package/lib/schema.js CHANGED
@@ -1,11 +1,27 @@
1
- import { schemaComponents as utilsSchemaComponents } from '@platformatic/utils'
1
+ import { schemaComponents as utilsSchemaComponents } from '@platformatic/foundation'
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'))
5
+ export const version = packageJson.version
5
6
 
7
+ // This is used by applications to have common properties.
6
8
  const application = {
9
+ type: 'object',
10
+ properties: {},
11
+ additionalProperties: false,
12
+ required: [],
13
+ default: {}
14
+ }
15
+
16
+ /*
17
+ For legacy purposes, when an application is buildable (like Astro), it should use for the application properties.
18
+ Make sure this always extends the `application` schema above.
19
+ */
20
+
21
+ const buildableApplication = {
7
22
  type: 'object',
8
23
  properties: {
24
+ ...application.properties,
9
25
  basePath: {
10
26
  type: 'string'
11
27
  },
@@ -28,7 +44,7 @@ const application = {
28
44
  default: 'npm ci --omit-dev'
29
45
  },
30
46
  // All the following options purposely don't have a default so
31
- // that stackables can detect if the user explicitly set them.
47
+ // that capabilities can detect if the user explicitly set them.
32
48
  build: {
33
49
  type: 'string'
34
50
  },
@@ -44,7 +60,10 @@ const application = {
44
60
  }
45
61
  },
46
62
  additionalProperties: false,
47
- default: {}
63
+ required: [...application.required],
64
+ default: {
65
+ ...application.default
66
+ }
48
67
  }
49
68
 
50
69
  const watch = {
@@ -59,17 +78,18 @@ const watch = {
59
78
  ]
60
79
  }
61
80
 
62
- export const schemaComponents = { application, watch }
81
+ export const schemaComponents = { application, buildableApplication, watch }
63
82
 
64
83
  export const schema = {
65
84
  $id: `https://schemas.platformatic.dev/@platformatic/basic/${packageJson.version}.json`,
66
85
  $schema: 'http://json-schema.org/draft-07/schema#',
67
- title: 'Platformatic Stackable',
86
+ title: 'Platformatic Basic Config',
68
87
  type: 'object',
69
88
  properties: {
70
89
  $schema: {
71
90
  type: 'string'
72
- }
91
+ },
92
+ runtime: utilsSchemaComponents.wrappedRuntime
73
93
  },
74
94
  additionalProperties: true
75
95
  }
package/lib/utils.js CHANGED
@@ -62,8 +62,10 @@ export function importFile (path) {
62
62
  /* c8 ignore next 6 */
63
63
  export function resolvePackage (root, pkg) {
64
64
  const require = createRequire(root)
65
-
66
- return require.resolve(pkg, { paths: [root, ...require.main.paths] })
65
+ // We need to add the main module paths to the require.resolve call
66
+ // Note that `require.main` is not defined in `next` if we set sthe instrumentation hook reequired for ESM applications.
67
+ // see: https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md#instrumentation-hook-required-for-esm
68
+ return require.resolve(pkg, { paths: [root, ...(require.main?.paths || [])] })
67
69
  }
68
70
 
69
71
  export function cleanBasePath (basePath) {
@@ -1,16 +1,17 @@
1
- import { ITC, generateNotification } from '@platformatic/itc'
2
- import { createDirectory, ensureLoggableError } from '@platformatic/utils'
1
+ import { createDirectory, ensureLoggableError } from '@platformatic/foundation'
2
+ import { ITC } from '@platformatic/itc/lib/index.js'
3
3
  import { once } from 'node:events'
4
4
  import { rm, writeFile } from 'node:fs/promises'
5
5
  import { createServer } from 'node:http'
6
- import { register } from 'node:module'
6
+ import { createRequire, register } from 'node:module'
7
7
  import { platform, tmpdir } from 'node:os'
8
- import { dirname, resolve } from 'node:path'
9
- import { workerData } from 'node:worker_threads'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
10
10
  import { request } from 'undici'
11
11
  import { WebSocketServer } from 'ws'
12
12
  import { exitCodes } from '../errors.js'
13
13
  import { ensureFileUrl } from '../utils.js'
14
+
14
15
  export const isWindows = platform() === 'win32'
15
16
 
16
17
  // In theory we could use the context.id to namespace even more, but due to
@@ -41,9 +42,11 @@ export class ChildManager extends ITC {
41
42
  #scripts
42
43
  #logger
43
44
  #server
45
+ #websocketServer
44
46
  #socketPath
45
47
  #clients
46
48
  #requests
49
+ #currentSender
47
50
  #currentClient
48
51
  #listener
49
52
  #originalNodeOptions
@@ -56,19 +59,8 @@ export class ChildManager extends ITC {
56
59
  scripts ??= []
57
60
 
58
61
  super({
59
- name: 'child-manager',
60
62
  ...itcOpts,
61
- handlers: {
62
- log: message => {
63
- /* c8 ignore next */
64
- const logs = Array.isArray(message.logs) ? message.logs : [message.logs]
65
- this._forwardLogs(logs)
66
- },
67
- fetch: request => {
68
- return this.#fetch(request)
69
- },
70
- ...itcOpts.handlers
71
- }
63
+ name: 'child-manager'
72
64
  })
73
65
 
74
66
  this.#id = generateChildrenId(context)
@@ -77,7 +69,7 @@ export class ChildManager extends ITC {
77
69
  this.#scripts = scripts
78
70
  this.#originalNodeOptions = process.env.NODE_OPTIONS
79
71
  this.#logger = globalThis.platformatic.logger
80
- this.#server = createServer()
72
+ this.#server = createServer(this.#childProcessFetchHandler.bind(this))
81
73
  this.#socketPath ??= getSocketPath(this.#id)
82
74
  this.#clients = new Set()
83
75
  this.#requests = new Map()
@@ -90,18 +82,21 @@ export class ChildManager extends ITC {
90
82
  await createDirectory(dirname(this.#socketPath))
91
83
  }
92
84
 
93
- const wssServer = new WebSocketServer({ server: this.#server })
85
+ this.#websocketServer = new WebSocketServer({ server: this.#server })
94
86
 
95
- wssServer.on('connection', ws => {
87
+ this.#websocketServer.on('connection', ws => {
96
88
  this.#clients.add(ws)
97
89
 
98
90
  ws.on('message', raw => {
99
91
  try {
92
+ this.#currentSender = ws
100
93
  const message = JSON.parse(raw)
101
94
  this.#requests.set(message.reqId, ws)
102
95
  this.#listener(message)
103
96
  } catch (error) {
104
97
  this.#handleUnexpectedError(error, 'Handling a message failed.', exitCodes.MANAGER_MESSAGE_HANDLING_FAILED)
98
+ } finally {
99
+ this.#currentSender = null
105
100
  }
106
101
  })
107
102
 
@@ -124,24 +119,25 @@ export class ChildManager extends ITC {
124
119
  })
125
120
  }
126
121
 
127
- async close (signal) {
122
+ async close () {
128
123
  if (this.#dataPath) {
129
124
  await rm(this.#dataPath, { force: true })
130
125
  }
131
126
 
132
127
  for (const client of this.#clients) {
133
- this.#currentClient = client
134
- this._send(generateNotification('close', signal))
128
+ client.close()
129
+ await once(client, 'close')
135
130
  }
136
131
 
137
- this.#server?.close()
138
- super.close()
132
+ await this.#closeServer(this.#websocketServer)
133
+ await this.#closeServer(this.#server)
134
+ await super.close()
139
135
  }
140
136
 
141
137
  async inject () {
142
138
  await this.listen()
143
139
 
144
- // Serialize data into a JSON file for the stackable to use
140
+ // Serialize data into a JSON file for the capability to use
145
141
  this.#dataPath = resolve(tmpdir(), 'platformatic', 'runtimes', `${this.#id}.json`)
146
142
  await createDirectory(dirname(this.#dataPath))
147
143
 
@@ -161,8 +157,19 @@ export class ChildManager extends ITC {
161
157
  )
162
158
 
163
159
  process.env.PLT_MANAGER_ID = this.#id
164
- process.env.NODE_OPTIONS =
165
- `--import="${new URL('./child-process.js', import.meta.url)}" ${process.env.NODE_OPTIONS ?? ''}`.trim()
160
+
161
+ const nodeOptions = process.env.NODE_OPTIONS ?? ''
162
+ const childProcessInclude = `--import="${new URL('./child-process.js', import.meta.url)}"`
163
+
164
+ let telemetryInclude = ''
165
+ if (this.#context.telemetryConfig && this.#context.telemetryConfig.enabled !== false) {
166
+ const require = createRequire(import.meta.url)
167
+ const telemetryPath = require.resolve('@platformatic/telemetry')
168
+ const openTelemetrySetupPath = join(telemetryPath, '..', 'lib', 'node-telemetry.js')
169
+ telemetryInclude = `--import="${pathToFileURL(openTelemetrySetupPath)}"`
170
+ }
171
+
172
+ process.env.NODE_OPTIONS = `${telemetryInclude} ${childProcessInclude} ${nodeOptions}`.trim()
166
173
  }
167
174
 
168
175
  async eject () {
@@ -179,14 +186,24 @@ export class ChildManager extends ITC {
179
186
  }
180
187
 
181
188
  register () {
189
+ Object.assign(globalThis.platformatic, this.#context)
182
190
  register(this.#loader, { data: this.#context })
183
191
  }
184
192
 
193
+ emit (...args) {
194
+ super.emit(...args, this.#currentSender)
195
+ }
196
+
185
197
  send (client, name, message) {
186
198
  this.#currentClient = client
187
199
  return super.send(name, message)
188
200
  }
189
201
 
202
+ notify (client, name, message) {
203
+ this.#currentClient = client
204
+ return super.notify(name, message)
205
+ }
206
+
190
207
  _send (message, stringify = true) {
191
208
  if (!this.#currentClient) {
192
209
  this.#currentClient = this.#requests.get(message.reqId)
@@ -213,22 +230,50 @@ export class ChildManager extends ITC {
213
230
  this.#server.close()
214
231
  }
215
232
 
216
- /* c8 ignore next 3 */
217
- _forwardLogs (logs) {
218
- workerData.loggingPort.postMessage({ logs: logs.map(m => JSON.stringify(m)) })
219
- }
233
+ async #childProcessFetchHandler (req, res) {
234
+ const { url, headers } = req
220
235
 
221
- async #fetch (opts) {
222
- const { statusCode, headers, body } = await request(opts)
236
+ const requestOptions = { method: req.method, headers }
223
237
 
224
- const rawPayload = Buffer.from(await body.arrayBuffer())
225
- const payload = rawPayload.toString()
238
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
239
+ requestOptions.body = req
240
+ }
226
241
 
227
- return { statusCode, headers, body: payload, payload, rawPayload }
242
+ try {
243
+ const {
244
+ statusCode,
245
+ headers: responseHeaders,
246
+ body
247
+ } = await request(`http://${headers.host}${url ?? '/'}`, requestOptions)
248
+
249
+ res.writeHead(statusCode, responseHeaders)
250
+ body.pipe(res)
251
+ } catch (error) {
252
+ res.writeHead(502, { 'content-type': 'application/json' })
253
+ res.end(JSON.stringify(ensureLoggableError(error)))
254
+ }
228
255
  }
229
256
 
230
257
  #handleUnexpectedError (error, message, exitCode) {
231
258
  this.#logger.error({ err: ensureLoggableError(error) }, message)
232
259
  process.exit(exitCode)
233
260
  }
261
+
262
+ #closeServer (server) {
263
+ return new Promise((resolve, reject) => {
264
+ if (!server || server.listening === false) {
265
+ resolve()
266
+ return
267
+ }
268
+
269
+ server.close(err => {
270
+ if (err) {
271
+ reject(err)
272
+ return
273
+ }
274
+
275
+ resolve()
276
+ })
277
+ })
278
+ }
234
279
  }