@platformatic/foundation 3.0.0-alpha.2

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,188 @@
1
+ import generateName from 'boring-name-generator'
2
+ import { EventEmitter } from 'node:events'
3
+ import { existsSync } from 'node:fs'
4
+ import { access, glob, mkdir, rm, watch } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join, matchesGlob, resolve } from 'node:path'
7
+ import { setTimeout as sleep } from 'node:timers/promises'
8
+ import { PathOptionRequiredError } from './errors.js'
9
+
10
+ let tmpCount = 0
11
+ const ALLOWED_FS_EVENTS = ['change', 'rename']
12
+
13
+ export function removeDotSlash (path) {
14
+ return path.replace(/^\.[/\\]/, '')
15
+ }
16
+
17
+ export function generateDashedName () {
18
+ return generateName().dashed.replace(/\s+/g, '')
19
+ }
20
+
21
+ export async function isFileAccessible (filename, directory) {
22
+ try {
23
+ const filePath = directory ? resolve(directory, filename) : filename
24
+ await access(filePath)
25
+ return true
26
+ } catch (err) {
27
+ return false
28
+ }
29
+ }
30
+
31
+ export async function createDirectory (path, empty = false) {
32
+ if (empty) {
33
+ await safeRemove(path)
34
+ }
35
+
36
+ return mkdir(path, { recursive: true, maxRetries: 10, retryDelay: 1000 })
37
+ }
38
+
39
+ export async function createTemporaryDirectory (prefix) {
40
+ const directory = join(tmpdir(), `plt-utils-${prefix}-${process.pid}-${tmpCount++}`)
41
+
42
+ return createDirectory(directory)
43
+ }
44
+
45
+ export async function safeRemove (path) {
46
+ let i = 0
47
+ while (i++ < 10) {
48
+ if (!existsSync(path)) {
49
+ return
50
+ }
51
+
52
+ /* c8 ignore start - Hard to test */
53
+ try {
54
+ await rm(path, { force: true, recursive: true })
55
+ break
56
+ } catch {
57
+ // This means that we might not delete the folder at all.
58
+ // This is ok as we can't really trust Windows to behave.
59
+ }
60
+
61
+ await sleep(1000)
62
+ /* c8 ignore end - Hard to test */
63
+ }
64
+ }
65
+
66
+ export async function searchFilesWithExtensions (root, extensions, globOptions = {}) {
67
+ const globSuffix = Array.isArray(extensions) ? `{${extensions.join(',')}}` : extensions
68
+ return Array.fromAsync(glob(`**/*.${globSuffix}`, { ...globOptions, cwd: root }))
69
+ }
70
+
71
+ export async function searchJavascriptFiles (projectDir, globOptions = {}) {
72
+ return searchFilesWithExtensions(projectDir, ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'], {
73
+ ...globOptions,
74
+ ignore: ['node_modules', '**/node_modules/**']
75
+ })
76
+ }
77
+
78
+ export async function hasFilesWithExtensions (root, extensions, globOptions = {}) {
79
+ const files = await searchFilesWithExtensions(root, extensions, globOptions)
80
+ return files.length > 0
81
+ }
82
+
83
+ export async function hasJavascriptFiles (projectDir, globOptions = {}) {
84
+ const files = await searchJavascriptFiles(projectDir, globOptions)
85
+ return files.length > 0
86
+ }
87
+
88
+ export class FileWatcher extends EventEmitter {
89
+ constructor (opts) {
90
+ super()
91
+
92
+ if (typeof opts.path !== 'string') {
93
+ throw new PathOptionRequiredError()
94
+ }
95
+ this.path = opts.path
96
+ this.allowToWatch = opts.allowToWatch?.map(removeDotSlash) || null
97
+ this.watchIgnore = opts.watchIgnore?.map(removeDotSlash) || null
98
+ this.handlePromise = null
99
+ this.abortController = null
100
+
101
+ if (this.allowToWatch) {
102
+ this.allowToWatch = Array.from(new Set(this.allowToWatch))
103
+ }
104
+
105
+ if (this.watchIgnore) {
106
+ this.watchIgnore = Array.from(new Set(this.watchIgnore))
107
+ }
108
+
109
+ this.isWatching = false
110
+ }
111
+
112
+ startWatching () {
113
+ if (this.isWatching) return
114
+ this.isWatching = true
115
+
116
+ this.abortController = new AbortController()
117
+ const signal = this.abortController.signal
118
+
119
+ // Recursive watch is unreliable on platforms besides macOS and Windows.
120
+ // See: https://github.com/nodejs/node/issues/48437
121
+ const fsWatcher = watch(this.path, {
122
+ signal,
123
+ recursive: true
124
+ })
125
+
126
+ let updateTimeout = null
127
+
128
+ this.on('update', () => {
129
+ clearTimeout(updateTimeout)
130
+ updateTimeout = null
131
+ })
132
+
133
+ const eventHandler = async () => {
134
+ for await (const { eventType, filename } of fsWatcher) {
135
+ /* c8 ignore next */
136
+ if (filename === null) return
137
+ const isTimeoutSet = updateTimeout === null
138
+ const isTrackedEvent = ALLOWED_FS_EVENTS.includes(eventType)
139
+ const isTrackedFile = this.shouldFileBeWatched(filename)
140
+
141
+ if (isTimeoutSet && isTrackedEvent && isTrackedFile) {
142
+ updateTimeout = setTimeout(() => this.emit('update', filename), 100)
143
+ updateTimeout.unref()
144
+ }
145
+ }
146
+ } /* c8 ignore next */
147
+ this.handlePromise = eventHandler()
148
+ }
149
+
150
+ async stopWatching () {
151
+ if (!this.isWatching) return
152
+ this.isWatching = false
153
+
154
+ this.abortController.abort()
155
+ await this.handlePromise.catch(() => {})
156
+ }
157
+
158
+ shouldFileBeWatched (fileName) {
159
+ return this.isFileAllowed(fileName) && !this.isFileIgnored(fileName)
160
+ }
161
+
162
+ isFileAllowed (fileName) {
163
+ if (this.allowToWatch === null) {
164
+ return true
165
+ }
166
+
167
+ return this.allowToWatch.some(allowedFile => matchesGlob(fileName, allowedFile))
168
+ }
169
+
170
+ isFileIgnored (fileName) {
171
+ // Always ignore the node_modules folder - This can be overriden by the allow list
172
+ if (fileName.startsWith('node_modules')) {
173
+ return true
174
+ }
175
+
176
+ if (this.watchIgnore === null) {
177
+ return false
178
+ }
179
+
180
+ for (const ignoredFile of this.watchIgnore) {
181
+ if (matchesGlob(fileName, ignoredFile)) {
182
+ return true
183
+ }
184
+ }
185
+
186
+ return false
187
+ }
188
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,180 @@
1
+ import { createRequire } from 'node:module'
2
+ import { hostname } from 'node:os'
3
+ import path from 'node:path'
4
+ import pino from 'pino'
5
+
6
+ // Utilities to build pino options from a config object
7
+ // There are many variants to fit better the different use cases
8
+
9
+ export function setPinoFormatters (options) {
10
+ const r = createRequire(path.dirname(options.formatters.path))
11
+ const formatters = loadFormatters(r, options.formatters.path)
12
+ if (formatters.bindings) {
13
+ if (typeof formatters.bindings === 'function') {
14
+ options.formatters.bindings = formatters.bindings
15
+ } else {
16
+ throw new Error('logger.formatters.bindings must be a function')
17
+ }
18
+ }
19
+ if (formatters.level) {
20
+ if (typeof formatters.level === 'function') {
21
+ options.formatters.level = formatters.level
22
+ } else {
23
+ throw new Error('logger.formatters.level must be a function')
24
+ }
25
+ }
26
+ }
27
+
28
+ export function buildPinoFormatters (formatters) {
29
+ const r = createRequire(path.dirname(formatters.path))
30
+ const f = loadFormatters(r, formatters.path)
31
+ const pinoFormatters = {}
32
+ if (f.bindings) {
33
+ if (typeof f.bindings === 'function') {
34
+ pinoFormatters.bindings = f.bindings
35
+ } else {
36
+ throw new Error('logger.formatters.bindings must be a function')
37
+ }
38
+ }
39
+ if (f.level) {
40
+ if (typeof f.level === 'function') {
41
+ pinoFormatters.level = f.level
42
+ } else {
43
+ throw new Error('logger.formatters.level must be a function')
44
+ }
45
+ }
46
+ return pinoFormatters
47
+ }
48
+
49
+ export function setPinoTimestamp (options) {
50
+ options.timestamp = stdTimeFunctions[options.timestamp]
51
+ }
52
+
53
+ export function buildPinoTimestamp (timestamp) {
54
+ return stdTimeFunctions[timestamp]
55
+ }
56
+
57
+ export function buildPinoOptions (loggerConfig, serverConfig, serviceId, workerId, context, root) {
58
+ const pinoOptions = {
59
+ level: loggerConfig?.level ?? serverConfig?.level ?? 'trace'
60
+ }
61
+
62
+ if (serviceId) {
63
+ pinoOptions.name = serviceId
64
+ }
65
+
66
+ if (loggerConfig?.base) {
67
+ for (const [key, value] of Object.entries(loggerConfig.base)) {
68
+ if (typeof value !== 'string') {
69
+ throw new Error(`logger.base.${key} must be a string`)
70
+ }
71
+ }
72
+ /* c8 ignore next - else */
73
+ } else if (loggerConfig?.base === null) {
74
+ pinoOptions.base = undefined
75
+ }
76
+
77
+ if (typeof context.worker?.index !== 'undefined' && loggerConfig?.base !== null) {
78
+ pinoOptions.base = {
79
+ ...(pinoOptions.base ?? {}),
80
+ pid: process.pid,
81
+ hostname: hostname(),
82
+ worker: workerId
83
+ }
84
+ }
85
+
86
+ if (loggerConfig?.formatters) {
87
+ pinoOptions.formatters = {}
88
+ const formatters = loadFormatters(createRequire(root), loggerConfig.formatters.path)
89
+ if (formatters.bindings) {
90
+ if (typeof formatters.bindings === 'function') {
91
+ pinoOptions.formatters.bindings = formatters.bindings
92
+ } else {
93
+ throw new Error('logger.formatters.bindings must be a function')
94
+ }
95
+ }
96
+ if (formatters.level) {
97
+ if (typeof formatters.level === 'function') {
98
+ pinoOptions.formatters.level = formatters.level
99
+ } else {
100
+ throw new Error('logger.formatters.level must be a function')
101
+ }
102
+ }
103
+ }
104
+
105
+ if (loggerConfig?.timestamp !== undefined) {
106
+ pinoOptions.timestamp = stdTimeFunctions[loggerConfig.timestamp]
107
+ }
108
+
109
+ if (loggerConfig?.redact) {
110
+ pinoOptions.redact = {
111
+ paths: loggerConfig.redact.paths,
112
+ censor: loggerConfig.redact.censor
113
+ }
114
+ }
115
+
116
+ if (loggerConfig?.messageKey) {
117
+ pinoOptions.messageKey = loggerConfig.messageKey
118
+ }
119
+
120
+ if (loggerConfig?.customLevels) {
121
+ for (const [key, value] of Object.entries(loggerConfig.customLevels)) {
122
+ if (typeof value !== 'number') {
123
+ throw new Error(`logger.customLevels.${key} must be a number`)
124
+ }
125
+ }
126
+ pinoOptions.customLevels = loggerConfig.customLevels
127
+ }
128
+
129
+ // This is used by standalone CLI like start-platformatic-node in @platformatic/node
130
+ if (loggerConfig?.pretty) {
131
+ pinoOptions.transport = { target: 'pino-pretty' }
132
+ }
133
+
134
+ return pinoOptions
135
+ }
136
+
137
+ export function loadFormatters (require, file) {
138
+ try {
139
+ // Check if the file is a valid path
140
+ const resolvedPath = require.resolve(file)
141
+
142
+ // Load the module
143
+ const loaded = require(resolvedPath)
144
+
145
+ return loaded?.default ?? loaded
146
+ } catch (error) {
147
+ throw new Error(`Failed to load function from ${file}: ${error.message}`)
148
+ }
149
+ }
150
+
151
+ // This is needed so that pino detects a tampered stdout and avoid writing directly to the FD.
152
+ // Writing directly to the FD would bypass worker.stdout, which is currently piped in the parent process.
153
+ // See: https://github.com/pinojs/pino/blob/ad864b7ae02b314b9a548614f705a437e0db78c3/lib/tools.js#L330
154
+ export function disablePinoDirectWrite () {
155
+ process.stdout.write = process.stdout.write.bind(process.stdout)
156
+ }
157
+
158
+ /* c8 ignore start - Nothing to test */
159
+ export function noop () {}
160
+
161
+ export const abstractLogger = {
162
+ fatal: noop,
163
+ error: noop,
164
+ warn: noop,
165
+ info: noop,
166
+ debug: noop,
167
+ trace: noop,
168
+ done: noop,
169
+ child () {
170
+ return abstractLogger
171
+ }
172
+ }
173
+ /* c8 ignore end */
174
+
175
+ export const stdTimeFunctions = {
176
+ epochTime: pino.stdTimeFunctions.epochTime,
177
+ unixTime: pino.stdTimeFunctions.unixTime,
178
+ nullTime: pino.stdTimeFunctions.nullTime,
179
+ isoTime: pino.stdTimeFunctions.isoTime
180
+ }
package/lib/module.js ADDED
@@ -0,0 +1,175 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { request } from 'undici'
6
+ import { hasJavascriptFiles } from './file-system.js'
7
+
8
+ let platformaticPackageVersion
9
+
10
+ export const kFailedImport = Symbol('plt.utils.failedImport')
11
+
12
+ export const defaultPackageManager = 'npm'
13
+
14
+ export async function getLatestNpmVersion (pkg) {
15
+ const res = await request(`https://registry.npmjs.org/${pkg}`)
16
+ if (res.statusCode === 200) {
17
+ const json = await res.body.json()
18
+ return json['dist-tags'].latest
19
+ }
20
+ return null
21
+ }
22
+
23
+ export function getPkgManager () {
24
+ const userAgent = process.env.npm_config_user_agent
25
+ if (!userAgent) {
26
+ return 'npm'
27
+ }
28
+ const pmSpec = userAgent.split(' ')[0]
29
+ const separatorPos = pmSpec.lastIndexOf('/')
30
+ const name = pmSpec.substring(0, separatorPos)
31
+ return name || 'npm'
32
+ }
33
+
34
+ /**
35
+ * Get the package manager used in the project by looking at the lock file
36
+ *
37
+ * if `search` is true, will search for the package manager in a nested directory
38
+ */
39
+ export async function getPackageManager (root, defaultManager = defaultPackageManager, search = false) {
40
+ if (existsSync(resolve(root, 'pnpm-lock.yaml'))) {
41
+ return 'pnpm'
42
+ }
43
+
44
+ if (existsSync(resolve(root, 'yarn.lock'))) {
45
+ return 'yarn'
46
+ }
47
+
48
+ if (existsSync(resolve(root, 'package-lock.json'))) {
49
+ return 'npm'
50
+ }
51
+
52
+ // search for the package manager in a nested directory
53
+ if (search) {
54
+ // look in the first level nested directory
55
+ for (const dir of await readdir(root)) {
56
+ const p = await getPackageManager(resolve(root, dir), null)
57
+ if (p) {
58
+ return p
59
+ }
60
+ }
61
+ }
62
+
63
+ return defaultManager
64
+ }
65
+
66
+ export function getInstallationCommand (packageManager, production) {
67
+ const args = ['install']
68
+ if (production) {
69
+ switch (packageManager) {
70
+ case 'pnpm':
71
+ args.push('--prod')
72
+ break
73
+ case 'yarn':
74
+ args.push('--production')
75
+ break
76
+ case 'npm':
77
+ args.push('--omit=dev')
78
+ break
79
+ }
80
+ }
81
+ return args
82
+ }
83
+
84
+ export async function getPlatformaticVersion () {
85
+ if (!platformaticPackageVersion) {
86
+ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'))
87
+ platformaticPackageVersion = packageJson.version
88
+ return platformaticPackageVersion
89
+ }
90
+
91
+ return platformaticPackageVersion
92
+ }
93
+
94
+ export function hasDependency (packageJson, dependency) {
95
+ return packageJson.dependencies?.[dependency] || packageJson.devDependencies?.[dependency]
96
+ }
97
+
98
+ export function splitModuleFromVersion (module) {
99
+ if (!module) {
100
+ return {}
101
+ }
102
+ const versionMatcher = module.match(/(.+)@(\d+.\d+.\d+)/)
103
+ let version
104
+ if (versionMatcher) {
105
+ module = versionMatcher[1]
106
+ version = versionMatcher[2]
107
+ }
108
+ return { module, version }
109
+ }
110
+
111
+ export async function detectApplicationType (root, packageJson) {
112
+ if (!packageJson) {
113
+ try {
114
+ packageJson = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf-8'))
115
+ } catch {
116
+ packageJson = {}
117
+ }
118
+ }
119
+
120
+ let name
121
+ let label
122
+
123
+ if (hasDependency(packageJson, '@nestjs/core')) {
124
+ name = '@platformatic/nest'
125
+ label = 'NestJS'
126
+ } else if (hasDependency(packageJson, 'next')) {
127
+ name = '@platformatic/next'
128
+ label = 'Next.js'
129
+ } else if (hasDependency(packageJson, '@remix-run/dev')) {
130
+ name = '@platformatic/remix'
131
+ label = 'Remix'
132
+ } else if (hasDependency(packageJson, 'astro')) {
133
+ name = '@platformatic/astro'
134
+ label = 'Astro'
135
+ // Since Vite is often used with other frameworks, we must check for Vite last
136
+ } else if (hasDependency(packageJson, 'vite')) {
137
+ name = '@platformatic/vite'
138
+ label = 'Vite'
139
+ } else if (await hasJavascriptFiles(root)) {
140
+ // If no specific framework is detected, we assume it's a generic Node.js application
141
+ name = '@platformatic/node'
142
+ label = 'Node.js'
143
+ }
144
+
145
+ return name ? { name, label } : null
146
+ }
147
+
148
+ export async function loadModule (require, path) {
149
+ if (path.startsWith('file://')) {
150
+ path = fileURLToPath(path)
151
+ }
152
+
153
+ let loaded
154
+ try {
155
+ try {
156
+ loaded = require(path)
157
+ } catch (err) {
158
+ /* c8 ignore next 4 */
159
+ if (err.code === 'ERR_REQUIRE_ESM') {
160
+ const toLoad = require.resolve(path)
161
+ loaded = await import('file://' + toLoad)
162
+ } else {
163
+ throw err
164
+ }
165
+ }
166
+ } catch (err) {
167
+ if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND') {
168
+ err[kFailedImport] = path
169
+ }
170
+
171
+ throw err
172
+ }
173
+
174
+ return loaded?.default ?? loaded
175
+ }
package/lib/node.js ADDED
@@ -0,0 +1,24 @@
1
+ import { platform } from 'node:os'
2
+ import { lt, satisfies } from 'semver'
3
+
4
+ const currentPlatform = platform()
5
+
6
+ export function checkNodeVersionForServices () {
7
+ const currentVersion = process.version
8
+ const minimumVersion = '22.18.0'
9
+
10
+ if (lt(currentVersion, minimumVersion)) {
11
+ throw new Error(
12
+ `Your current Node.js version is ${currentVersion}, while the minimum supported version is v${minimumVersion}. Please upgrade Node.js and try again.`
13
+ )
14
+ }
15
+ }
16
+
17
+ export const features = {
18
+ node: {
19
+ reusePort: satisfies(process.version, '^22.12.0 || ^23.1.0') && !['win32', 'darwin'].includes(currentPlatform),
20
+ worker: {
21
+ getHeapStatistics: satisfies(process.version, '^22.18.0')
22
+ }
23
+ }
24
+ }
package/lib/object.js ADDED
@@ -0,0 +1,35 @@
1
+ import { deepmerge as fastifyDeepMerge } from '@fastify/deepmerge'
2
+
3
+ // This function merges arrays recursively. When the source is shorter than the target, the target value is cloned.
4
+ function deepmergeArray (options) {
5
+ const { deepmerge, clone } = options
6
+
7
+ return function mergeArray (target, source) {
8
+ const sourceLength = source.length
9
+ const targetLength = Math.max(target.length, source.length)
10
+
11
+ const result = new Array(targetLength)
12
+ for (let i = 0; i < targetLength; i++) {
13
+ result[i] = i < sourceLength ? deepmerge(target[i], source[i]) : clone(target[i])
14
+ }
15
+
16
+ return result
17
+ }
18
+ }
19
+
20
+ export const deepmerge = fastifyDeepMerge({ all: true, mergeArray: deepmergeArray })
21
+
22
+ export function isKeyEnabled (key, config) {
23
+ if (config === undefined) return false
24
+ if (typeof config[key] === 'boolean') {
25
+ return config[key]
26
+ }
27
+ if (config[key] === undefined) {
28
+ return false
29
+ }
30
+ return true
31
+ }
32
+
33
+ export function getPrivateSymbol (obj, name) {
34
+ return Object.getOwnPropertySymbols(obj).find(s => s.description === name)
35
+ }