@platformatic/foundation 3.68.0 → 4.0.0-new-config.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/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
package/lib/v4/detect.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { hasJavascriptFiles } from '../file-system.js'
|
|
4
|
+
import { AmbiguousCapabilityError, CapabilityNotDetectedError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The table is enumerated rather than pattern-matched on @platformatic/*, so companion packages
|
|
8
|
+
like @platformatic/globals — which @platformatic/node's own generator writes alongside it —
|
|
9
|
+
cannot trip the ambiguity error, and the out-of-tree capabilities already in v3's table have a
|
|
10
|
+
defined place. A capability outside the table, which is every third-party one, is never inferred:
|
|
11
|
+
those applications declare an explicit config file.
|
|
12
|
+
*/
|
|
13
|
+
export const capabilityPackages = [
|
|
14
|
+
'@platformatic/node',
|
|
15
|
+
'@platformatic/service',
|
|
16
|
+
'@platformatic/db',
|
|
17
|
+
'@platformatic/gateway',
|
|
18
|
+
'@platformatic/next',
|
|
19
|
+
'@platformatic/astro',
|
|
20
|
+
'@platformatic/vite',
|
|
21
|
+
'@platformatic/remix',
|
|
22
|
+
'@platformatic/nest',
|
|
23
|
+
'@platformatic/nitro',
|
|
24
|
+
'@platformatic/nuxt',
|
|
25
|
+
'@platformatic/react-router',
|
|
26
|
+
'@platformatic/tanstack',
|
|
27
|
+
'@platformatic/php',
|
|
28
|
+
'@platformatic/ai-warp',
|
|
29
|
+
'@platformatic/pg-hooks',
|
|
30
|
+
'@platformatic/rabbitmq-hooks',
|
|
31
|
+
'@platformatic/kafka-hooks'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export const capabilityAliases = { '@platformatic/composer': '@platformatic/gateway' }
|
|
35
|
+
|
|
36
|
+
// Fallback only, and ordered: Nitro applications often use Vite, so Nitro is checked first, and
|
|
37
|
+
// Vite comes last amongst the frontend frameworks for the same reason.
|
|
38
|
+
export const frameworkDependencies = [
|
|
39
|
+
{ capability: '@platformatic/nest', dependencies: ['@nestjs/core'] },
|
|
40
|
+
{ capability: '@platformatic/next', dependencies: ['next'] },
|
|
41
|
+
{ capability: '@platformatic/remix', dependencies: ['@remix-run/dev'] },
|
|
42
|
+
{ capability: '@platformatic/astro', dependencies: ['astro'] },
|
|
43
|
+
{ capability: '@platformatic/react-router', dependencies: ['@react-router/dev'] },
|
|
44
|
+
{ capability: '@platformatic/nuxt', dependencies: ['nuxt'] },
|
|
45
|
+
{ capability: '@platformatic/tanstack', dependencies: ['@tanstack/react-start'] },
|
|
46
|
+
{ capability: '@platformatic/nitro', dependencies: ['nitro', 'nitropack'] },
|
|
47
|
+
{ capability: '@platformatic/vite', dependencies: ['vite'] }
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
export function hasDirectDependency (packageJson, dependency) {
|
|
51
|
+
return Boolean(packageJson?.dependencies?.[dependency] ?? packageJson?.devDependencies?.[dependency])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readPackageJson (directory) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(await readFile(join(directory, 'package.json'), 'utf-8'))
|
|
57
|
+
} catch {
|
|
58
|
+
return {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
One deterministic run against the application's package.json, for an entry with neither an inline
|
|
64
|
+
config nor a per-app file.
|
|
65
|
+
|
|
66
|
+
The order inverts v3, which checked framework dependencies first and reached @platformatic/node
|
|
67
|
+
only through the terminal fallback. Under that order a generated Node application that later
|
|
68
|
+
added Vite as unrelated tooling would silently switch capability on its next boot. Because
|
|
69
|
+
scaffolding always adds the chosen capability to the application's dependencies, this order
|
|
70
|
+
provably reconstructs the wizard's choice — which is what makes the single-app zero-config case
|
|
71
|
+
sound. Multi-app projects never rely on it.
|
|
72
|
+
*/
|
|
73
|
+
export async function detectCapability (directory, { id, packageJson } = {}) {
|
|
74
|
+
packageJson ??= await readPackageJson(directory)
|
|
75
|
+
|
|
76
|
+
const declared = new Set()
|
|
77
|
+
|
|
78
|
+
for (const [alias, canonical] of Object.entries(capabilityAliases)) {
|
|
79
|
+
if (hasDirectDependency(packageJson, alias)) {
|
|
80
|
+
declared.add(canonical)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const capability of capabilityPackages) {
|
|
85
|
+
if (hasDirectDependency(packageJson, capability)) {
|
|
86
|
+
declared.add(capability)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (declared.size > 1) {
|
|
91
|
+
throw new AmbiguousCapabilityError(id ?? directory, [...declared].sort().join(', '))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (declared.size === 1) {
|
|
95
|
+
return { capability: [...declared][0], source: 'dependency' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const { capability, dependencies } of frameworkDependencies) {
|
|
99
|
+
if (dependencies.some(dependency => hasDirectDependency(packageJson, dependency))) {
|
|
100
|
+
return { capability, source: 'framework' }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The terminal rule keeps v3's zero-config floor: a directory containing JavaScript or
|
|
105
|
+
// TypeScript sources that matched nothing else is a generic Node application. A directory with
|
|
106
|
+
// none is an error naming the application — there is no generic fallback beyond this one.
|
|
107
|
+
if (await hasJavascriptFiles(directory)) {
|
|
108
|
+
return { capability: '@platformatic/node', source: 'terminal' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
throw new CapabilityNotDetectedError(id ?? directory, directory)
|
|
112
|
+
}
|
package/lib/v4/env.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { parseEnv } from 'node:util'
|
|
4
|
+
import { EnvFileNotFoundError } from './errors.js'
|
|
5
|
+
import { ancestorDirectories } from './scope.js'
|
|
6
|
+
|
|
7
|
+
// Vite parity. Ordered most specific first, which is the order the layering consumes:
|
|
8
|
+
// .env.<mode>.local > .env.<mode> > .env.local > .env
|
|
9
|
+
export function listEnvFileNames (mode) {
|
|
10
|
+
const names = []
|
|
11
|
+
|
|
12
|
+
if (mode) {
|
|
13
|
+
names.push(`.env.${mode}.local`, `.env.${mode}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
names.push('.env.local', '.env')
|
|
17
|
+
|
|
18
|
+
return names
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A chain runs from `directory` up to and including `envRoot`, nearest winning. When `envRoot` is
|
|
22
|
+
// not an ancestor of `directory` — an application outside the runtime's tree, whose own env root
|
|
23
|
+
// is itself — the chain is the directory alone. Every chain terminates.
|
|
24
|
+
export function resolveDirectoryChain (directory, envRoot) {
|
|
25
|
+
const chain = []
|
|
26
|
+
|
|
27
|
+
for (const candidate of ancestorDirectories(directory)) {
|
|
28
|
+
chain.push(candidate)
|
|
29
|
+
|
|
30
|
+
if (candidate === envRoot) {
|
|
31
|
+
return chain
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [directory]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readEnvFile (path, { required = false } = {}) {
|
|
39
|
+
let contents
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
contents = await readFile(path, 'utf-8')
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!required && (error.code === 'ENOENT' || error.code === 'EISDIR')) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (error.code === 'ENOENT') {
|
|
49
|
+
throw new EnvFileNotFoundError(path)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { path, values: parseEnv(contents) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Every path a directory contributes, whether or not it exists. The watcher needs the full set:
|
|
59
|
+
// creating a .env is how a rung appears, and a set built from what exists cannot see that.
|
|
60
|
+
export function listDirectoryEnvFilePaths (directory, mode) {
|
|
61
|
+
return listEnvFileNames(mode).map(name => resolve(directory, name))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listChainEnvFilePaths (chain, mode) {
|
|
65
|
+
return chain.flatMap(directory => listDirectoryEnvFilePaths(directory, mode))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function collectPaths (paths, { required = false } = {}) {
|
|
69
|
+
const sources = await Promise.all(paths.map(path => readEnvFile(path, { required })))
|
|
70
|
+
|
|
71
|
+
return sources.filter(source => source !== null)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The env-files rung for one application, most specific first: its own chain layered over the
|
|
75
|
+
// chain of the file that decided the boot. For an application inside the project the two chains
|
|
76
|
+
// coincide and the second contributes nothing.
|
|
77
|
+
export async function resolveEnvFileSources ({
|
|
78
|
+
directory,
|
|
79
|
+
envRoot,
|
|
80
|
+
decidingDirectory,
|
|
81
|
+
decidingEnvRoot,
|
|
82
|
+
mode,
|
|
83
|
+
envfile,
|
|
84
|
+
customEnvFile
|
|
85
|
+
}) {
|
|
86
|
+
// --env replaces the entire rung, in both views and mode-exempt: no directory in any chain
|
|
87
|
+
// contributes. Defining it as merely the outermost layer would leave it overridden by any
|
|
88
|
+
// application's own .env, which is not what an escape hatch is.
|
|
89
|
+
if (customEnvFile) {
|
|
90
|
+
const path = isAbsolute(customEnvFile) ? customEnvFile : resolve(directory ?? decidingDirectory, customEnvFile)
|
|
91
|
+
|
|
92
|
+
return collectPaths([path], { required: true })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ownChain = directory ? resolveDirectoryChain(directory, envRoot ?? directory) : []
|
|
96
|
+
const decidingChain = decidingDirectory
|
|
97
|
+
? resolveDirectoryChain(decidingDirectory, decidingEnvRoot ?? decidingDirectory)
|
|
98
|
+
: []
|
|
99
|
+
|
|
100
|
+
const sources = []
|
|
101
|
+
|
|
102
|
+
if (envfile) {
|
|
103
|
+
// envfile is an opt-out of the convention that occupies the application's own-directory layer:
|
|
104
|
+
// none of the four mode-aware files are read for it, and the directories above are unaffected.
|
|
105
|
+
// It resolves app-relative and a missing one is a load error.
|
|
106
|
+
const path = isAbsolute(envfile) ? envfile : resolve(directory, envfile)
|
|
107
|
+
|
|
108
|
+
sources.push(...(await collectPaths([path], { required: true })))
|
|
109
|
+
sources.push(...(await collectPaths(listChainEnvFilePaths(ownChain.slice(1), mode))))
|
|
110
|
+
} else {
|
|
111
|
+
sources.push(...(await collectPaths(listChainEnvFilePaths(ownChain, mode))))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
sources.push(...(await collectPaths(listChainEnvFilePaths(decidingChain, mode))))
|
|
115
|
+
|
|
116
|
+
const seen = new Set()
|
|
117
|
+
|
|
118
|
+
return sources.filter(source => {
|
|
119
|
+
if (seen.has(source.path)) {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
seen.add(source.path)
|
|
124
|
+
return true
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolution is declarative: for each key, walk the ladder from the top and take the first source
|
|
129
|
+
// that defines it. There are no sequential apply-and-overwrite passes, so the ordering bugs they
|
|
130
|
+
// invite — an app env file clobbering a value an env block just set — are unrepresentable.
|
|
131
|
+
export function layerEnvironment (layers) {
|
|
132
|
+
const environment = {}
|
|
133
|
+
|
|
134
|
+
for (const layer of layers) {
|
|
135
|
+
if (!layer) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const key of Object.keys(layer)) {
|
|
140
|
+
const value = layer[key]
|
|
141
|
+
|
|
142
|
+
if (value === undefined || key in environment) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
environment[key] = typeof value === 'string' ? value : String(value)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return environment
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The bottom rung, and it belongs to both views. The test is non-empty rather than absent because
|
|
154
|
+
// that is what v3 tested: a production build running with NODE_ENV='' is read by every bundler in
|
|
155
|
+
// the ecosystem as "not production". This is the one place the ladder treats '' as missing.
|
|
156
|
+
export function applyNodeEnvDefault (environment, production) {
|
|
157
|
+
if (production && !environment.NODE_ENV) {
|
|
158
|
+
environment.NODE_ENV = 'production'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return environment
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The config-evaluation view: real environment > env files > NODE_ENV default. No env block
|
|
165
|
+
// appears at any position — a block configures the running application, not the reading of
|
|
166
|
+
// configuration.
|
|
167
|
+
export function resolveConfigurationEnvironment ({ realEnv = process.env, fileSources = [], production = false }) {
|
|
168
|
+
const environment = layerEnvironment([realEnv, ...fileSources.map(source => source.values)])
|
|
169
|
+
|
|
170
|
+
return applyNodeEnvDefault(environment, production)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The worker-runtime view. It differs from the one above by exactly the rungs that exist only once
|
|
174
|
+
// the runtime is running: the two env blocks and the injected PLT_<ID>_URL values.
|
|
175
|
+
export function resolveWorkerEnvironment ({
|
|
176
|
+
realEnv = process.env,
|
|
177
|
+
entryEnv,
|
|
178
|
+
rootEnv,
|
|
179
|
+
injectedUrls,
|
|
180
|
+
fileSources = [],
|
|
181
|
+
production = false
|
|
182
|
+
}) {
|
|
183
|
+
const environment = layerEnvironment([
|
|
184
|
+
realEnv,
|
|
185
|
+
entryEnv,
|
|
186
|
+
rootEnv,
|
|
187
|
+
injectedUrls,
|
|
188
|
+
...fileSources.map(source => source.values)
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
return applyNodeEnvDefault(environment, production)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Injection outranks env files, so a per-app eval worker must not read a stale PLT_<ID>_URL out of
|
|
195
|
+
// one and bake it into resolvedConfig, where injection can no longer reach it. The strip is scoped
|
|
196
|
+
// to names the runtime is going to supply itself: a key already in the real environment is one
|
|
197
|
+
// injection skips, so the worker genuinely uses the inherited value and it stays.
|
|
198
|
+
export function stripInjectedTopologyKeys (environment, injectedNames, realEnv = process.env) {
|
|
199
|
+
for (const name of injectedNames) {
|
|
200
|
+
if (name in realEnv) {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
delete environment[name]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return environment
|
|
208
|
+
}
|
package/lib/v4/errors.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import createError from '@fastify/error'
|
|
2
|
+
import { ERROR_PREFIX } from '../errors.js'
|
|
3
|
+
|
|
4
|
+
// The v4 loader carries its own error table rather than extending the v3 one: the v3
|
|
5
|
+
// configuration machinery moves out of foundation into wattpm-utils' migrate reader, and
|
|
6
|
+
// an error shared between the two would follow it.
|
|
7
|
+
|
|
8
|
+
export const AmbiguousConfigurationFileError = createError(
|
|
9
|
+
`${ERROR_PREFIX}_AMBIGUOUS_CONFIGURATION_FILE`,
|
|
10
|
+
'Multiple Watt configuration files found in %s: %s. Exactly one is allowed.'
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export const LegacyConfigurationFileError = createError(
|
|
14
|
+
`${ERROR_PREFIX}_LEGACY_CONFIGURATION_FILE`,
|
|
15
|
+
'%s is a v3-era configuration. Watt v4 uses watt.config.ts.\n Run: npx wattpm-utils@4 migrate'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
export const ConfigurationFileNotFoundError = createError(
|
|
19
|
+
`${ERROR_PREFIX}_CONFIGURATION_FILE_NOT_FOUND`,
|
|
20
|
+
'No Watt configuration file found in %s or its ancestors up to %s.'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
export const InvalidConfigurationExportError = createError(
|
|
24
|
+
`${ERROR_PREFIX}_INVALID_CONFIGURATION_EXPORT`,
|
|
25
|
+
'The default export of %s is not a configuration object (received %s).'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export const NestedFunctionExportError = createError(
|
|
29
|
+
`${ERROR_PREFIX}_NESTED_FUNCTION_EXPORT`,
|
|
30
|
+
'The default export of %s is a function that returned another function.'
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export const InvalidConfigValueError = createError(
|
|
34
|
+
`${ERROR_PREFIX}_INVALID_CONFIG_VALUE`,
|
|
35
|
+
'Invalid configuration value at %s: %s.'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export const DeferredSlotInApplicationDefinitionError = createError(
|
|
39
|
+
`${ERROR_PREFIX}_DEFERRED_SLOT_IN_APPLICATION_DEFINITION`,
|
|
40
|
+
'%s exports an application definition with a function at %s. Application definitions have no config slots; use the factory callback form instead.'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export const ApplicationShorthandConflictError = createError(
|
|
44
|
+
`${ERROR_PREFIX}_APPLICATION_SHORTHAND_CONFLICT`,
|
|
45
|
+
'%s declares the singular application shorthand alongside %s. The shorthand is only for genuinely single-application projects.'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
export const RootConfigurationInApplicationEntryError = createError(
|
|
49
|
+
`${ERROR_PREFIX}_ROOT_CONFIGURATION_IN_APPLICATION_ENTRY`,
|
|
50
|
+
'%s is the configuration of application %s but classifies as a root configuration. A root configuration cannot nest inside an application entry.'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export const ApplicationConfiguredTwiceError = createError(
|
|
54
|
+
`${ERROR_PREFIX}_APPLICATION_CONFIGURED_TWICE`,
|
|
55
|
+
'Application %s has an inline config in the root configuration and a configuration file at %s. Remove one of them.'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
export const ApplicationStartsNothingError = createError(
|
|
59
|
+
`${ERROR_PREFIX}_APPLICATION_STARTS_NOTHING`,
|
|
60
|
+
'Application %s would start nothing: %s does not serve without a listener under %s, and the application declares neither server.port nor application.commands.%s.'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
export const CapabilitySchemaNotFoundError = createError(
|
|
64
|
+
`${ERROR_PREFIX}_CAPABILITY_SCHEMA_NOT_FOUND`,
|
|
65
|
+
'Cannot import the schema of %s from %s, nor from the copy bundled with the runtime. A v4 capability exports one from its /schema subpath.'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
export const InvalidApplicationConfigurationError = createError(
|
|
69
|
+
`${ERROR_PREFIX}_INVALID_APPLICATION_CONFIGURATION`,
|
|
70
|
+
'The configuration of application %s does not validate against the %s schema:%s'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
export const CapabilityNotResolvableError = createError(
|
|
74
|
+
`${ERROR_PREFIX}_CAPABILITY_NOT_RESOLVABLE`,
|
|
75
|
+
'Cannot resolve %s from %s, nor from the copy bundled with the runtime.'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
export const CapabilityVersionSkewError = createError(
|
|
79
|
+
`${ERROR_PREFIX}_CAPABILITY_VERSION_SKEW`,
|
|
80
|
+
'%s'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
export const AmbiguousCapabilityError = createError(
|
|
84
|
+
`${ERROR_PREFIX}_AMBIGUOUS_CAPABILITY`,
|
|
85
|
+
'Application %s declares more than one capability dependency: %s. Add a configuration file naming the one it uses.'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
export const CapabilityNotDetectedError = createError(
|
|
89
|
+
`${ERROR_PREFIX}_CAPABILITY_NOT_DETECTED`,
|
|
90
|
+
'Cannot detect the capability of application %s: %s declares no capability dependency, no known framework and no JavaScript sources.'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
export const ObjectSourceRootRequiredError = createError(
|
|
94
|
+
`${ERROR_PREFIX}_OBJECT_SOURCE_ROOT_REQUIRED`,
|
|
95
|
+
'Provide the root argument when passing a configuration object: it stands in for the directory a configuration file would have been read from.'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
export const EnvFileOnInlineConfigError = createError(
|
|
99
|
+
`${ERROR_PREFIX}_ENV_FILE_ON_INLINE_CONFIG`,
|
|
100
|
+
'Application %s declares an envfile but carries an inline config, so no file is read for it and the envfile would govern the worker environment alone.'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
export const EnvFileOnDecidingDirectoryError = createError(
|
|
104
|
+
`${ERROR_PREFIX}_ENV_FILE_ON_DECIDING_DIRECTORY`,
|
|
105
|
+
'Application %s declares an envfile and its directory is the directory of %s. Applying it would mean reading the configuration in order to build the environment that produces the configuration.'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
export const EnvFileNotFoundError = createError(
|
|
109
|
+
`${ERROR_PREFIX}_ENV_FILE_NOT_FOUND`,
|
|
110
|
+
'The env file %s does not exist.'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
export const InvalidApplicationIdError = createError(
|
|
114
|
+
`${ERROR_PREFIX}_INVALID_APPLICATION_ID`,
|
|
115
|
+
'The application id %s (derived from %s) is not a valid DNS label, so it cannot be used as a mesh hostname. Set an explicit id on the entry.'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
export const InvalidRootConfigurationError = createError(
|
|
119
|
+
`${ERROR_PREFIX}_INVALID_ROOT_CONFIGURATION`,
|
|
120
|
+
'The configuration %s does not validate: %s'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
export const ConfigurationEvaluationTimeoutError = createError(
|
|
124
|
+
`${ERROR_PREFIX}_CONFIGURATION_EVALUATION_TIMEOUT`,
|
|
125
|
+
'Evaluating %s timed out after %dms.'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
export const EvaluationEndedWithoutResultError = createError(
|
|
129
|
+
`${ERROR_PREFIX}_EVALUATION_ENDED_WITHOUT_RESULT`,
|
|
130
|
+
'Evaluating %s ended without a result (worker exit code %d). A configuration that never resolves, or that calls process.exit, ends this way.'
|
|
131
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { registerHooks } from 'node:module'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { parentPort, workerData } from 'node:worker_threads'
|
|
4
|
+
import { ensureLoggableError } from '../errors.js'
|
|
5
|
+
import { evaluateConfiguration } from './pipeline.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The recorded import list has to survive a failed evaluation, which is exactly when it matters:
|
|
9
|
+
add an import of a helper to a config file, have the helper throw, and there is no valid result
|
|
10
|
+
for the list to ride back on. So the hook streams each resolved path as it records it rather than
|
|
11
|
+
accumulating one to post at the end — which is also what covers termination, where nothing can
|
|
12
|
+
be posted at all.
|
|
13
|
+
|
|
14
|
+
The synchronous API is deliberate: module.register's async variant does not intercept require(),
|
|
15
|
+
and a watt.config.js in a "type": "commonjs" package is CJS.
|
|
16
|
+
*/
|
|
17
|
+
registerHooks({
|
|
18
|
+
resolve (specifier, context, nextResolve) {
|
|
19
|
+
const result = nextResolve(specifier, context)
|
|
20
|
+
|
|
21
|
+
if (typeof result?.url === 'string' && result.url.startsWith('file:')) {
|
|
22
|
+
parentPort.postMessage({ type: 'import', path: fileURLToPath(result.url) })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { config, classification, resolveCandidates, warnings, mutatedEnvKeys } = await evaluateConfiguration({
|
|
31
|
+
...workerData,
|
|
32
|
+
// The worker's process.env is the layered view the main process resolved and handed over; it
|
|
33
|
+
// never inherits the loader's.
|
|
34
|
+
env: process.env,
|
|
35
|
+
onWatchFile (path) {
|
|
36
|
+
parentPort.postMessage({ type: 'watch', path })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
parentPort.postMessage({ type: 'result', config, classification, resolveCandidates, warnings, mutatedEnvKeys })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// Errors are posted as plain data rather than as Error instances: structured clone keeps name,
|
|
43
|
+
// message and stack but drops the code, which is the part every caller branches on.
|
|
44
|
+
const { message, code, stack, name } = ensureLoggableError(error)
|
|
45
|
+
|
|
46
|
+
parentPort.postMessage({ type: 'error', error: { message, code, stack, name } })
|
|
47
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { Worker } from 'node:worker_threads'
|
|
3
|
+
import { ensureError } from '../errors.js'
|
|
4
|
+
import { ConfigurationEvaluationTimeoutError, EvaluationEndedWithoutResultError } from './errors.js'
|
|
5
|
+
import { evaluateConfiguration } from './pipeline.js'
|
|
6
|
+
|
|
7
|
+
export const defaultEvaluationTimeout = 30000
|
|
8
|
+
|
|
9
|
+
const workerPath = new URL('./eval-worker.js', import.meta.url)
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
All configuration is evaluated in short-lived evaluation worker threads — one for the root
|
|
13
|
+
config, then one per per-app config file, run in parallel.
|
|
14
|
+
|
|
15
|
+
A throwaway worker rather than a plain import() in the main process because the ESM module cache
|
|
16
|
+
is not invalidatable, so a same-process re-import would silently return stale config on every dev
|
|
17
|
+
reload — and the recorded import list is what lets the watcher cover helper files, not just the
|
|
18
|
+
config file itself. It also isolates env mutation and config crashes and hangs from the loader.
|
|
19
|
+
|
|
20
|
+
The workers isolate module caches, environments, crashes and hangs. They are not a sandbox: a
|
|
21
|
+
config file runs with the runtime's privileges, exactly as in v3, where an application's config
|
|
22
|
+
selected a module the worker then imported and executed.
|
|
23
|
+
*/
|
|
24
|
+
export async function evaluateConfigurationFile ({
|
|
25
|
+
path,
|
|
26
|
+
directory,
|
|
27
|
+
role = 'root',
|
|
28
|
+
applicationId,
|
|
29
|
+
env,
|
|
30
|
+
command,
|
|
31
|
+
mode,
|
|
32
|
+
production,
|
|
33
|
+
schema,
|
|
34
|
+
timeout = defaultEvaluationTimeout,
|
|
35
|
+
onImport,
|
|
36
|
+
onWatchFile
|
|
37
|
+
} = {}) {
|
|
38
|
+
const importedFiles = new Set()
|
|
39
|
+
const watchedFiles = new Set()
|
|
40
|
+
|
|
41
|
+
const worker = new Worker(workerPath, {
|
|
42
|
+
// Every eval worker is constructed with an explicit env — the computed layered view — never by
|
|
43
|
+
// inheriting the main process's process.env, so a mutated parent environment can never leak in
|
|
44
|
+
// as apparent real-environment keys. No env windows, no apply/restore choreography.
|
|
45
|
+
env: env ?? {},
|
|
46
|
+
workerData: {
|
|
47
|
+
path,
|
|
48
|
+
directory: directory ?? dirname(path),
|
|
49
|
+
role,
|
|
50
|
+
applicationId,
|
|
51
|
+
command,
|
|
52
|
+
mode,
|
|
53
|
+
production,
|
|
54
|
+
schema
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
let settle
|
|
59
|
+
const settled = new Promise(resolve => {
|
|
60
|
+
settle = resolve
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
let outcome = null
|
|
64
|
+
let timer = null
|
|
65
|
+
|
|
66
|
+
worker.on('message', message => {
|
|
67
|
+
switch (message.type) {
|
|
68
|
+
case 'import':
|
|
69
|
+
if (!importedFiles.has(message.path)) {
|
|
70
|
+
importedFiles.add(message.path)
|
|
71
|
+
onImport?.(message.path)
|
|
72
|
+
}
|
|
73
|
+
break
|
|
74
|
+
case 'watch':
|
|
75
|
+
if (!watchedFiles.has(message.path)) {
|
|
76
|
+
watchedFiles.add(message.path)
|
|
77
|
+
onWatchFile?.(message.path)
|
|
78
|
+
}
|
|
79
|
+
break
|
|
80
|
+
case 'result':
|
|
81
|
+
outcome = { ok: true, value: message }
|
|
82
|
+
settle()
|
|
83
|
+
break
|
|
84
|
+
case 'error':
|
|
85
|
+
outcome = { ok: false, error: ensureError(message.error) }
|
|
86
|
+
settle()
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
worker.on('error', error => {
|
|
92
|
+
outcome ??= { ok: false, error }
|
|
93
|
+
settle()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
worker.on('exit', code => {
|
|
97
|
+
// A worker that dies without posting anything still has to settle, and with the paths it did
|
|
98
|
+
// report. The deadline does not cover this: a configuration that awaits a promise nothing will
|
|
99
|
+
// ever settle leaves an empty event loop, so Node exits the thread at once rather than hanging
|
|
100
|
+
// — which is the deadlock the timer is too late to see, not a case it handles.
|
|
101
|
+
outcome ??= { ok: false, error: new EvaluationEndedWithoutResultError(path, code) }
|
|
102
|
+
settle()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
if (timeout > 0) {
|
|
106
|
+
// A config that never resolves — an awaited fetch to a dead host, a forgotten promise —
|
|
107
|
+
// terminates the worker and fails the load with a targeted error instead of hanging boot.
|
|
108
|
+
// Streaming is what makes the imports recorded up to this point survive the termination.
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
outcome ??= { ok: false, error: new ConfigurationEvaluationTimeoutError(path, timeout) }
|
|
111
|
+
worker.terminate()
|
|
112
|
+
settle()
|
|
113
|
+
}, timeout)
|
|
114
|
+
|
|
115
|
+
timer.unref?.()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await settled
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
await worker.terminate()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const collected = { importedFiles: [...importedFiles], watchedFiles: [...watchedFiles] }
|
|
126
|
+
|
|
127
|
+
if (!outcome.ok) {
|
|
128
|
+
// The paths ride back on the failure too: a watcher holding only the last good set is not
|
|
129
|
+
// watching the helper that just threw, so fixing it would trigger no reload and wattpm dev
|
|
130
|
+
// would look hung on a file the user is actively editing.
|
|
131
|
+
Object.assign(outcome.error, collected)
|
|
132
|
+
throw outcome.error
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { config, classification, resolveCandidates, warnings, mutatedEnvKeys } = outcome.value
|
|
136
|
+
|
|
137
|
+
return { config, classification, resolveCandidates, warnings, mutatedEnvKeys, ...collected }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installEnvironment (view) {
|
|
141
|
+
const previous = { ...process.env }
|
|
142
|
+
|
|
143
|
+
for (const key of Object.keys(process.env)) {
|
|
144
|
+
delete process.env[key]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Object.assign(process.env, view)
|
|
148
|
+
|
|
149
|
+
return function restore () {
|
|
150
|
+
for (const key of Object.keys(process.env)) {
|
|
151
|
+
delete process.env[key]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Object.assign(process.env, previous)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
Breakpoint debugging gets an explicit escape hatch, since a throwaway thread dies before an
|
|
160
|
+
inspector can attach: with --inspect-brk, evaluation runs in-process and is therefore restricted
|
|
161
|
+
to one config file, precisely because one process has one module cache, in which only a single
|
|
162
|
+
file's env view can be correct. The other files still evaluate in their workers, and cannot be
|
|
163
|
+
contaminated by this one regardless of ordering — the main process constructs each worker with an
|
|
164
|
+
explicit env, and workers never inherit process.env.
|
|
165
|
+
|
|
166
|
+
process.env is installed and restored around the call, so the "does not propagate" statement
|
|
167
|
+
stays true in debug mode. The evaluation deadline is not applied: a paused breakpoint session
|
|
168
|
+
must not be killed by the 30 s timer.
|
|
169
|
+
*/
|
|
170
|
+
export async function evaluateConfigurationInProcess ({ env, ...options } = {}) {
|
|
171
|
+
const restore = installEnvironment(env ?? {})
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const result = await evaluateConfiguration({ ...options, env: process.env })
|
|
175
|
+
|
|
176
|
+
return { ...result, importedFiles: [], watchedFiles: [] }
|
|
177
|
+
} finally {
|
|
178
|
+
restore()
|
|
179
|
+
}
|
|
180
|
+
}
|