@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
package/lib/v4/load.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
2
|
+
import { createConfigurationContext, defaultMode, isProductionCommand } from './context.js'
|
|
3
|
+
import { detectCapability } from './detect.js'
|
|
4
|
+
import {
|
|
5
|
+
listChainEnvFilePaths,
|
|
6
|
+
resolveConfigurationEnvironment,
|
|
7
|
+
resolveDirectoryChain,
|
|
8
|
+
resolveEnvFileSources,
|
|
9
|
+
resolveWorkerEnvironment,
|
|
10
|
+
stripInjectedTopologyKeys
|
|
11
|
+
} from './env.js'
|
|
12
|
+
import {
|
|
13
|
+
ApplicationConfiguredTwiceError,
|
|
14
|
+
CapabilityVersionSkewError,
|
|
15
|
+
EnvFileOnDecidingDirectoryError,
|
|
16
|
+
EnvFileOnInlineConfigError,
|
|
17
|
+
InvalidApplicationIdError,
|
|
18
|
+
ObjectSourceRootRequiredError
|
|
19
|
+
} from './errors.js'
|
|
20
|
+
import { defaultEvaluationTimeout, evaluateConfigurationFile } from './evaluate.js'
|
|
21
|
+
import { configurationFileNames } from './filenames.js'
|
|
22
|
+
import {
|
|
23
|
+
assertValidApplicationId,
|
|
24
|
+
deriveApplicationId,
|
|
25
|
+
findTopologyVariableCollisions,
|
|
26
|
+
getApplicationUrl,
|
|
27
|
+
topologyVariableName
|
|
28
|
+
} from './identifiers.js'
|
|
29
|
+
import { checkCapabilityVersionSkew } from './capability-resolution.js'
|
|
30
|
+
import { runRootPipeline } from './pipeline.js'
|
|
31
|
+
import { assertApplicationServes } from './serving.js'
|
|
32
|
+
import { importCapabilitySchema, validateCapabilityConfiguration } from './validate.js'
|
|
33
|
+
import {
|
|
34
|
+
findAncestorConfiguration,
|
|
35
|
+
findAncestorConfigurationOfAnyKind,
|
|
36
|
+
findApplicationConfigurationFile,
|
|
37
|
+
findDecidingFile,
|
|
38
|
+
findEnvRoot,
|
|
39
|
+
listAncestorCandidatePaths,
|
|
40
|
+
resolveNamedConfigurationFile
|
|
41
|
+
} from './scope.js'
|
|
42
|
+
import { readPackageName } from './topology.js'
|
|
43
|
+
|
|
44
|
+
export const zeroConfigDefaultPort = 3042
|
|
45
|
+
|
|
46
|
+
function resolveEntryDirectory (entry, decidingDirectory) {
|
|
47
|
+
const path = entry.path ?? decidingDirectory
|
|
48
|
+
|
|
49
|
+
return isAbsolute(path) ? path : resolve(decidingDirectory, path)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
The watcher consumes a filtered import list plus everything else a reload depends on that is not
|
|
54
|
+
an import. Imports alone are not the input set: the topology is derived from the filesystem, so
|
|
55
|
+
creating an env file, a config candidate or an autoload directory each change the answer without
|
|
56
|
+
any import changing. Every path is watched for creation and deletion, not only modification,
|
|
57
|
+
which is why most of these do not exist yet.
|
|
58
|
+
|
|
59
|
+
The one filter is node_modules, and it is not a project boundary: an application at
|
|
60
|
+
path: '../shared/api' is a supported layout and its config file is in the set. A project-local
|
|
61
|
+
filter would drop exactly the external application config this promises to watch.
|
|
62
|
+
*/
|
|
63
|
+
function createReport ({ onImport, onWatchFile, onWarning, onInfo }) {
|
|
64
|
+
const importedFiles = new Set()
|
|
65
|
+
const watchedFiles = new Set()
|
|
66
|
+
const watchFiles = new Set()
|
|
67
|
+
const watchDirectories = new Set()
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
importedFiles,
|
|
71
|
+
watchedFiles,
|
|
72
|
+
watchFiles,
|
|
73
|
+
watchDirectories,
|
|
74
|
+
onWarning,
|
|
75
|
+
onInfo,
|
|
76
|
+
onImport (path) {
|
|
77
|
+
if (!importedFiles.has(path)) {
|
|
78
|
+
importedFiles.add(path)
|
|
79
|
+
onImport?.(path)
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
onWatchFile (path) {
|
|
83
|
+
if (!watchedFiles.has(path)) {
|
|
84
|
+
watchedFiles.add(path)
|
|
85
|
+
onWatchFile?.(path)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
watch (...paths) {
|
|
89
|
+
for (const path of paths) {
|
|
90
|
+
if (path) {
|
|
91
|
+
watchFiles.add(path)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
watchDirectory (path) {
|
|
96
|
+
watchDirectories.add(path)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const nodeModulesPattern = /[\\/]node_modules[\\/]/
|
|
102
|
+
|
|
103
|
+
function collectWatchTargets (report) {
|
|
104
|
+
const files = new Set(report.watchFiles)
|
|
105
|
+
|
|
106
|
+
for (const path of report.watchedFiles) {
|
|
107
|
+
files.add(path)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const path of report.importedFiles) {
|
|
111
|
+
// Watt itself, capability packages and their transitive dependencies are recorded and never
|
|
112
|
+
// watched, so dependency churn cannot trigger reloads or exhaust watcher limits.
|
|
113
|
+
if (!nodeModulesPattern.test(path)) {
|
|
114
|
+
files.add(path)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { files: [...files].sort(), directories: [...report.watchDirectories].sort() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function reportMutatedEnv (report, source, keys) {
|
|
122
|
+
if (keys.length === 0) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Keys only: a snapshot diff cannot attribute a write to a module or a line, and the diagnostics
|
|
127
|
+
// must not claim otherwise.
|
|
128
|
+
report.onWarning?.({
|
|
129
|
+
type: 'mutated-env',
|
|
130
|
+
source,
|
|
131
|
+
keys,
|
|
132
|
+
message: `configuration evaluation mutated process.env; these keys do NOT propagate to applications: ${keys.join(', ')}. Use: defineConfig({ env: { … } })`
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Both the config-evaluation environment and the enumerable env-file set for one directory come
|
|
137
|
+
// from the same two chains, so they are resolved together: the watcher must cover a rung that does
|
|
138
|
+
// not exist yet, and a set built from what exists cannot see one appear.
|
|
139
|
+
async function resolveEnvironmentFor (
|
|
140
|
+
{ directory, envRoot, decidingDirectory, decidingEnvRoot, mode, envfile, customEnvFile, realEnv, production },
|
|
141
|
+
report
|
|
142
|
+
) {
|
|
143
|
+
if (customEnvFile) {
|
|
144
|
+
report.watch(isAbsolute(customEnvFile) ? customEnvFile : resolve(directory, customEnvFile))
|
|
145
|
+
} else {
|
|
146
|
+
const ownChain = resolveDirectoryChain(directory, envRoot)
|
|
147
|
+
const decidingChain = resolveDirectoryChain(decidingDirectory, decidingEnvRoot)
|
|
148
|
+
|
|
149
|
+
report.watch(...listChainEnvFilePaths(envfile ? ownChain.slice(1) : ownChain, mode))
|
|
150
|
+
report.watch(...listChainEnvFilePaths(decidingChain, mode))
|
|
151
|
+
|
|
152
|
+
if (envfile) {
|
|
153
|
+
report.watch(isAbsolute(envfile) ? envfile : resolve(directory, envfile))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const fileSources = await resolveEnvFileSources({
|
|
158
|
+
directory,
|
|
159
|
+
envRoot,
|
|
160
|
+
decidingDirectory,
|
|
161
|
+
decidingEnvRoot,
|
|
162
|
+
mode,
|
|
163
|
+
envfile,
|
|
164
|
+
customEnvFile
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Both views come from this one set. They differ only by the rungs that exist once the runtime
|
|
168
|
+
// is running — the two env blocks and the injected PLT_<ID>_URL values — which is what makes
|
|
169
|
+
// "one implementation of the ladder" true rather than asserted.
|
|
170
|
+
return { fileSources, env: resolveConfigurationEnvironment({ realEnv, fileSources, production }) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
The main-side driver. It resolves the environment for every worker — workers never read env files
|
|
175
|
+
themselves — spawns the root eval worker, then fans out one worker per per-app config file in
|
|
176
|
+
parallel. Evaluation is phased by necessity: the fan-out cannot exist before the root export has
|
|
177
|
+
been evaluated and autoload expanded, and everything discovered then runs concurrently.
|
|
178
|
+
*/
|
|
179
|
+
export async function loadConfiguration ({
|
|
180
|
+
cwd = process.cwd(),
|
|
181
|
+
configPath,
|
|
182
|
+
command = 'start',
|
|
183
|
+
mode,
|
|
184
|
+
production,
|
|
185
|
+
customEnvFile,
|
|
186
|
+
realEnv = process.env,
|
|
187
|
+
schema,
|
|
188
|
+
validateCapabilities = true,
|
|
189
|
+
runtimeScope,
|
|
190
|
+
timeout = defaultEvaluationTimeout,
|
|
191
|
+
onImport,
|
|
192
|
+
onWatchFile,
|
|
193
|
+
onWarning,
|
|
194
|
+
onInfo
|
|
195
|
+
} = {}) {
|
|
196
|
+
const resolvedProduction = production ?? isProductionCommand(command)
|
|
197
|
+
const resolvedMode = mode ?? defaultMode(command, resolvedProduction)
|
|
198
|
+
const report = createReport({ onImport, onWatchFile, onWarning, onInfo })
|
|
199
|
+
const shared = {
|
|
200
|
+
command,
|
|
201
|
+
mode: resolvedMode,
|
|
202
|
+
production: resolvedProduction,
|
|
203
|
+
customEnvFile,
|
|
204
|
+
realEnv,
|
|
205
|
+
timeout,
|
|
206
|
+
validateCapabilities,
|
|
207
|
+
runtimeScope
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --config is not a scope flag, but it does take cwd out of the decision.
|
|
211
|
+
const deciding = configPath
|
|
212
|
+
? await resolveNamedConfigurationFile(configPath, cwd)
|
|
213
|
+
: await findDecidingFile(cwd, { throwOnMissing: false })
|
|
214
|
+
|
|
215
|
+
// The recognized candidate paths across the whole ancestor horizon are watched because the scan
|
|
216
|
+
// selects the env root: creating ../watt.config.ts moves it outward and makes ../.env live, and
|
|
217
|
+
// neither path is in the active env-file set beforehand — that set is the consequence.
|
|
218
|
+
report.watch(...listAncestorCandidatePaths(deciding?.directory ?? cwd))
|
|
219
|
+
|
|
220
|
+
if (!deciding) {
|
|
221
|
+
return synthesizeConfiguration({ cwd, schema, report, ...shared })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
report.watch(deciding.path)
|
|
225
|
+
|
|
226
|
+
const decidingEnvRoot = await findEnvRoot(deciding.directory)
|
|
227
|
+
const { env: rootEnv, fileSources: rootEnvFileSources } = await resolveEnvironmentFor(
|
|
228
|
+
{
|
|
229
|
+
directory: deciding.directory,
|
|
230
|
+
envRoot: decidingEnvRoot,
|
|
231
|
+
decidingDirectory: deciding.directory,
|
|
232
|
+
decidingEnvRoot,
|
|
233
|
+
...shared
|
|
234
|
+
},
|
|
235
|
+
report
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const root = await evaluateConfigurationFile({
|
|
239
|
+
path: deciding.path,
|
|
240
|
+
directory: deciding.directory,
|
|
241
|
+
role: 'root',
|
|
242
|
+
env: rootEnv,
|
|
243
|
+
command,
|
|
244
|
+
mode: resolvedMode,
|
|
245
|
+
production: resolvedProduction,
|
|
246
|
+
schema,
|
|
247
|
+
timeout,
|
|
248
|
+
onImport: report.onImport,
|
|
249
|
+
onWatchFile: report.onWatchFile
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
for (const warning of root.warnings) {
|
|
253
|
+
report.onWarning?.(warning)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
reportMutatedEnv(report, deciding.path, root.mutatedEnvKeys)
|
|
257
|
+
|
|
258
|
+
const standalone = root.classification === 'application'
|
|
259
|
+
|
|
260
|
+
if (standalone) {
|
|
261
|
+
// Both conditions earn their place. Without the app-def half, a nested root config would tell
|
|
262
|
+
// the user the mesh is unavailable while a full runtime with a working mesh boots. Without the
|
|
263
|
+
// ancestor half, the canonical single-app project would print it on every boot.
|
|
264
|
+
const ancestor = await findAncestorConfiguration(deciding.directory)
|
|
265
|
+
|
|
266
|
+
if (ancestor) {
|
|
267
|
+
report.onWarning?.({
|
|
268
|
+
type: 'standalone-boot',
|
|
269
|
+
ancestor,
|
|
270
|
+
message: `booting standalone — sibling applications and http://*.plt.local are unavailable. Nothing the configuration in ${ancestor} says is applied: neither its own settings (logger, telemetry, the env blocks, envfile) nor this application's entry (workers, health, dependencies, enabled). Its own server settings are unchanged: it listens exactly as it does under the full runtime.`
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return assemble({
|
|
276
|
+
result: { ...root, rootEnvFileSources },
|
|
277
|
+
deciding,
|
|
278
|
+
decidingEnvRoot,
|
|
279
|
+
rootEnv,
|
|
280
|
+
standalone,
|
|
281
|
+
report,
|
|
282
|
+
...shared
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/*
|
|
287
|
+
Object config sources skip the root eval worker. The programmatic API and the zero-config
|
|
288
|
+
synthesis pass an object, not a file: for those the root pipeline runs main-side with no import
|
|
289
|
+
step, and the environment is built without mutating the main process's process.env.
|
|
290
|
+
|
|
291
|
+
The root argument stands in for the deciding file's directory — there is no config file to take a
|
|
292
|
+
dirname of. Where the walk floors differs between the two sources, which is why the caller
|
|
293
|
+
supplies envRoot: an embedder saying create('/app', …) declared its root and does not mean "and
|
|
294
|
+
also whatever .env sits above /app", while synthesis, where nobody declared anything, resolves
|
|
295
|
+
its env root the same way a config file would.
|
|
296
|
+
*/
|
|
297
|
+
export async function loadObjectConfiguration ({
|
|
298
|
+
root,
|
|
299
|
+
source,
|
|
300
|
+
envRoot,
|
|
301
|
+
command = 'start',
|
|
302
|
+
mode,
|
|
303
|
+
production,
|
|
304
|
+
customEnvFile,
|
|
305
|
+
realEnv = process.env,
|
|
306
|
+
schema,
|
|
307
|
+
validateCapabilities = true,
|
|
308
|
+
runtimeScope,
|
|
309
|
+
timeout = defaultEvaluationTimeout,
|
|
310
|
+
onImport,
|
|
311
|
+
onWatchFile,
|
|
312
|
+
onWarning,
|
|
313
|
+
onInfo,
|
|
314
|
+
report,
|
|
315
|
+
synthesized = false,
|
|
316
|
+
standalone
|
|
317
|
+
} = {}) {
|
|
318
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
319
|
+
throw new ObjectSourceRootRequiredError()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const resolvedProduction = production ?? isProductionCommand(command)
|
|
323
|
+
const resolvedMode = mode ?? defaultMode(command, resolvedProduction)
|
|
324
|
+
const shared = {
|
|
325
|
+
command,
|
|
326
|
+
mode: resolvedMode,
|
|
327
|
+
production: resolvedProduction,
|
|
328
|
+
customEnvFile,
|
|
329
|
+
realEnv,
|
|
330
|
+
timeout,
|
|
331
|
+
validateCapabilities,
|
|
332
|
+
runtimeScope
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
report ??= createReport({ onImport, onWatchFile, onWarning, onInfo })
|
|
336
|
+
|
|
337
|
+
const deciding = { path: null, directory: root, stopDirectory: root }
|
|
338
|
+
const decidingEnvRoot = envRoot ?? root
|
|
339
|
+
const { env: rootEnv, fileSources: rootEnvFileSources } = await resolveEnvironmentFor(
|
|
340
|
+
{ directory: root, envRoot: decidingEnvRoot, decidingDirectory: root, decidingEnvRoot, ...shared },
|
|
341
|
+
report
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
const context = createConfigurationContext({
|
|
345
|
+
command,
|
|
346
|
+
mode: resolvedMode,
|
|
347
|
+
production: resolvedProduction,
|
|
348
|
+
env: rootEnv,
|
|
349
|
+
root
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Canonicalized in the same position the root worker uses — before autoload expansion or any
|
|
353
|
+
// other read of the object's shape. An embedder can hand create() an object carrying getters or
|
|
354
|
+
// a Proxy just as easily as a config file can build one.
|
|
355
|
+
const evaluated = await runRootPipeline(source, {
|
|
356
|
+
path: root,
|
|
357
|
+
directory: root,
|
|
358
|
+
schema,
|
|
359
|
+
production: resolvedProduction,
|
|
360
|
+
env: rootEnv,
|
|
361
|
+
context,
|
|
362
|
+
deferred: 'reject'
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
for (const warning of evaluated.warnings) {
|
|
366
|
+
report.onWarning?.(warning)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return assemble({
|
|
370
|
+
result: { ...evaluated, importedFiles: [], watchedFiles: [], rootEnvFileSources },
|
|
371
|
+
deciding,
|
|
372
|
+
decidingEnvRoot,
|
|
373
|
+
rootEnv,
|
|
374
|
+
// standalone means no root orchestration was read. An app-def export satisfies it because the
|
|
375
|
+
// root config, if any, was never evaluated; synthesis satisfies it because there was nothing
|
|
376
|
+
// to evaluate at all — which is why it says so rather than being inferred from a shape it
|
|
377
|
+
// does not have, the synthesized source being written in the singular root form.
|
|
378
|
+
standalone: standalone ?? evaluated.classification === 'application',
|
|
379
|
+
synthesized,
|
|
380
|
+
report,
|
|
381
|
+
...shared
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
Level 0. The synthesized configuration supplies a port of Number(env.PORT || 3042), where env is
|
|
387
|
+
the map already resolved for that directory: with the entrypoint gone a framework application
|
|
388
|
+
carrying no server.port would start nothing. Reading the ambient process.env instead would ignore
|
|
389
|
+
a PORT=4000 sitting in the project's own .env — the one file a zero-config user is most likely to
|
|
390
|
+
have written — because synthesis runs main-side and does not mutate process.env.
|
|
391
|
+
*/
|
|
392
|
+
async function synthesizeConfiguration ({ cwd, schema, report, ...shared }) {
|
|
393
|
+
// Nobody declared a root here, so the env root is resolved the same way a config file's would
|
|
394
|
+
// be, flooring at the directory itself. Without that, running in web/api of a monorepo would
|
|
395
|
+
// synthesize an application that cannot see the root .env.
|
|
396
|
+
const envRoot = await findEnvRoot(cwd)
|
|
397
|
+
const { env } = await resolveEnvironmentFor(
|
|
398
|
+
{ directory: cwd, envRoot, decidingDirectory: cwd, decidingEnvRoot: envRoot, ...shared },
|
|
399
|
+
report
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
// Never refused on account of a configuration above: refusing would mean deciding that an
|
|
403
|
+
// ancestor config describes this directory, which a filename check cannot establish and an
|
|
404
|
+
// evaluation could only establish by executing a file above the search's stop point.
|
|
405
|
+
const ancestor = await findAncestorConfigurationOfAnyKind(cwd)
|
|
406
|
+
|
|
407
|
+
if (ancestor) {
|
|
408
|
+
report.onWarning?.({
|
|
409
|
+
type: 'synthesized-under-ancestor',
|
|
410
|
+
ancestor: ancestor.path,
|
|
411
|
+
legacy: ancestor.legacy,
|
|
412
|
+
message: ancestor.legacy
|
|
413
|
+
? `${cwd} has no watt.config.* of its own and is booting with inferred defaults. A v3 configuration exists at ${ancestor.path}, which this version cannot read. Run npx wattpm-utils@4 migrate there, then run wattpm from that directory.`
|
|
414
|
+
: `${cwd} has no watt.config.* of its own and is booting with inferred defaults. A Watt configuration exists at ${ancestor.path}; if it describes this application, none of what it says — workers, health, env, telemetry, and the port it assigns — is applied here. Run wattpm there to start it with the runtime, or add a watt.config.ts here to configure it standalone.`
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const { capability } = await detectCapability(cwd)
|
|
419
|
+
|
|
420
|
+
report.onInfo?.({
|
|
421
|
+
type: 'synthesized-configuration',
|
|
422
|
+
capability,
|
|
423
|
+
message: `no configuration file found; booting ${cwd} as ${capability} with inferred defaults`
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
// The convention lives in configuration rather than becoming a hidden loader default: synthesis
|
|
427
|
+
// simply is the configuration for a zero-config boot. It applies only to a single-application
|
|
428
|
+
// project, which is the only shape zero-config can produce.
|
|
429
|
+
const source = {
|
|
430
|
+
application: {
|
|
431
|
+
config: {
|
|
432
|
+
module: capability,
|
|
433
|
+
server: { port: Number(env.PORT || zeroConfigDefaultPort) }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return loadObjectConfiguration({
|
|
439
|
+
root: cwd,
|
|
440
|
+
source,
|
|
441
|
+
envRoot,
|
|
442
|
+
schema,
|
|
443
|
+
report,
|
|
444
|
+
synthesized: true,
|
|
445
|
+
standalone: true,
|
|
446
|
+
...shared
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function assemble ({
|
|
451
|
+
result,
|
|
452
|
+
deciding,
|
|
453
|
+
decidingEnvRoot,
|
|
454
|
+
rootEnv,
|
|
455
|
+
standalone,
|
|
456
|
+
synthesized = false,
|
|
457
|
+
report,
|
|
458
|
+
command,
|
|
459
|
+
mode,
|
|
460
|
+
production,
|
|
461
|
+
customEnvFile,
|
|
462
|
+
realEnv,
|
|
463
|
+
timeout,
|
|
464
|
+
validateCapabilities,
|
|
465
|
+
runtimeScope
|
|
466
|
+
}) {
|
|
467
|
+
const config = result.config
|
|
468
|
+
|
|
469
|
+
if (config.autoload?.path) {
|
|
470
|
+
// Creating or removing an application directory changes the application list, so the directory
|
|
471
|
+
// itself is watched for membership rather than only its current members.
|
|
472
|
+
const path = isAbsolute(config.autoload.path)
|
|
473
|
+
? config.autoload.path
|
|
474
|
+
: resolve(deciding.directory, config.autoload.path)
|
|
475
|
+
|
|
476
|
+
report.watchDirectory(path)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
config.applications = await prepareApplications({
|
|
480
|
+
entries: config.applications ?? [],
|
|
481
|
+
deciding,
|
|
482
|
+
decidingEnvRoot,
|
|
483
|
+
command,
|
|
484
|
+
mode,
|
|
485
|
+
production,
|
|
486
|
+
customEnvFile,
|
|
487
|
+
realEnv,
|
|
488
|
+
timeout,
|
|
489
|
+
validateCapabilities,
|
|
490
|
+
runtimeScope,
|
|
491
|
+
report
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
applyWorkerEnvironments(config.applications, { rootEnv: config.env, realEnv, production })
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
config,
|
|
498
|
+
configPath: deciding.path,
|
|
499
|
+
root: deciding.directory,
|
|
500
|
+
envRoot: decidingEnvRoot,
|
|
501
|
+
standalone,
|
|
502
|
+
synthesized,
|
|
503
|
+
mode,
|
|
504
|
+
production,
|
|
505
|
+
resolveCandidates: result.resolveCandidates,
|
|
506
|
+
envFileSources: result.rootEnvFileSources ?? [],
|
|
507
|
+
importedFiles: [...report.importedFiles],
|
|
508
|
+
watchedFiles: [...report.watchedFiles],
|
|
509
|
+
watchTargets: collectWatchTargets(report),
|
|
510
|
+
context: createConfigurationContext({ command, mode, production, env: rootEnv, root: deciding.directory })
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/*
|
|
515
|
+
The worker-runtime view, resolved main-side for every application before any worker starts —
|
|
516
|
+
workers never read env files themselves. It is the config-evaluation view plus exactly the rungs
|
|
517
|
+
that exist only once the runtime is running: the entry env block, the root env block, and the
|
|
518
|
+
injected topology URLs.
|
|
519
|
+
|
|
520
|
+
Injection covers every application including the application's own PLT_<SELF>_URL, and it is
|
|
521
|
+
skipped for a key the runtime already has in its own real environment, so a container or k8s
|
|
522
|
+
override wins — the runtime's process.env is the oracle.
|
|
523
|
+
*/
|
|
524
|
+
export function applyWorkerEnvironments (
|
|
525
|
+
applications,
|
|
526
|
+
{ rootEnv, realEnv = process.env, production, additionalIds = [] }
|
|
527
|
+
) {
|
|
528
|
+
const injectedUrls = {}
|
|
529
|
+
|
|
530
|
+
// additionalIds carries the applications already running when this set was added: they are not
|
|
531
|
+
// re-enveloped here, but the new application still has to be able to address them.
|
|
532
|
+
for (const id of [...additionalIds, ...applications.map(entry => entry.id)]) {
|
|
533
|
+
const key = topologyVariableName(id)
|
|
534
|
+
|
|
535
|
+
if (!(key in realEnv)) {
|
|
536
|
+
injectedUrls[key] = getApplicationUrl(id)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
for (const entry of applications) {
|
|
541
|
+
const workerEnv = resolveWorkerEnvironment({
|
|
542
|
+
realEnv,
|
|
543
|
+
entryEnv: entry.env,
|
|
544
|
+
rootEnv,
|
|
545
|
+
injectedUrls,
|
|
546
|
+
fileSources: entry.envFileSources ?? [],
|
|
547
|
+
production
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
Object.defineProperty(entry, 'workerEnv', { value: workerEnv, enumerable: false })
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return applications
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/*
|
|
557
|
+
Applications added while the runtime is running -- POST /applications and the management ITC
|
|
558
|
+
handler -- have to be evaluated the way boot evaluates them. An entry that skips this arrives
|
|
559
|
+
without resolvedConfig, and the worker then falls back to discovering a configuration file by the
|
|
560
|
+
v3 names, which v4 does not write: the application fails to initialize rather than being told
|
|
561
|
+
what is wrong.
|
|
562
|
+
|
|
563
|
+
The environment is resolved from disk again rather than reused from boot. The ladder is a
|
|
564
|
+
function of the files that are there now, and a runtime that has been up for a week should see
|
|
565
|
+
the .env it has today, not the one it started with.
|
|
566
|
+
|
|
567
|
+
rootEnvBlock is the root configuration's own env block, which the caller already holds -- passing
|
|
568
|
+
it avoids re-evaluating the root file for a value that cannot have changed without a restart.
|
|
569
|
+
*/
|
|
570
|
+
export async function loadAdditionalApplications ({
|
|
571
|
+
configPath,
|
|
572
|
+
entries,
|
|
573
|
+
existingIds = [],
|
|
574
|
+
rootEnvBlock,
|
|
575
|
+
command = 'start',
|
|
576
|
+
mode,
|
|
577
|
+
production,
|
|
578
|
+
customEnvFile,
|
|
579
|
+
realEnv = process.env,
|
|
580
|
+
timeout = defaultEvaluationTimeout,
|
|
581
|
+
validateCapabilities = true,
|
|
582
|
+
runtimeScope,
|
|
583
|
+
onImport,
|
|
584
|
+
onWatchFile,
|
|
585
|
+
onWarning,
|
|
586
|
+
onInfo
|
|
587
|
+
} = {}) {
|
|
588
|
+
const resolvedProduction = production ?? isProductionCommand(command)
|
|
589
|
+
const resolvedMode = mode ?? defaultMode(command, resolvedProduction)
|
|
590
|
+
const report = createReport({ onImport, onWatchFile, onWarning, onInfo })
|
|
591
|
+
const deciding = { path: configPath, directory: dirname(configPath) }
|
|
592
|
+
|
|
593
|
+
const applications = await prepareApplications({
|
|
594
|
+
entries,
|
|
595
|
+
deciding,
|
|
596
|
+
decidingEnvRoot: await findEnvRoot(deciding.directory),
|
|
597
|
+
reservedIds: existingIds,
|
|
598
|
+
command,
|
|
599
|
+
mode: resolvedMode,
|
|
600
|
+
production: resolvedProduction,
|
|
601
|
+
customEnvFile,
|
|
602
|
+
realEnv,
|
|
603
|
+
timeout,
|
|
604
|
+
validateCapabilities,
|
|
605
|
+
runtimeScope,
|
|
606
|
+
report
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
applyWorkerEnvironments(applications, {
|
|
610
|
+
rootEnv: rootEnvBlock,
|
|
611
|
+
realEnv,
|
|
612
|
+
production: resolvedProduction,
|
|
613
|
+
additionalIds: existingIds
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
return { applications, watchTargets: collectWatchTargets(report) }
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function prepareApplications ({ entries, deciding, reservedIds = [], ...shared }) {
|
|
620
|
+
// The ids have to be known before the fan-out: they name the topology variables each per-app
|
|
621
|
+
// worker has stripped from its environment, and they are checked before reaching either
|
|
622
|
+
// consumer — the mesh hostname and the variable normalization.
|
|
623
|
+
const identified = await Promise.all(
|
|
624
|
+
entries.map(async entry => {
|
|
625
|
+
const directory = resolveEntryDirectory(entry, deciding.directory)
|
|
626
|
+
|
|
627
|
+
// The same three rungs autoload uses: an explicit id, then the package.json name with any
|
|
628
|
+
// scope stripped, then the directory name. A default that varied by position would move the
|
|
629
|
+
// mesh hostname, the injected variable, the metrics label, wattpm inject's argument and the
|
|
630
|
+
// dependencies spelling all at once.
|
|
631
|
+
const derived = deriveApplicationId({
|
|
632
|
+
id: entry.id,
|
|
633
|
+
packageName: entry.id ? undefined : await readPackageName(directory),
|
|
634
|
+
directory
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
assertValidApplicationId(derived.id, derived.source)
|
|
638
|
+
return { entry, directory, id: derived.id }
|
|
639
|
+
})
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
// Applications already in the topology count: an application added after boot collides with the
|
|
643
|
+
// running ones, not only with the others in its own batch.
|
|
644
|
+
const [collision] = findTopologyVariableCollisions([...reservedIds, ...identified.map(({ id }) => id)])
|
|
645
|
+
|
|
646
|
+
if (collision) {
|
|
647
|
+
// The label grammar removes most of the ways this could happen, so what remains is a case
|
|
648
|
+
// difference — and DNS labels being case-insensitive, those are the same mesh hostname too.
|
|
649
|
+
throw new InvalidApplicationIdError(
|
|
650
|
+
JSON.stringify(collision.ids.join(', ')),
|
|
651
|
+
`two application ids normalizing to ${collision.name}`
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const injectedNames = [...reservedIds, ...identified.map(({ id }) => id)].map(topologyVariableName)
|
|
656
|
+
|
|
657
|
+
// Per-app files are independent by definition — cross-file coordination was never supported — so
|
|
658
|
+
// parallel evaluation is safe and typically faster than any serial scheme.
|
|
659
|
+
return Promise.all(
|
|
660
|
+
identified.map(application => prepareApplication({ ...application, injectedNames, deciding, ...shared }))
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function prepareApplication ({
|
|
665
|
+
entry,
|
|
666
|
+
directory,
|
|
667
|
+
id,
|
|
668
|
+
injectedNames,
|
|
669
|
+
deciding,
|
|
670
|
+
decidingEnvRoot,
|
|
671
|
+
command,
|
|
672
|
+
mode,
|
|
673
|
+
production,
|
|
674
|
+
customEnvFile,
|
|
675
|
+
realEnv,
|
|
676
|
+
timeout,
|
|
677
|
+
validateCapabilities,
|
|
678
|
+
runtimeScope,
|
|
679
|
+
report
|
|
680
|
+
}) {
|
|
681
|
+
const prepared = { ...entry, id, path: directory }
|
|
682
|
+
|
|
683
|
+
// Adding or deleting a watt.config.ts in an application directory changes which applications own
|
|
684
|
+
// a file — and, after the scoping rule, what wattpm dev does there — so the candidates are
|
|
685
|
+
// watched whether or not one exists. The package.json is watched for the same reason: it supplies
|
|
686
|
+
// the id and the dependencies the detector reads.
|
|
687
|
+
report.watch(...configurationFileNames.map(name => join(directory, name)), join(directory, 'package.json'))
|
|
688
|
+
|
|
689
|
+
// Discovery skips a candidate that is the deciding file itself, whatever the entry's shape: an
|
|
690
|
+
// entry whose directory is the deciding file's own falls through to the detector rather than
|
|
691
|
+
// re-reading the file that produced it.
|
|
692
|
+
const configurationFile = await findApplicationConfigurationFile(directory, deciding.path)
|
|
693
|
+
|
|
694
|
+
// Resolved for every application, not only the ones with a file to evaluate: every application
|
|
695
|
+
// has workers, and the worker-runtime view is built from this same set.
|
|
696
|
+
const { fileSources, env } = await resolveEnvironmentFor(
|
|
697
|
+
{
|
|
698
|
+
directory,
|
|
699
|
+
envRoot: await findEnvRoot(directory),
|
|
700
|
+
decidingDirectory: deciding.directory,
|
|
701
|
+
decidingEnvRoot,
|
|
702
|
+
mode,
|
|
703
|
+
envfile: entry.envfile,
|
|
704
|
+
customEnvFile,
|
|
705
|
+
realEnv,
|
|
706
|
+
production
|
|
707
|
+
},
|
|
708
|
+
report
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
// Non-enumerable, as foundation already does for kEnvFileFallbackKeys: the runtime reads these
|
|
712
|
+
// main-side to build workerData, and they have no business in the configuration DTO, in
|
|
713
|
+
// --debug-config output, or in anything that structured-clones into a worker. The environment in
|
|
714
|
+
// particular would otherwise put every secret on every application entry.
|
|
715
|
+
Object.defineProperty(prepared, 'envFileSources', { value: fileSources, enumerable: false })
|
|
716
|
+
|
|
717
|
+
if (entry.config !== undefined) {
|
|
718
|
+
if (configurationFile) {
|
|
719
|
+
// A root boot must not have two sources for one application. No evaluation is involved, and
|
|
720
|
+
// the deciding file itself is exempt, so a Level 1 auto-wrap never trips it.
|
|
721
|
+
throw new ApplicationConfiguredTwiceError(id, configurationFile)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (entry.envfile) {
|
|
725
|
+
// No file is read for this entry, so the envfile would govern the worker-runtime view alone,
|
|
726
|
+
// and a key that silently covers one view and not the other is the ambiguity this format
|
|
727
|
+
// exists to remove. A deliberate simplification rather than an impossibility.
|
|
728
|
+
throw new EnvFileOnInlineConfigError(id)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
await applyDefinition(prepared, entry.config, { directory, report, validateCapabilities, production, runtimeScope })
|
|
732
|
+
return prepared
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (entry.envfile && directory === deciding.directory) {
|
|
736
|
+
// Not a simplification but an ordering impossibility: the root worker's environment is
|
|
737
|
+
// resolved from the deciding file's own directory chain before that file is evaluated, and the
|
|
738
|
+
// entry's envfile does not exist until after. Applying it would mean reading the configuration
|
|
739
|
+
// in order to build the environment that produces the configuration.
|
|
740
|
+
throw new EnvFileOnDecidingDirectoryError(id, deciding.path)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!configurationFile) {
|
|
744
|
+
// Neither an inline config nor a per-app file: one deterministic detector run. A third shape
|
|
745
|
+
// with no eval worker, and the one an envfile is not refused for — nothing is evaluated, so
|
|
746
|
+
// there is no evaluation view for it to be absent from.
|
|
747
|
+
const { capability, source } = await detectCapability(directory, { id })
|
|
748
|
+
|
|
749
|
+
report.onInfo?.({
|
|
750
|
+
type: 'detected-capability',
|
|
751
|
+
id,
|
|
752
|
+
capability,
|
|
753
|
+
source,
|
|
754
|
+
message: `${id} → ${capability} (detected)`
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
prepared.module = capability
|
|
758
|
+
prepared.config = {}
|
|
759
|
+
prepared.detected = true
|
|
760
|
+
|
|
761
|
+
await validateApplication(prepared, { module: capability, directory, report, validateCapabilities, production, runtimeScope })
|
|
762
|
+
|
|
763
|
+
if (entry.envfile) {
|
|
764
|
+
report.watch(isAbsolute(entry.envfile) ? entry.envfile : resolve(directory, entry.envfile))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return prepared
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
report.watch(configurationFile)
|
|
771
|
+
|
|
772
|
+
// Injection is a runtime act with no rung in the config-evaluation ladder, so the declared
|
|
773
|
+
// topology keys are stripped from every per-app worker: a config file reading one during
|
|
774
|
+
// evaluation would bake a stale value into resolvedConfig, where runtime injection can no longer
|
|
775
|
+
// reach it.
|
|
776
|
+
stripInjectedTopologyKeys(env, injectedNames, realEnv)
|
|
777
|
+
|
|
778
|
+
const evaluated = await evaluateConfigurationFile({
|
|
779
|
+
path: configurationFile,
|
|
780
|
+
directory,
|
|
781
|
+
role: 'application',
|
|
782
|
+
applicationId: id,
|
|
783
|
+
env,
|
|
784
|
+
command,
|
|
785
|
+
mode,
|
|
786
|
+
production,
|
|
787
|
+
timeout,
|
|
788
|
+
onImport: report.onImport,
|
|
789
|
+
onWatchFile: report.onWatchFile
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
reportMutatedEnv(report, configurationFile, evaluated.mutatedEnvKeys)
|
|
793
|
+
|
|
794
|
+
await applyDefinition(prepared, evaluated.config, { directory, report, validateCapabilities, production, runtimeScope })
|
|
795
|
+
prepared.configPath = configurationFile
|
|
796
|
+
|
|
797
|
+
return prepared
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/*
|
|
801
|
+
module and version are loader metadata, not capability options. They are stripped into the entry's
|
|
802
|
+
envelope before the capability's AJV validation and transform run, so capability schemas keep
|
|
803
|
+
additionalProperties: false and gain no reserved properties — a stamped factory result validates
|
|
804
|
+
as cleanly as a hand-written one. They surface on the entry as module and definitionVersion, the
|
|
805
|
+
latter renamed on the way out because getApplicationDetails().version already means the capability
|
|
806
|
+
version the running worker loaded.
|
|
807
|
+
*/
|
|
808
|
+
async function applyDefinition (prepared, definition, { directory, report, validateCapabilities, production, runtimeScope }) {
|
|
809
|
+
const { module, version, ...payload } = definition
|
|
810
|
+
|
|
811
|
+
prepared.config = payload
|
|
812
|
+
|
|
813
|
+
if (module) {
|
|
814
|
+
prepared.module = module
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (version) {
|
|
818
|
+
prepared.definitionVersion = version
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const skew = checkCapabilityVersionSkew({
|
|
822
|
+
id: prepared.id,
|
|
823
|
+
module,
|
|
824
|
+
stamped: version,
|
|
825
|
+
applicationRoot: directory,
|
|
826
|
+
runtimeScope
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
if (skew) {
|
|
830
|
+
if (skew.level === 'error') {
|
|
831
|
+
throw new CapabilityVersionSkewError(skew.message)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Minor drift is legitimate mid-upgrade, so it warns rather than failing the boot.
|
|
835
|
+
report.onWarning?.({ type: 'capability-version-skew', ...skew })
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
await validateApplication(prepared, { module, directory, report, validateCapabilities, production, runtimeScope })
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/*
|
|
842
|
+
Capability configuration is validated main-side, against the capability's own schema, after the
|
|
843
|
+
module/version envelope has been stripped — so the schema keeps additionalProperties: false and
|
|
844
|
+
needs no reserved properties. useDefaults runs here rather than in the eval worker, which is what
|
|
845
|
+
keeps the resolve projection carrying authored values rather than schema-supplied ones.
|
|
846
|
+
|
|
847
|
+
It is opt-in for now, and deliberately not defaulted on: no capability ships the light /schema
|
|
848
|
+
subpath yet, so every boot would import full capability packages into the main process — the cost
|
|
849
|
+
the subpath exists to avoid. Making it skip when the schema cannot be imported was the
|
|
850
|
+
alternative, and it is worse: a check that treats "I could not verify this" as "verified" is not
|
|
851
|
+
a check. The default flips when the subpaths land.
|
|
852
|
+
*/
|
|
853
|
+
async function validateApplication (prepared, { module, directory, report, validateCapabilities, production, runtimeScope }) {
|
|
854
|
+
if (!validateCapabilities || !module) {
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const { schema, metadata, via, path } = await importCapabilitySchema(module, directory, { runtimeScope })
|
|
859
|
+
|
|
860
|
+
if (via === 'entry') {
|
|
861
|
+
report.onWarning?.({
|
|
862
|
+
type: 'capability-schema-fallback',
|
|
863
|
+
module,
|
|
864
|
+
path,
|
|
865
|
+
message: `${module} has no /schema subpath, so its full package was imported into the loader to validate ${prepared.id}.`
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
validateCapabilityConfiguration(prepared.config, schema, { id: prepared.id, module, root: directory })
|
|
870
|
+
/*
|
|
871
|
+
The serving declaration stays main-side. It is sometimes a function -- vite decides from the
|
|
872
|
+
configuration -- and this entry is structured-cloned into the worker, where a function is a
|
|
873
|
+
DataCloneError rather than a value. The worker needs the other two; it has no use for a
|
|
874
|
+
predicate that has already been evaluated here.
|
|
875
|
+
*/
|
|
876
|
+
const { servesWithoutPort, ...workerMetadata } = metadata
|
|
877
|
+
|
|
878
|
+
prepared.capabilityMetadata = workerMetadata
|
|
879
|
+
|
|
880
|
+
// Read after validation and before any worker, which is precisely when the resolved capability
|
|
881
|
+
// configuration is available and the callable form can be evaluated.
|
|
882
|
+
prepared.serving = assertApplicationServes({
|
|
883
|
+
id: prepared.id,
|
|
884
|
+
module,
|
|
885
|
+
declaration: servesWithoutPort,
|
|
886
|
+
config: prepared.config,
|
|
887
|
+
production
|
|
888
|
+
})
|
|
889
|
+
}
|