@platformatic/foundation 3.68.0 → 4.0.0-new-config.1
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/cli.js +39 -1
- package/lib/schema.js +10 -23
- package/lib/v4/canonicalize.js +206 -0
- package/lib/v4/capability-resolution.js +149 -0
- package/lib/v4/classify.js +46 -0
- package/lib/v4/context.js +52 -0
- package/lib/v4/detect.js +112 -0
- package/lib/v4/env.js +208 -0
- package/lib/v4/errors.js +131 -0
- package/lib/v4/eval-worker.js +47 -0
- package/lib/v4/evaluate.js +180 -0
- package/lib/v4/filenames.js +116 -0
- package/lib/v4/identifiers.js +81 -0
- package/lib/v4/index.js +21 -0
- package/lib/v4/load.js +889 -0
- package/lib/v4/pipeline.js +233 -0
- package/lib/v4/scope.js +184 -0
- package/lib/v4/serving.js +82 -0
- package/lib/v4/topology.js +137 -0
- package/lib/v4/validate.js +185 -0
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { AmbiguousConfigurationFileError, LegacyConfigurationFileError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
// The four recognized v4 filenames. Exactly one may exist in a directory.
|
|
6
|
+
export const configurationFileExtensions = ['ts', 'mts', 'js', 'mjs']
|
|
7
|
+
export const configurationFileNames = configurationFileExtensions.map(extension => `watt.config.${extension}`)
|
|
8
|
+
|
|
9
|
+
// The complete v3 candidate set. Legacy detection is unconditional and by filename alone —
|
|
10
|
+
// no parsing, no shape heuristics — so this table is the whole of it. It is deliberately
|
|
11
|
+
// duplicated from the v3 machinery rather than imported: that machinery leaves foundation.
|
|
12
|
+
export const legacyConfigurationFileExtensions = ['json', 'json5', 'yaml', 'yml', 'toml', 'tml']
|
|
13
|
+
export const legacyConfigurationFileSuffixes = [
|
|
14
|
+
'runtime',
|
|
15
|
+
'application',
|
|
16
|
+
'service',
|
|
17
|
+
'db',
|
|
18
|
+
'gateway',
|
|
19
|
+
'composer'
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export const legacyConfigurationFileNames = (function listLegacyConfigurationFileNames () {
|
|
23
|
+
const names = []
|
|
24
|
+
|
|
25
|
+
for (const extension of legacyConfigurationFileExtensions) {
|
|
26
|
+
names.push(`watt.${extension}`, `platformatic.${extension}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const suffix of legacyConfigurationFileSuffixes) {
|
|
30
|
+
for (const extension of legacyConfigurationFileExtensions) {
|
|
31
|
+
names.push(`watt.${suffix}.${extension}`, `platformatic.${suffix}.${extension}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return names
|
|
36
|
+
})()
|
|
37
|
+
|
|
38
|
+
const legacyConfigurationFileNamesSet = new Set(legacyConfigurationFileNames)
|
|
39
|
+
const configurationFileNamesSet = new Set(configurationFileNames)
|
|
40
|
+
|
|
41
|
+
// One directory read serves both tables. Returning the raw listing lets callers that already
|
|
42
|
+
// walked a directory answer both questions without a second stat storm.
|
|
43
|
+
export async function listDirectoryEntries (directory) {
|
|
44
|
+
try {
|
|
45
|
+
return await readdir(directory)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES') {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function selectConfigurationFileNames (entries) {
|
|
56
|
+
// Ordered by the canonical extension order rather than by directory order, so the ambiguity
|
|
57
|
+
// error reads the same on every filesystem.
|
|
58
|
+
return configurationFileNames.filter(name => entries.includes(name))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function selectLegacyConfigurationFileNames (entries) {
|
|
62
|
+
return entries.filter(entry => legacyConfigurationFileNamesSet.has(entry)).sort()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isConfigurationFileName (name) {
|
|
66
|
+
return configurationFileNamesSet.has(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isLegacyConfigurationFileName (name) {
|
|
70
|
+
return legacyConfigurationFileNamesSet.has(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Consulting a directory means both checks, in this order: a legacy file is an error even next
|
|
74
|
+
// to a v4 one, so it is reported before the ambiguity check can shadow it.
|
|
75
|
+
export async function inspectDirectory (directory) {
|
|
76
|
+
const entries = await listDirectoryEntries(directory)
|
|
77
|
+
const legacy = selectLegacyConfigurationFileNames(entries)
|
|
78
|
+
|
|
79
|
+
if (legacy.length > 0) {
|
|
80
|
+
throw new LegacyConfigurationFileError(join(directory, legacy[0]))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const candidates = selectConfigurationFileNames(entries)
|
|
84
|
+
|
|
85
|
+
if (candidates.length > 1) {
|
|
86
|
+
throw new AmbiguousConfigurationFileError(directory, candidates.join(', '))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return candidates.length === 1 ? join(directory, candidates[0]) : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Synthesis is never refused on account of a configuration above, but it does say so — and the
|
|
93
|
+
// scan looks for the complete candidate set rather than only the v4 names, because a v3 monorepo
|
|
94
|
+
// is exactly where a configless subpackage is most likely to be found. Synthesizing there while an
|
|
95
|
+
// ancestor platformatic.json describes the application is the same silence with an older filename.
|
|
96
|
+
export async function findAnyConfigurationFile (directory) {
|
|
97
|
+
const entries = await listDirectoryEntries(directory)
|
|
98
|
+
const candidates = selectConfigurationFileNames(entries)
|
|
99
|
+
|
|
100
|
+
if (candidates.length > 0) {
|
|
101
|
+
return { path: join(directory, candidates[0]), legacy: false }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const legacy = selectLegacyConfigurationFileNames(entries)
|
|
105
|
+
|
|
106
|
+
return legacy.length > 0 ? { path: join(directory, legacy[0]), legacy: true } : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The env-root and watch-horizon scans are filename checks that execute nothing and decide
|
|
110
|
+
// nothing about which configuration boots, so they never raise the ambiguity error: a directory
|
|
111
|
+
// that has two candidates still has a configuration in it.
|
|
112
|
+
export async function hasConfigurationFile (directory) {
|
|
113
|
+
const entries = await listDirectoryEntries(directory)
|
|
114
|
+
|
|
115
|
+
return selectConfigurationFileNames(entries).length > 0
|
|
116
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { InvalidApplicationIdError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
// The id is not cosmetic: it is the mesh hostname, the injected PLT_<ID>_URL name, the metrics
|
|
5
|
+
// label, wattpm inject's argument and how siblings name each other in dependencies. A default
|
|
6
|
+
// that varied by boot style would move all five at once, so there is one derivation used at every
|
|
7
|
+
// position and under every boot style — though not always over the same inputs, since an explicit
|
|
8
|
+
// id lives on a root entry and a standalone boot never reads one.
|
|
9
|
+
export function stripPackageScope (name) {
|
|
10
|
+
return name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function deriveApplicationId ({ id, packageName, directory } = {}) {
|
|
14
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
15
|
+
return { id, source: 'configuration' }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof packageName === 'string' && packageName.length > 0) {
|
|
19
|
+
return { id: stripPackageScope(packageName), source: 'package.json' }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return { id: basename(directory ?? ''), source: 'directory' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The test is the DNS label grammar itself, not a list of bad characters: an enumeration of @, /,
|
|
26
|
+
// : and whitespace would have admitted my_app and api.v2, which are equally unusable as
|
|
27
|
+
// http://<id>.plt.local.
|
|
28
|
+
export const dnsLabelPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
|
29
|
+
|
|
30
|
+
export function isValidApplicationId (id) {
|
|
31
|
+
return typeof id === 'string' && dnsLabelPattern.test(id)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Checked before the id reaches either consumer — the hostname and the topology-variable
|
|
35
|
+
// normalization. Silently rewriting my_app to my-app would move the mesh hostname, the injected
|
|
36
|
+
// variable and the metrics label without the user asking, so this reports rather than sanitizes.
|
|
37
|
+
export function assertValidApplicationId (id, source) {
|
|
38
|
+
if (!isValidApplicationId(id)) {
|
|
39
|
+
throw new InvalidApplicationIdError(JSON.stringify(id), source)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getApplicationUrl (id) {
|
|
46
|
+
return `http://${id}.plt.local`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Uppercased id, non-alphanumerics to underscore — the same normalization injection uses, which is
|
|
50
|
+
// what lets the loader strip exactly these keys from a per-app eval worker's environment.
|
|
51
|
+
export function topologyVariableName (id) {
|
|
52
|
+
return `PLT_${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_URL`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The label grammar removes most of the ways two ids could normalize to one variable, so what
|
|
56
|
+
// remains is a case difference — and DNS labels being case-insensitive, api-v2 and API-v2 are the
|
|
57
|
+
// same mesh hostname too. One check catches both collisions.
|
|
58
|
+
export function findTopologyVariableCollisions (ids) {
|
|
59
|
+
const byName = new Map()
|
|
60
|
+
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
const name = topologyVariableName(id)
|
|
63
|
+
const existing = byName.get(name)
|
|
64
|
+
|
|
65
|
+
if (existing) {
|
|
66
|
+
existing.push(id)
|
|
67
|
+
} else {
|
|
68
|
+
byName.set(name, [id])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collisions = []
|
|
73
|
+
|
|
74
|
+
for (const [name, colliding] of byName) {
|
|
75
|
+
if (colliding.length > 1) {
|
|
76
|
+
collisions.push({ name, ids: colliding })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return collisions
|
|
81
|
+
}
|
package/lib/v4/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The v4 configuration loader. It is written new for v4 rather than carved out of the v3
|
|
2
|
+
// configuration machinery, and it shares nothing with it: the v3 parsers, replaceEnv, the YAML
|
|
3
|
+
// pre-pass, strictEnv and the $schema URL machinery move into wattpm-utils as migrate's private
|
|
4
|
+
// legacy reader. Only deliberately-kept pieces are carried over, each by explicit decision.
|
|
5
|
+
export * from './canonicalize.js'
|
|
6
|
+
export * from './capability-resolution.js'
|
|
7
|
+
export * from './classify.js'
|
|
8
|
+
export * from './context.js'
|
|
9
|
+
export * from './detect.js'
|
|
10
|
+
export * from './env.js'
|
|
11
|
+
export * from './evaluate.js'
|
|
12
|
+
export * from './errors.js'
|
|
13
|
+
export * as errors from './errors.js'
|
|
14
|
+
export * from './filenames.js'
|
|
15
|
+
export * from './identifiers.js'
|
|
16
|
+
export * from './load.js'
|
|
17
|
+
export * from './pipeline.js'
|
|
18
|
+
export * from './scope.js'
|
|
19
|
+
export * from './serving.js'
|
|
20
|
+
export * from './topology.js'
|
|
21
|
+
export * from './validate.js'
|