@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/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
+ }