@platformatic/node 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.
@@ -0,0 +1,407 @@
1
+ import {
2
+ BaseCapability,
3
+ cleanBasePath,
4
+ createServerListener,
5
+ ensureTrailingSlash,
6
+ getServerUrl,
7
+ importFile,
8
+ injectViaRequest
9
+ } from '@platformatic/basic'
10
+ import { features } from '@platformatic/foundation'
11
+ import inject from 'light-my-request'
12
+ import { existsSync } from 'node:fs'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { Server } from 'node:http'
15
+ import { resolve as resolvePath } from 'node:path'
16
+ import { version } from './schema.js'
17
+ import { getTsconfig, ignoreDirs, isApplicationBuildable } from './utils.js'
18
+
19
+ const validFields = [
20
+ 'main',
21
+ 'exports',
22
+ 'exports',
23
+ 'exports#node',
24
+ 'exports#import',
25
+ 'exports#require',
26
+ 'exports#default',
27
+ 'exports#.#node',
28
+ 'exports#.#import',
29
+ 'exports#.#require',
30
+ 'exports#.#default'
31
+ ]
32
+
33
+ const validFilesBasenames = ['index', 'main', 'app', 'application', 'server', 'start', 'bundle', 'run', 'entrypoint']
34
+
35
+ // Paolo: This is kinda hackish but there is no better way. I apologize.
36
+ function isFastify (app) {
37
+ return Object.getOwnPropertySymbols(app).some(s => s.description === 'fastify.state')
38
+ }
39
+
40
+ function isKoa (app) {
41
+ return typeof app.callback === 'function'
42
+ }
43
+
44
+ async function getEntrypointInformation (root) {
45
+ let entrypoint
46
+ let packageJson
47
+ let hadEntrypointField = false
48
+
49
+ try {
50
+ packageJson = JSON.parse(await readFile(resolvePath(root, 'package.json'), 'utf-8'))
51
+ } catch {
52
+ // No package.json, we only load the index.js file
53
+ packageJson = {}
54
+ }
55
+
56
+ for (const field of validFields) {
57
+ let current = packageJson
58
+ const sequence = field.split('#')
59
+
60
+ while (current && sequence.length && typeof current !== 'string') {
61
+ current = current[sequence.shift()]
62
+ }
63
+
64
+ if (typeof current === 'string') {
65
+ entrypoint = current
66
+ hadEntrypointField = true
67
+ break
68
+ }
69
+ }
70
+
71
+ if (!entrypoint) {
72
+ for (const basename of validFilesBasenames) {
73
+ for (const ext of ['js', 'mjs', 'cjs']) {
74
+ const file = `${basename}.${ext}`
75
+
76
+ if (existsSync(resolvePath(root, file))) {
77
+ entrypoint = file
78
+ break
79
+ }
80
+ }
81
+
82
+ if (entrypoint) {
83
+ break
84
+ }
85
+ }
86
+ }
87
+
88
+ return { entrypoint, hadEntrypointField }
89
+ }
90
+
91
+ export class NodeCapability extends BaseCapability {
92
+ #module
93
+ #app
94
+ #server
95
+ #basePath
96
+ #dispatcher
97
+ #isFastify
98
+ #isKoa
99
+ #useHttpForDispatch
100
+
101
+ constructor (root, config, context) {
102
+ super('nodejs', version, root, config, context)
103
+ }
104
+
105
+ async start ({ listen }) {
106
+ // Make this idempotent
107
+ if (this.url) {
108
+ return this.url
109
+ }
110
+
111
+ // Listen if entrypoint
112
+ if (this.#app && listen) {
113
+ await this._listen()
114
+ return this.url
115
+ }
116
+
117
+ const config = this.config
118
+
119
+ if (!this.isProduction && (await isApplicationBuildable(this.root, config))) {
120
+ this.logger.info(`Building application "${this.applicationId}" before starting in development mode ...`)
121
+ try {
122
+ await this.build()
123
+ this.childManager = null
124
+ } catch (e) {
125
+ this.logger.error(`Error while building application "${this.applicationId}": ${e.message}`)
126
+ }
127
+ }
128
+
129
+ const command = config.application.commands[this.isProduction ? 'production' : 'development']
130
+
131
+ if (command) {
132
+ return this.startWithCommand(command)
133
+ }
134
+
135
+ // Resolve the entrypoint
136
+ // The priority is platformatic.application.json, then package.json and finally autodetect.
137
+ // Only when autodetecting we eventually search in the dist folder when in production mode
138
+ const finalEntrypoint = await this._findEntrypoint()
139
+
140
+ // Require the application
141
+ this.#basePath = config.application?.basePath
142
+ ? ensureTrailingSlash(cleanBasePath(config.application?.basePath))
143
+ : undefined
144
+
145
+ this.registerGlobals({
146
+ basePath: this.#basePath
147
+ })
148
+
149
+ // The server promise must be created before requiring the entrypoint even if it's not going to be used
150
+ // at all. Otherwise there is chance we miss the listen event.
151
+ const serverOptions = this.serverConfig
152
+ const serverPromise = createServerListener(
153
+ (this.isEntrypoint ? serverOptions?.port : undefined) ?? true,
154
+ (this.isEntrypoint ? serverOptions?.hostname : undefined) ?? true
155
+ )
156
+ this.#module = await importFile(finalEntrypoint)
157
+ this.#module = this.#module.default || this.#module
158
+
159
+ // Deal with application
160
+ const factory = ['build', 'create'].find(f => typeof this.#module[f] === 'function')
161
+
162
+ if (config.node?.hasServer !== false && this.#module.hasServer !== false) {
163
+ if (factory) {
164
+ // We have build function, this Capability will not use HTTP unless it is the entrypoint
165
+ serverPromise.cancel()
166
+
167
+ this.#app = await this.#module[factory]()
168
+ this.#isFastify = isFastify(this.#app)
169
+ this.#isKoa = isKoa(this.#app)
170
+
171
+ if (this.#isFastify) {
172
+ await this.#app.ready()
173
+ } else if (this.#isKoa) {
174
+ this.#dispatcher = this.#app.callback()
175
+ } else if (this.#app instanceof Server) {
176
+ this.#server = this.#app
177
+ this.#dispatcher = this.#server.listeners('request')[0]
178
+ }
179
+
180
+ if (listen) {
181
+ await this._listen()
182
+ }
183
+ } else {
184
+ // User blackbox function, we wait for it to listen on a port
185
+ this.#server = await serverPromise
186
+ this.#dispatcher = this.#server.listeners('request')[0]
187
+
188
+ this.url = getServerUrl(this.#server)
189
+ }
190
+ }
191
+
192
+ await this._collectMetrics()
193
+ return this.url
194
+ }
195
+
196
+ async stop () {
197
+ await super.stop()
198
+
199
+ if (this.childManager) {
200
+ return this.stopCommand()
201
+ // This is needed if the capability was subclassed
202
+ } else if (!this.#server) {
203
+ return
204
+ }
205
+
206
+ if (this.#isFastify && this.#app) {
207
+ return this.#app.close()
208
+ }
209
+
210
+ /* c8 ignore next 3 */
211
+ if (!this.#server?.listening) {
212
+ return
213
+ }
214
+
215
+ return new Promise((resolve, reject) => {
216
+ this.#server.close(error => {
217
+ /* c8 ignore next 3 */
218
+ if (error) {
219
+ return reject(error)
220
+ }
221
+
222
+ resolve()
223
+ })
224
+ })
225
+ }
226
+
227
+ async build () {
228
+ const config = this.config
229
+ const disableChildManager = config.node?.disablePlatformaticInBuild
230
+ const command = config.application?.commands?.build
231
+
232
+ if (command) {
233
+ return this.buildWithCommand(command, null, { disableChildManager })
234
+ }
235
+
236
+ // If no command was specified, we try to see if there is a build script defined in package.json.
237
+ const hasBuildScript = await this.#hasBuildScript()
238
+
239
+ if (!hasBuildScript) {
240
+ this.logger.debug(
241
+ 'No "application.commands.build" configuration value specified and no build script found in package.json. Skipping build ...'
242
+ )
243
+ return
244
+ }
245
+
246
+ return this.buildWithCommand('npm run build', null, { disableChildManager })
247
+ }
248
+
249
+ async inject (injectParams, onInject) {
250
+ let res
251
+
252
+ if (this.#useHttpForDispatch) {
253
+ this.logger.trace({ injectParams, url: this.url }, 'injecting via request')
254
+ res = await injectViaRequest(this.url, injectParams, onInject)
255
+ } else {
256
+ if (this.#isFastify) {
257
+ this.logger.trace({ injectParams }, 'injecting via fastify')
258
+ res = await this.#app.inject(injectParams, onInject)
259
+ } else {
260
+ this.logger.trace({ injectParams }, 'injecting via light-my-request')
261
+ res = await inject(this.#dispatcher ?? this.#app, injectParams, onInject)
262
+ }
263
+ }
264
+
265
+ /* c8 ignore next 3 */
266
+ if (onInject) {
267
+ return
268
+ }
269
+
270
+ // Since inject might be called from the main thread directly via ITC, let's clean it up
271
+ const { statusCode, headers, body, payload, rawPayload } = res
272
+
273
+ return { statusCode, headers, body, payload, rawPayload }
274
+ }
275
+
276
+ _getWantsAbsoluteUrls () {
277
+ const config = this.config
278
+ return config.node.absoluteUrl
279
+ }
280
+
281
+ getMeta () {
282
+ return {
283
+ gateway: {
284
+ tcp: typeof this.url !== 'undefined',
285
+ url: this.url,
286
+ prefix: this.basePath ?? this.#basePath,
287
+ wantsAbsoluteUrls: this._getWantsAbsoluteUrls(),
288
+ needsRootTrailingSlash: true
289
+ },
290
+ connectionStrings: this.connectionString ? [this.connectionString] : []
291
+ }
292
+ }
293
+
294
+ async getDispatchTarget () {
295
+ this.#useHttpForDispatch = this.childManager || (this.url && this.config.node?.dispatchViaHttp === true)
296
+
297
+ if (this.#useHttpForDispatch) {
298
+ return this.getUrl()
299
+ }
300
+
301
+ return this.getDispatchFunc()
302
+ }
303
+
304
+ async _listen () {
305
+ // Make this idempotent
306
+ /* c8 ignore next 3 */
307
+ if (this.url) {
308
+ return this.url
309
+ }
310
+
311
+ const serverOptions = this.serverConfig
312
+ const listenOptions = { host: serverOptions?.hostname || '127.0.0.1', port: serverOptions?.port || 0 }
313
+
314
+ if (this.isProduction && features.node.reusePort) {
315
+ listenOptions.reusePort = true
316
+ }
317
+
318
+ if (this.#isFastify) {
319
+ await this.#app.listen(listenOptions)
320
+ this.url = getServerUrl(this.#app.server)
321
+ } else {
322
+ // Express / Node / Koa
323
+ this.#server = await new Promise((resolve, reject) => {
324
+ return this.#app
325
+ .listen(listenOptions, function () {
326
+ resolve(this)
327
+ })
328
+ .on('error', reject)
329
+ })
330
+
331
+ this.url = getServerUrl(this.#server)
332
+ }
333
+
334
+ return this.url
335
+ }
336
+
337
+ _getApplication () {
338
+ return this.#app
339
+ }
340
+
341
+ async _findEntrypoint () {
342
+ const config = this.config
343
+
344
+ if (config.node.main) {
345
+ return resolvePath(this.root, config.node.main)
346
+ }
347
+
348
+ const { entrypoint, hadEntrypointField } = await getEntrypointInformation(this.root)
349
+
350
+ if (typeof this.workerId === 'undefined' || this.workerId === 0) {
351
+ if (!entrypoint) {
352
+ this.logger.error(
353
+ `The application "${this.applicationId}" had no valid entrypoint defined in the package.json file and no valid entrypoint file was found.`
354
+ )
355
+
356
+ process.exit(1)
357
+ }
358
+
359
+ if (!hadEntrypointField) {
360
+ this.logger.warn(
361
+ `The application "${this.applicationId}" had no valid entrypoint defined in the package.json file. Falling back to the file "${entrypoint}".`
362
+ )
363
+ }
364
+ }
365
+
366
+ return resolvePath(this.root, entrypoint)
367
+ }
368
+
369
+ async #hasBuildScript () {
370
+ // If no command was specified, we try to see if there is a build script defined in package.json.
371
+ let hasBuildScript
372
+ try {
373
+ const packageJson = JSON.parse(await readFile(resolvePath(this.root, 'package.json'), 'utf-8'))
374
+ hasBuildScript = typeof packageJson.scripts.build === 'string' && packageJson.scripts.build
375
+ } catch (e) {
376
+ // No-op
377
+ }
378
+
379
+ return hasBuildScript
380
+ }
381
+
382
+ async getWatchConfig () {
383
+ const config = this.config
384
+
385
+ const enabled = config.watch?.enabled !== false
386
+
387
+ if (!enabled) {
388
+ return { enabled, path: this.root }
389
+ }
390
+
391
+ // ignore the outDir from tsconfig or application config if any
392
+ let ignore = config.watch?.ignore
393
+ if (!ignore) {
394
+ const tsConfig = await getTsconfig(this.root, config)
395
+ if (tsConfig) {
396
+ ignore = ignoreDirs(tsConfig?.compilerOptions?.outDir, tsConfig?.watchOptions?.excludeDirectories)
397
+ }
398
+ }
399
+
400
+ return {
401
+ enabled,
402
+ path: this.root,
403
+ allow: config.watch?.allow,
404
+ ignore
405
+ }
406
+ }
407
+ }
@@ -0,0 +1,134 @@
1
+ 'use strict'
2
+
3
+ import { BaseGenerator } from '@platformatic/generators'
4
+ import { basename, dirname, sep } from 'node:path'
5
+
6
+ const indexFileJS = `
7
+ import { createServer } from 'node:http'
8
+
9
+ export function create() {
10
+ return createServer((_, res) => {
11
+ globalThis.platformatic.logger.debug('Serving request.')
12
+ res.writeHead(200, { 'content-type': 'application/json', connection: 'close' })
13
+ res.end(JSON.stringify({ hello: 'world' }))
14
+ })
15
+ }
16
+ `
17
+
18
+ const indexFileTS = `
19
+ import { getGlobal } from '@platformatic/globals'
20
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
21
+
22
+ export function create() {
23
+ const platformatic = getGlobal()
24
+
25
+ return createServer((_: IncomingMessage, res: ServerResponse) => {
26
+ platformatic.logger.debug('Serving request.')
27
+ res.writeHead(200, { 'content-type': 'application/json', connection: 'close' })
28
+ res.end(JSON.stringify({ hello: 'world' }))
29
+ })
30
+ }
31
+ `
32
+
33
+ export class Generator extends BaseGenerator {
34
+ constructor (opts = {}) {
35
+ super({
36
+ ...opts,
37
+ module: '@platformatic/node'
38
+ })
39
+ }
40
+
41
+ async prepareQuestions () {
42
+ await super.prepareQuestions()
43
+
44
+ if (!this.config.skipTypescript) {
45
+ this.questions.push({
46
+ type: 'list',
47
+ name: 'typescript',
48
+ message: 'Do you want to use TypeScript?',
49
+ default: false,
50
+ choices: [
51
+ { name: 'yes', value: true },
52
+ { name: 'no', value: false }
53
+ ]
54
+ })
55
+ }
56
+ }
57
+
58
+ async prepare () {
59
+ await this.getPlatformaticVersion()
60
+
61
+ if (this.config.isUpdating) {
62
+ return
63
+ }
64
+
65
+ const main = this.config.main || (this.config.typescript ? 'index.ts' : 'index.js')
66
+ let indexPath = ''
67
+ let indexName = main
68
+
69
+ if (main.indexOf(sep) !== -1) {
70
+ indexPath = dirname(main)
71
+ indexName = basename(main)
72
+ }
73
+
74
+ let indexTemplate = indexFileJS
75
+ const dependencies = {
76
+ '@platformatic/node': `^${this.platformaticVersion}`
77
+ }
78
+
79
+ const devDependencies = {}
80
+
81
+ if (this.config.typescript) {
82
+ indexTemplate = indexFileTS
83
+
84
+ dependencies['@platformatic/globals'] = `^${this.platformaticVersion}`
85
+ devDependencies['@platformatic/tsconfig'] = '^0.1.0'
86
+ devDependencies['@types/node'] = '^22.0.0'
87
+ }
88
+
89
+ this.addFile({ path: indexPath, file: indexName, contents: indexTemplate.trim() + '\n' })
90
+
91
+ this.addFile({
92
+ path: '',
93
+ file: 'package.json',
94
+ contents: JSON.stringify(
95
+ {
96
+ name: `${this.config.applicationName}`,
97
+ main,
98
+ type: 'module',
99
+ dependencies,
100
+ devDependencies
101
+ },
102
+ null,
103
+ 2
104
+ )
105
+ })
106
+
107
+ if (this.config.typescript) {
108
+ this.addFile({
109
+ path: '',
110
+ file: 'tsconfig.json',
111
+ contents: JSON.stringify({ extends: '@platformatic/tsconfig' }, null, 2)
112
+ })
113
+ }
114
+
115
+ this.addFile({
116
+ path: '',
117
+ file: 'watt.json',
118
+ contents: JSON.stringify(
119
+ {
120
+ $schema: `https://schemas.platformatic.dev/@platformatic/node/${this.platformaticVersion}.json`
121
+ },
122
+ null,
123
+ 2
124
+ )
125
+ })
126
+
127
+ return {
128
+ targetDirectory: this.targetDirectory,
129
+ env: this.config.env
130
+ }
131
+ }
132
+
133
+ async _getConfigFileContents () {}
134
+ }
package/lib/schema.js CHANGED
@@ -1,19 +1,33 @@
1
1
  import { schemaComponents as basicSchemaComponents } from '@platformatic/basic'
