@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,525 @@
1
+ import toml from '@iarna/toml'
2
+ import Ajv from 'ajv'
3
+ import { parse as parseEnvFile } from 'dotenv'
4
+ import jsonPatch from 'fast-json-patch'
5
+ import JSON5 from 'json5'
6
+ import { readFile, writeFile } from 'node:fs/promises'
7
+ import { createRequire } from 'node:module'
8
+ import { dirname, extname, isAbsolute, parse, resolve } from 'node:path'
9
+ import { parse as rawParseYAML, stringify as stringifyYAML } from 'yaml'
10
+ import {
11
+ AddAModulePropertyToTheConfigOrAddAKnownSchemaError,
12
+ CannotParseConfigFileError,
13
+ ConfigurationDoesNotValidateAgainstSchemaError,
14
+ InvalidConfigFileExtensionError,
15
+ RootMissingError,
16
+ SourceMissingError
17
+ } from './errors.js'
18
+ import { isFileAccessible } from './file-system.js'
19
+ import { loadModule, splitModuleFromVersion } from './module.js'
20
+
21
+ const { parse: parseJSON5, stringify: rawStringifyJSON5 } = JSON5
22
+ const { parse: parseTOML, stringify: stringifyTOML } = toml
23
+
24
+ const kReplaceEnvIgnore = Symbol('plt.foundation.replaceEnvIgnore')
25
+
26
+ export const kMetadata = Symbol('plt.foundation.metadata')
27
+ export const envVariablePattern = /(?:\{{1,2})([a-z0-9_]+)(?:\}{1,2})/i
28
+
29
+ export const knownConfigurationFilesExtensions = ['json', 'json5', 'yaml', 'yml', 'toml', 'tml']
30
+
31
+ // Important: do not put $ in any RegExp since we might use the querystring to deliver additional information
32
+ export const knownConfigurationFilesSchemas = [
33
+ /^https:\/\/platformatic.dev\/schemas\/(v?)(?<version>[^/]+)\/(?<module>.*)/,
34
+ /^https:\/\/schemas.platformatic.dev\/@platformatic\/(?<module>.*)\/(v?)(?<version>[^/]+)\.json/,
35
+ /^https:\/\/schemas.platformatic.dev\/(?<module>wattpm)\/(v?)(?<version>[^/]+)\.json/
36
+ ]
37
+
38
+ function parseYAML (raw, ...args) {
39
+ const bracesRegexp = /{(\d+|[a-z$_][\w\-$]*?(?:\.[\w\-$]*?)*?)}/gi
40
+ const stringRegexp = /(["'])(?:(?=(\\?))\2.)*?\1/gi
41
+
42
+ const stringMatches = [...raw.matchAll(stringRegexp)]
43
+
44
+ raw = raw.replace(bracesRegexp, (match, p1, offset) => {
45
+ for (const stringMatch of stringMatches) {
46
+ const stringStart = stringMatch.index
47
+ const stringEnd = stringMatch.index + stringMatch[0].length
48
+ if (offset >= stringStart && offset <= stringEnd) return match
49
+ }
50
+ return `'${match}'`
51
+ })
52
+
53
+ return rawParseYAML(raw, ...args)
54
+ }
55
+
56
+ export function stringifyJSON (data) {
57
+ return JSON.stringify(data, null, 2)
58
+ }
59
+
60
+ export function stringifyJSON5 (data) {
61
+ return rawStringifyJSON5(data, null, 2)
62
+ }
63
+
64
+ export function getParser (path) {
65
+ let parser
66
+
67
+ switch (extname(path)) {
68
+ case '.yaml':
69
+ case '.yml':
70
+ parser = parseYAML
71
+ break
72
+ case '.json':
73
+ parser = JSON.parse
74
+ break
75
+ case '.json5':
76
+ parser = parseJSON5
77
+ break
78
+ case '.toml':
79
+ case '.tml':
80
+ parser = parseTOML
81
+ break
82
+ default:
83
+ throw new InvalidConfigFileExtensionError()
84
+ }
85
+
86
+ return parser
87
+ }
88
+
89
+ export function getStringifier (path) {
90
+ let stringifer
91
+ switch (extname(path)) {
92
+ case '.yaml':
93
+ case '.yml':
94
+ stringifer = stringifyYAML
95
+ break
96
+ case '.json':
97
+ stringifer = stringifyJSON
98
+ break
99
+ case '.json5':
100
+ stringifer = stringifyJSON5
101
+ break
102
+ case '.toml':
103
+ case '.tml':
104
+ stringifer = stringifyTOML
105
+ break
106
+ default:
107
+ throw new InvalidConfigFileExtensionError()
108
+ }
109
+
110
+ return stringifer
111
+ }
112
+
113
+ export function printValidationErrors (err) {
114
+ const tabularData = err.validation.map(err => {
115
+ return { path: err.path, message: err.message }
116
+ })
117
+
118
+ console.table(tabularData, ['path', 'message'])
119
+ }
120
+
121
+ export function listRecognizedConfigurationFiles (suffixes, extensions) {
122
+ if (typeof suffixes === 'undefined' || suffixes === null) {
123
+ suffixes = ['runtime', 'service', 'application', 'db', 'composer']
124
+ } else if (suffixes && !Array.isArray(suffixes)) {
125
+ suffixes = [suffixes]
126
+ } else if (!suffixes) {
127
+ suffixes = []
128
+ }
129
+
130
+ if (!extensions) {
131
+ extensions = knownConfigurationFilesExtensions
132
+ } else if (!Array.isArray(extensions)) {
133
+ extensions = [extensions]
134
+ }
135
+
136
+ const files = []
137
+
138
+ for (const ext of extensions) {
139
+ files.push(`watt.${ext}`)
140
+ files.push(`platformatic.${ext}`)
141
+ }
142
+
143
+ for (const suffix of suffixes) {
144
+ for (const ext of extensions) {
145
+ files.push(`watt.${suffix}.${ext}`)
146
+ files.push(`platformatic.${suffix}.${ext}`)
147
+ }
148
+ }
149
+
150
+ return files
151
+ }
152
+
153
+ export function extractModuleFromSchemaUrl (config, throwOnMissing = false) {
154
+ if (typeof config?.module === 'string') {
155
+ return config
156
+ } else if (typeof config?.$schema !== 'string') {
157
+ if (throwOnMissing) {
158
+ throw new AddAModulePropertyToTheConfigOrAddAKnownSchemaError()
159
+ }
160
+
161
+ return null
162
+ }
163
+
164
+ const matching = knownConfigurationFilesSchemas.map(matcher => config.$schema.match(matcher)).find(m => m)
165
+
166
+ if (!matching) {
167
+ if (throwOnMissing) {
168
+ throw new AddAModulePropertyToTheConfigOrAddAKnownSchemaError()
169
+ }
170
+
171
+ return null
172
+ }
173
+
174
+ const mod = matching.groups.module
175
+ const version = matching.groups.version
176
+
177
+ return { module: `@platformatic/${mod === 'wattpm' ? 'runtime' : mod}`, version }
178
+ }
179
+
180
+ export async function findConfigurationFile (root, suffixes, extensions, candidates) {
181
+ if (!candidates) {
182
+ candidates = listRecognizedConfigurationFiles(suffixes, extensions)
183
+ }
184
+
185
+ const existingFiles = await Promise.all(
186
+ candidates.map(async fileName => {
187
+ const accessible = await isFileAccessible(fileName, root)
188
+ return accessible ? fileName : null
189
+ })
190
+ )
191
+ return existingFiles.find(v => v !== null) || null
192
+ }
193
+
194
+ export async function findConfigurationFileRecursive (root, configurationFile, schemas, suffixes) {
195
+ if (schemas && !Array.isArray(schemas)) {
196
+ schemas = [schemas]
197
+ }
198
+
199
+ let current = root
200
+
201
+ const candidates = listRecognizedConfigurationFiles(suffixes)
202
+
203
+ while (!configurationFile) {
204
+ // Find a wattpm.json or watt.json file
205
+ configurationFile = await findConfigurationFile(current, null, null, candidates)
206
+
207
+ // If a file is found, verify it actually represents a watt or runtime configuration
208
+ if (configurationFile) {
209
+ const configuration = await loadConfigurationFile(resolve(current, configurationFile))
210
+
211
+ if (schemas) {
212
+ const schemaMatch = extractModuleFromSchemaUrl(configuration)
213
+ /* c8 ignore next - else */
214
+ const moduleMatch = typeof schemaMatch === 'string' ? schemaMatch : schemaMatch?.module
215
+ if (!schemas.includes(moduleMatch)) {
216
+ configurationFile = null
217
+ }
218
+ }
219
+ }
220
+
221
+ if (!configurationFile) {
222
+ const newCurrent = dirname(current)
223
+
224
+ if (newCurrent === current) {
225
+ break
226
+ }
227
+
228
+ current = newCurrent
229
+ }
230
+ }
231
+
232
+ if (typeof configurationFile !== 'string') {
233
+ return null
234
+ }
235
+
236
+ const resolved = resolve(current, configurationFile)
237
+ return resolved
238
+ }
239
+
240
+ export async function loadConfigurationFile (configurationFile) {
241
+ try {
242
+ const parse = getParser(configurationFile)
243
+ return parse(await readFile(configurationFile, 'utf-8'))
244
+ } catch (error) {
245
+ throw new CannotParseConfigFileError(configurationFile, error, { cause: error })
246
+ }
247
+ }
248
+
249
+ export function saveConfigurationFile (configurationFile, config) {
250
+ const stringifer = getStringifier(configurationFile)
251
+ return writeFile(configurationFile, stringifer(config), 'utf-8')
252
+ }
253
+
254
+ export function createValidator (schema, validationOptions, context = {}) {
255
+ const ajv = new Ajv(validationOptions)
256
+
257
+ ajv.addKeyword({
258
+ keyword: 'resolvePath',
259
+ type: 'string',
260
+ schemaType: 'boolean',
261
+ // TODO@ShogunPanda: figure out how to implement this via the new `code` option in Ajv
262
+ validate: (schema, path, parentSchema, data) => {
263
+ if (typeof path !== 'string' || path.trim() === '') {
264
+ return !!parentSchema.allowEmptyPaths
265
+ }
266
+
267
+ if (context.fixPaths !== false) {
268
+ const resolved = resolve(context.root, path)
269
+ data.parentData[data.parentDataProperty] = resolved
270
+ }
271
+ return true
272
+ }
273
+ })
274
+
275
+ ajv.addKeyword({ keyword: 'allowEmptyPaths', type: 'string', schemaType: 'boolean' })
276
+
277
+ ajv.addKeyword({
278
+ keyword: 'resolveModule',
279
+ type: 'string',
280
+ schemaType: 'boolean',
281
+ // TODO@ShogunPanda: figure out how to implement this via the new `code` option in Ajv
282
+ validate: (_schema, path, _parentSchema, data) => {
283
+ if (typeof path !== 'string' || path.trim() === '') {
284
+ return false
285
+ }
286
+
287
+ if (context.fixPaths === false) {
288
+ return true
289
+ }
290
+
291
+ const require = createRequire(resolve(context.root, 'noop.js'))
292
+ try {
293
+ const resolved = require.resolve(path)
294
+ data.parentData[data.parentDataProperty] = resolved
295
+ return true
296
+ } catch {
297
+ return false
298
+ }
299
+ }
300
+ })
301
+
302
+ ajv.addKeyword({
303
+ keyword: 'typeof',
304
+ validate: function validate (schema, value, _, data) {
305
+ // eslint-disable-next-line valid-typeof
306
+ if (typeof value === schema) {
307
+ return true
308
+ }
309
+
310
+ validate.errors = [{ message: `"${data.parentDataProperty}" shoud be a ${schema}.`, params: data.parentData }]
311
+ return false
312
+ }
313
+ })
314
+
315
+ return ajv.compile(schema)
316
+ }
317
+
318
+ export async function loadEnv (root, ignoreProcessEnv = false, additionalEnv = {}) {
319
+ if (!isAbsolute(root)) {
320
+ root = resolve(process.cwd(), root)
321
+ }
322
+
323
+ let currentPath = root
324
+ const rootPath = parse(root).root
325
+
326
+ // Search for the .env file in the current directory and its parents
327
+ let envFile
328
+ while (currentPath !== rootPath) {
329
+ const candidate = resolve(currentPath, '.env')
330
+
331
+ if (await isFileAccessible(candidate)) {
332
+ envFile = candidate
333
+ break
334
+ }
335
+
336
+ currentPath = dirname(currentPath)
337
+ }
338
+
339
+ // If not found, check the current working directory
340
+ if (!envFile) {
341
+ const cwdCandidate = resolve(process.cwd(), '.env')
342
+
343
+ if (await isFileAccessible(cwdCandidate)) {
344
+ envFile = cwdCandidate
345
+ }
346
+ }
347
+
348
+ const baseEnv = ignoreProcessEnv ? {} : process.env
349
+ const envFromFile = envFile ? parseEnvFile(await readFile(envFile, 'utf-8')) : {}
350
+
351
+ return {
352
+ ...baseEnv,
353
+ ...additionalEnv,
354
+ ...envFromFile
355
+ }
356
+ }
357
+
358
+ export function replaceEnv (config, env, onMissingEnv, ignore) {
359
+ // First of all, apply the ignore list
360
+ if (ignore) {
361
+ for (let path of ignore) {
362
+ // Migrate JSON Path to JSON Pointer
363
+ if (path.startsWith('$')) {
364
+ path = '/' + path.slice(2).replaceAll('.', '/')
365
+ }
366
+
367
+ try {
368
+ const value = jsonPatch.getValueByPointer(config, path)
369
+
370
+ if (typeof value !== 'undefined') {
371
+ jsonPatch.applyOperation(config, {
372
+ op: 'add',
373
+ path,
374
+ value: { [kReplaceEnvIgnore]: true, originalValue: value }
375
+ })
376
+ }
377
+ } catch {
378
+ // No-op, the path does not exist
379
+ }
380
+ }
381
+ }
382
+
383
+ if (typeof config === 'object' && config !== null) {
384
+ if (config[kReplaceEnvIgnore]) {
385
+ return config.originalValue
386
+ }
387
+
388
+ for (const key of Object.keys(config)) {
389
+ config[key] = replaceEnv(config[key], env, onMissingEnv)
390
+ }
391
+ } else if (typeof config === 'string') {
392
+ let matches = config.match(envVariablePattern)
393
+
394
+ while (matches) {
395
+ const [template, key] = matches
396
+
397
+ try {
398
+ const replacement = env[key] ?? onMissingEnv?.(key) ?? ''
399
+ config = config.replace(template, replacement)
400
+
401
+ matches = config.match(envVariablePattern)
402
+ } catch (error) {
403
+ throw new CannotParseConfigFileError(error.message, { cause: error })
404
+ }
405
+ }
406
+ }
407
+
408
+ return config
409
+ }
410
+
411
+ export async function loadConfiguration (source, schema, options = {}) {
412
+ const {
413
+ validate,
414
+ validationOptions,
415
+ transform,
416
+ upgrade,
417
+ env: additionalEnv,
418
+ ignoreProcessEnv,
419
+ replaceEnv: shouldReplaceEnv,
420
+ replaceEnvIgnore,
421
+ onMissingEnv,
422
+ fixPaths,
423
+ logger,
424
+ skipMetadata
425
+ } = {
426
+ validate: !!schema,
427
+ validationOptions: {},
428
+ ignoreProcessEnv: false,
429
+ replaceEnv: true,
430
+ replaceEnvIgnore: [],
431
+ fixPaths: true,
432
+ ...options
433
+ }
434
+
435
+ let root = options.root
436
+
437
+ if (typeof source === 'undefined') {
438
+ throw new SourceMissingError()
439
+ }
440
+
441
+ let config = source
442
+ if (typeof source === 'string') {
443
+ root = dirname(source)
444
+ config = await loadConfigurationFile(source)
445
+ }
446
+
447
+ if (!root) {
448
+ throw new RootMissingError()
449
+ }
450
+
451
+ const env = await loadEnv(root, ignoreProcessEnv, additionalEnv)
452
+ env.PLT_ROOT = root
453
+
454
+ if (shouldReplaceEnv) {
455
+ config = replaceEnv(config, env, onMissingEnv, replaceEnvIgnore)
456
+ }
457
+
458
+ const moduleInfo = extractModuleFromSchemaUrl(config)
459
+
460
+ if (upgrade) {
461
+ let version = moduleInfo?.version
462
+
463
+ if (!version && config.module) {
464
+ version = splitModuleFromVersion(config.module).version
465
+ }
466
+
467
+ if (version) {
468
+ config = await upgrade(logger, config, version)
469
+ }
470
+ }
471
+
472
+ if (validate) {
473
+ if (typeof schema === 'undefined') {
474
+ throw new SourceMissingError()
475
+ }
476
+
477
+ const validator = createValidator(schema, validationOptions, { root, fixPaths })
478
+ const valid = validator(config)
479
+
480
+ if (!valid) {
481
+ const validationErrors = []
482
+ let errors = ':'
483
+
484
+ for (const validationError of validator.errors) {
485
+ /* c8 ignore next - else */
486
+ const path = validationError.instancePath === '' ? '/' : validationError.instancePath
487
+
488
+ validationErrors.push({ path, message: validationError.message, params: validationError.params })
489
+ errors += `\n - ${path}: ${validationError.message}`
490
+ }
491
+
492
+ const error = new ConfigurationDoesNotValidateAgainstSchemaError()
493
+ error.message += errors + '\n'
494
+ Object.defineProperty(error, 'validationErrors', { value: validationErrors })
495
+
496
+ throw error
497
+ }
498
+ }
499
+
500
+ if (!skipMetadata) {
501
+ config[kMetadata] = {
502
+ root,
503
+ env,
504
+ path: typeof source === 'string' ? source : null,
505
+ module: moduleInfo?.module ?? null
506
+ }
507
+ }
508
+
509
+ if (typeof transform === 'function') {
510
+ try {
511
+ config = await transform(config, schema, options)
512
+ } catch (error) {
513
+ throw new CannotParseConfigFileError(error.message, { cause: error })
514
+ }
515
+ }
516
+
517
+ return config
518
+ }
519
+
520
+ export function loadConfigurationModule (root, config, pkg) {
521
+ pkg ??= extractModuleFromSchemaUrl(config, true).module
522
+
523
+ const require = createRequire(resolve(root, 'noop.js'))
524
+ return loadModule(require, pkg)
525
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,58 @@
1
+ import createError from '@fastify/error'
2
+
3
+ export const ERROR_PREFIX = 'PLT'
4
+
5
+ export function ensureLoggableError (error) {
6
+ Reflect.defineProperty(error, 'message', { enumerable: true })
7
+
8
+ if ('code' in error) {
9
+ Reflect.defineProperty(error, 'code', { enumerable: true })
10
+ }
11
+
12
+ if ('stack' in error) {
13
+ Reflect.defineProperty(error, 'stack', { enumerable: true })
14
+ }
15
+
16
+ return error
17
+ }
18
+
19
+ // This is needed if an error is received via ensureLoggableError->postMessage
20
+ export function ensureError (error) {
21
+ if (error instanceof Error) {
22
+ return error
23
+ }
24
+
25
+ const err = new Error(error.message)
26
+ Object.assign(err, error)
27
+ return err
28
+ }
29
+
30
+ export const PathOptionRequiredError = createError(`${ERROR_PREFIX}_PATH_OPTION_REQUIRED`, 'path option is required')
31
+ export const NoConfigFileFoundError = createError(`${ERROR_PREFIX}_NO_CONFIG_FILE_FOUND`, 'no config file found')
32
+ export const InvalidConfigFileExtensionError = createError(
33
+ `${ERROR_PREFIX}_INVALID_CONFIG_FILE_EXTENSION`,
34
+ 'Invalid config file extension. Only yml, yaml, json, json5, toml, tml are supported.'
35
+ )
36
+ export const AddAModulePropertyToTheConfigOrAddAKnownSchemaError = createError(
37
+ `${ERROR_PREFIX}_ADD_A_MODULE_PROPERTY_TO_THE_CONFIG_OR_ADD_A_KNOWN_SCHEMA`,
38
+ 'Add a module property to the config or add a known $schema.'
39
+ )
40
+ export const CannotParseConfigFileError = createError(
41
+ `${ERROR_PREFIX}_CANNOT_PARSE_CONFIG_FILE`,
42
+ 'Cannot parse config file. %s'
43
+ )
44
+ export const SourceMissingError = createError(`${ERROR_PREFIX}_SOURCE_MISSING`, 'Source missing.')
45
+ export const RootMissingError = createError(
46
+ `${ERROR_PREFIX}_ROOT_MISSING`,
47
+ 'Provide the root option to loadConfiguration when using an object as source.'
48
+ )
49
+ export const SchemaMustBeDefinedError = createError(
50
+ `${ERROR_PREFIX}_SCHEMA_MUST_BE_DEFINED`,
51
+ 'schema must be defined',
52
+ undefined,
53
+ TypeError
54
+ )
55
+ export const ConfigurationDoesNotValidateAgainstSchemaError = createError(
56
+ `${ERROR_PREFIX}_CONFIGURATION_DOES_NOT_VALIDATE_AGAINST_SCHEMA`,
57
+ 'The configuration does not validate against the configuration schema'
58
+ )
@@ -0,0 +1,13 @@
1
+ import { Unpromise } from '@watchable/unpromise'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+
4
+ export const kTimeout = Symbol('plt.utils.timeout')
5
+
6
+ export async function executeWithTimeout (promise, timeout, timeoutValue = kTimeout) {
7
+ const ac = new AbortController()
8
+
9
+ return Unpromise.race([promise, sleep(timeout, timeoutValue, { signal: ac.signal, ref: false })]).then(value => {
10
+ ac.abort()
11
+ return value
12
+ })
13
+ }