2
- import { schemaComponents as utilsSchemaComponents } from '@platformatic/utils'
2
+ import { schemaComponents as utilsSchemaComponents } from '@platformatic/foundation'
3
3
  import { readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
4
5
 
5
- export const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
6
+ export const packageJson = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8'))
7
+ export const version = packageJson.version
6
8
 
7
9
  const node = {
8
10
  type: 'object',
9
11
  properties: {
10
12
  main: {
11
- type: 'string',
13
+ type: 'string'
12
14
  },
13
15
  absoluteUrl: {
14
- description: 'This Node.js application requires the Absolute URL from the Composer',
16
+ description: 'This Node.js application requires the Absolute URL from the Gateway',
17
+ type: 'boolean',
18
+ default: false
19
+ },
20
+ dispatchViaHttp: {
21
+ type: 'boolean',
22
+ default: false
23
+ },
24
+ disablePlatformaticInBuild: {
25
+ type: 'boolean',
26
+ default: false
27
+ },
28
+ hasServer: {
15
29
  type: 'boolean',
16
- default: false,
30
+ default: true
17
31
  }
18
32
  },
19
33
  default: {},
@@ -23,9 +37,9 @@ const node = {
23
37
  export const schemaComponents = { node }
24
38
 
25
39
  export const schema = {
26
- $id: `https://schemas.platformatic.dev/@platformatic/node/${packageJson.version}.json`,
40
+ $id: `https://schemas.platformatic.dev/@platformatic/node/${version}.json`,
27
41
  $schema: 'http://json-schema.org/draft-07/schema#',
28
- title: 'Platformatic Node.js Stackable',
42
+ title: 'Platformatic Node.js Config',
29
43
  type: 'object',
30
44
  properties: {
31
45
  $schema: {
@@ -34,7 +48,8 @@ export const schema = {
34
48
  logger: utilsSchemaComponents.logger,
35
49
  server: utilsSchemaComponents.server,
36
50
  watch: basicSchemaComponents.watch,
37
- application: basicSchemaComponents.application,
51
+ application: basicSchemaComponents.buildableApplication,
52
+ runtime: utilsSchemaComponents.wrappedRuntime,
38
53
  node
39
54
  },
40
55
  additionalProperties: false
package/lib/utils.js ADDED
@@ -0,0 +1,69 @@
1
+ import json5 from 'json5'
2
+ import { readFile } from 'node:fs/promises'
3
+ import path, { join } from 'node:path'
4
+
5
+ export async function isApplicationBuildable (applicationRoot, config) {
6
+ // skip vite as capability as it has its own build command
7
+ if (config?.vite) {
8
+ return false
9
+ }
10
+
11
+ if (config?.application?.commands?.build) {
12
+ return true
13
+ }
14
+
15
+ // Check if package.json exists and has a build command
16
+ const packageJsonPath = join(applicationRoot, 'package.json')
17
+
18
+ try {
19
+ // File exists, try to read and parse it
20
+ try {
21
+ const content = await readFile(packageJsonPath, 'utf8')
22
+ const packageJson = JSON.parse(content)
23
+ if (packageJson.scripts && packageJson.scripts.build) {
24
+ return true
25
+ }
26
+ } catch {
27
+ // Invalid JSON or other read error
28
+ }
29
+ } catch {
30
+ // package.json doesn't exist
31
+ }
32
+
33
+ return false
34
+ }
35
+
36
+ export async function getTsconfig (root, config) {
37
+ try {
38
+ const tsConfigPath = config?.plugins?.typescript?.tsConfig || path.resolve(root, 'tsconfig.json')
39
+ const tsConfig = json5.parse(await readFile(tsConfigPath, 'utf8'))
40
+
41
+ return Object.assign(tsConfig.compilerOptions, config?.plugins?.typescript)
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ export function ignoreDirs (outDir, watchOptionsExcludeDirectories) {
48
+ const ignore = new Set()
49
+
50
+ if (watchOptionsExcludeDirectories) {
51
+ for (const dir of watchOptionsExcludeDirectories) {
52
+ ignore.add(dir)
53
+ }
54
+ }
55
+
56
+ if (outDir) {
57
+ ignore.add(outDir)
58
+ if (!outDir.endsWith('/**')) {
59
+ ignore.add(`${outDir}/*`)
60
+ ignore.add(`${outDir}/**/*`)
61
+ }
62
+ }
63
+
64
+ if (ignore.size === 0) {
65
+ return ['dist', 'dist/*', 'dist/**/*']
66
+ }
67
+
68
+ return Array.from(ignore)
69
+ }