@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/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Deepmerge from '@fastify/deepmerge'
|
|
2
2
|
import { bgGreen, black, bold, green, isColorSupported } from 'colorette'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
4
|
import { parseArgs as nodeParseArgs } from 'node:util'
|
|
5
5
|
import { pino } from 'pino'
|
|
6
6
|
import pinoPretty from 'pino-pretty'
|
|
@@ -8,6 +8,7 @@ import { findConfigurationFileRecursive, loadConfigurationModule, saveConfigurat
|
|
|
8
8
|
import { hasJavascriptFiles } from './file-system.js'
|
|
9
9
|
import { setPinoTimestamp } from './logger.js'
|
|
10
10
|
import { detectApplicationType, getPlatformaticVersion } from './module.js'
|
|
11
|
+
import { findDecidingFile, isConfigurationFileName } from './v4/index.js'
|
|
11
12
|
|
|
12
13
|
/* c8 ignore next 4 - else branches */
|
|
13
14
|
let verbose = false
|
|
@@ -212,6 +213,32 @@ export function applicationToEnvVariable (application) {
|
|
|
212
213
|
return `PLT_APPLICATION_${application.toUpperCase().replaceAll(/[^A-Z0-9_]/g, '_')}_PATH`
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/*
|
|
217
|
+
The same routing the runtime does, in the one other place a configuration is found: by an
|
|
218
|
+
explicit name, or by the walk from the directory. A legacy file found by the walk means "this
|
|
219
|
+
project is not v4", and the v3 lookups below are the ones that should answer.
|
|
220
|
+
*/
|
|
221
|
+
async function findV4ConfigurationFile (root, configurationFile) {
|
|
222
|
+
if (typeof configurationFile === 'string') {
|
|
223
|
+
const named = resolve(root, configurationFile)
|
|
224
|
+
|
|
225
|
+
// The extension decides for a name given outright: v4 configuration is code, v3 is a document.
|
|
226
|
+
return isConfigurationFileName(basename(named)) || /\.(js|mjs|ts|mts)$/.test(named) ? named : null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const deciding = await findDecidingFile(root, { throwOnMissing: false })
|
|
231
|
+
|
|
232
|
+
return deciding?.path ?? null
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error.code === 'PLT_LEGACY_CONFIGURATION_FILE') {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw error
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
export async function findRuntimeConfigurationFile (
|
|
216
243
|
logger,
|
|
217
244
|
root,
|
|
@@ -221,6 +248,17 @@ export async function findRuntimeConfigurationFile (
|
|
|
221
248
|
verifyPackages = true,
|
|
222
249
|
executableName = ''
|
|
223
250
|
) {
|
|
251
|
+
/*
|
|
252
|
+
v4 first. A v4 project has no v3 configuration file by construction, so every lookup below
|
|
253
|
+
fails and the fallback then auto-detects the directory and writes a watt.json into it -- the
|
|
254
|
+
command silently builds something other than the project it was pointed at.
|
|
255
|
+
*/
|
|
256
|
+
const v4ConfigurationFile = await findV4ConfigurationFile(root, configurationFile)
|
|
257
|
+
|
|
258
|
+
if (v4ConfigurationFile) {
|
|
259
|
+
return v4ConfigurationFile
|
|
260
|
+
}
|
|
261
|
+
|
|
224
262
|
let configFile = await findConfigurationFileRecursive(root, configurationFile, '@platformatic/runtime')
|
|
225
263
|
|
|
226
264
|
// If a runtime was not found, search for application file that we wrap in a runtime
|
package/lib/schema.js
CHANGED
|
@@ -401,7 +401,7 @@ export const server = {
|
|
|
401
401
|
type: 'string',
|
|
402
402
|
enum: ['shared', 'perWorkerIncrement'],
|
|
403
403
|
description:
|
|
404
|
-
'Configures how
|
|
404
|
+
'Configures how the port is assigned when the application runs multiple workers. When set to shared (the default), all workers listen on the same port (which requires SO_REUSEPORT support). When set to perWorkerIncrement, each worker listens on its own port, starting from port (worker 0) and incrementing by one for each additional worker.'
|
|
405
405
|
},
|
|
406
406
|
backlog: {
|
|
407
407
|
type: 'integer',
|
|
@@ -512,6 +512,8 @@ export const fastifyServer = {
|
|
|
512
512
|
type: 'string'
|
|
513
513
|
},
|
|
514
514
|
port: server.properties.port,
|
|
515
|
+
portAssignment: server.properties.portAssignment,
|
|
516
|
+
backlog: server.properties.backlog,
|
|
515
517
|
pluginTimeout: {
|
|
516
518
|
type: 'integer'
|
|
517
519
|
},
|
|
@@ -639,15 +641,7 @@ export const fastifyServer = {
|
|
|
639
641
|
},
|
|
640
642
|
http2: server.properties.http2,
|
|
641
643
|
https: server.properties.https,
|
|
642
|
-
cors
|
|
643
|
-
errorHandler: {
|
|
644
|
-
description:
|
|
645
|
-
'Path to a file or name of a package whose default export is a Fastify error handler. It is installed on the root instance before any route is registered, so it also covers the routes registered by the capability itself, such as the auto generated CRUD routes of @platformatic/db. Plugins can still override it for their own encapsulation context.',
|
|
646
|
-
anyOf: [
|
|
647
|
-
{ type: 'string', resolveModule: true },
|
|
648
|
-
{ type: 'string', resolvePath: true }
|
|
649
|
-
]
|
|
650
|
-
}
|
|
644
|
+
cors
|
|
651
645
|
},
|
|
652
646
|
additionalProperties: false
|
|
653
647
|
}
|
|
@@ -895,7 +889,11 @@ export const application = {
|
|
|
895
889
|
resolvePath: true
|
|
896
890
|
},
|
|
897
891
|
config: {
|
|
898
|
-
|
|
892
|
+
// v4 entries carry an inline ApplicationDefinition here — the object a capability factory
|
|
893
|
+
// returns — where v3 carried a path to a configuration file. The union is transitional and
|
|
894
|
+
// narrows to the object alone when the v3 reader leaves foundation; it is listed for the
|
|
895
|
+
// schema audit rather than left to be rediscovered.
|
|
896
|
+
anyOf: [{ type: 'string' }, { type: 'object' }]
|
|
899
897
|
},
|
|
900
898
|
url: {
|
|
901
899
|
type: 'string'
|
|
@@ -904,12 +902,6 @@ export const application = {
|
|
|
904
902
|
type: 'string',
|
|
905
903
|
default: 'main'
|
|
906
904
|
},
|
|
907
|
-
useHttp: {
|
|
908
|
-
type: 'boolean'
|
|
909
|
-
},
|
|
910
|
-
websocket: {
|
|
911
|
-
type: 'boolean'
|
|
912
|
-
},
|
|
913
905
|
reuseTcpPorts: {
|
|
914
906
|
type: 'boolean',
|
|
915
907
|
default: true
|
|
@@ -1071,9 +1063,6 @@ export const runtimeProperties = {
|
|
|
1071
1063
|
},
|
|
1072
1064
|
preload,
|
|
1073
1065
|
extensions,
|
|
1074
|
-
entrypoint: {
|
|
1075
|
-
type: 'string'
|
|
1076
|
-
},
|
|
1077
1066
|
basePath: {
|
|
1078
1067
|
type: 'string'
|
|
1079
1068
|
},
|
|
@@ -1119,7 +1108,6 @@ export const runtimeProperties = {
|
|
|
1119
1108
|
default: 0
|
|
1120
1109
|
},
|
|
1121
1110
|
logger,
|
|
1122
|
-
server,
|
|
1123
1111
|
reuseTcpPorts: {
|
|
1124
1112
|
type: 'boolean',
|
|
1125
1113
|
default: true
|
|
@@ -1631,6 +1619,7 @@ export const runtimeProperties = {
|
|
|
1631
1619
|
export const runtimeUnwrappablePropertiesList = [
|
|
1632
1620
|
'$schema',
|
|
1633
1621
|
'entrypoint',
|
|
1622
|
+
'server',
|
|
1634
1623
|
'applications',
|
|
1635
1624
|
'application',
|
|
1636
1625
|
'autoload',
|
|
@@ -1647,8 +1636,6 @@ export const applicationsUnwrappablePropertiesList = [
|
|
|
1647
1636
|
'url',
|
|
1648
1637
|
'gitBranch',
|
|
1649
1638
|
'dependencies',
|
|
1650
|
-
'useHttp',
|
|
1651
|
-
'websocket',
|
|
1652
1639
|
'management'
|
|
1653
1640
|
]
|
|
1654
1641
|
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { types } from 'node:util'
|
|
2
|
+
import { InvalidConfigValueError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
export function formatPointer (segments) {
|
|
5
|
+
if (segments.length === 0) {
|
|
6
|
+
return '/'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return segments.map(segment => `/${String(segment).replace(/~/g, '~0').replace(/\//g, '~1')}`).join('')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function describeValue (value) {
|
|
13
|
+
if (value === null) {
|
|
14
|
+
return 'null'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
return 'array'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof value === 'object') {
|
|
22
|
+
const name = value.constructor?.name
|
|
23
|
+
|
|
24
|
+
return name && name !== 'Object' ? `${name} instance` : 'object'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return typeof value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isPlainObject (value) {
|
|
31
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const prototype = Object.getPrototypeOf(value)
|
|
36
|
+
|
|
37
|
+
return prototype === Object.prototype || prototype === null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The two positions where a function survives the walk. The test is structural, not semantic,
|
|
41
|
+
// which is why it can run before classification: whatever the file turns out to be, these are the
|
|
42
|
+
// only paths a deferred definition can occupy.
|
|
43
|
+
export function isDeferredSlot (segments) {
|
|
44
|
+
if (segments.length === 2) {
|
|
45
|
+
return segments[0] === 'application' && segments[1] === 'config'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (segments.length === 3) {
|
|
49
|
+
return segments[0] === 'applications' && typeof segments[1] === 'number' && segments[2] === 'config'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
Canonicalization builds a plain-data snapshot rather than inspecting the evaluated object,
|
|
57
|
+
because inspecting is a time-of-check/time-of-use gap: a getter or a Proxy can return one shape
|
|
58
|
+
to the check and another to the clone, so the validated structure and the transported structure
|
|
59
|
+
need not be the same object graph. After this walk nothing else holds a reference to the
|
|
60
|
+
original, which is what makes "nothing downstream ever touches it" literally true.
|
|
61
|
+
|
|
62
|
+
structuredClone is not JSON.stringify — it preserves own properties whose value is undefined —
|
|
63
|
+
so omitting them is something this pass does rather than something the boundary does for it.
|
|
64
|
+
*/
|
|
65
|
+
export function canonicalize (value, { deferred = false } = {}) {
|
|
66
|
+
// deferred: true records a function at the two carve-out paths and leaves the slot pending;
|
|
67
|
+
// 'reject' refuses one there with the object-source message; false gives it the ordinary
|
|
68
|
+
// functions-cannot-be-transported error, which is correct for a per-app file, where a property
|
|
69
|
+
// named config is a capability option rather than a slot.
|
|
70
|
+
|
|
71
|
+
const slots = []
|
|
72
|
+
const ancestors = new Set()
|
|
73
|
+
|
|
74
|
+
function walk (current, segments) {
|
|
75
|
+
const pointer = () => formatPointer(segments)
|
|
76
|
+
|
|
77
|
+
if (current === null) {
|
|
78
|
+
return current
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (typeof current) {
|
|
82
|
+
case 'string':
|
|
83
|
+
case 'boolean':
|
|
84
|
+
return current
|
|
85
|
+
case 'number':
|
|
86
|
+
if (!Number.isFinite(current)) {
|
|
87
|
+
throw new InvalidConfigValueError(pointer(), `${current} is not a finite number`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return current
|
|
91
|
+
case 'bigint':
|
|
92
|
+
throw new InvalidConfigValueError(pointer(), 'bigint values cannot be transported')
|
|
93
|
+
case 'symbol':
|
|
94
|
+
throw new InvalidConfigValueError(pointer(), 'symbol values cannot be transported')
|
|
95
|
+
case 'function':
|
|
96
|
+
if (isDeferredSlot(segments)) {
|
|
97
|
+
if (deferred === true) {
|
|
98
|
+
slots.push({ pointer: pointer(), path: segments.slice(), value: current })
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (deferred === 'reject') {
|
|
103
|
+
// An embedder is already writing JavaScript and can call the function itself. What
|
|
104
|
+
// rejecting it avoids is a second, weaker evaluation contract: a callback run
|
|
105
|
+
// main-side would receive a resolved ctx.env while process.env around it stayed the
|
|
106
|
+
// caller's, and would skip the mutation diff, the deadline and the cache isolation.
|
|
107
|
+
throw new InvalidConfigValueError(
|
|
108
|
+
pointer(),
|
|
109
|
+
'a function-valued config is not allowed in a programmatic configuration object; call it and pass the result'
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new InvalidConfigValueError(
|
|
115
|
+
pointer(),
|
|
116
|
+
'functions cannot be transported; use a file path loaded by the capability instead'
|
|
117
|
+
)
|
|
118
|
+
case 'undefined':
|
|
119
|
+
// Reached only at the root or inside an array: object properties are filtered by the
|
|
120
|
+
// caller below, which is where "omitted" is implemented.
|
|
121
|
+
throw new InvalidConfigValueError(pointer(), 'undefined is not a configuration value')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (types.isProxy(current)) {
|
|
125
|
+
throw new InvalidConfigValueError(pointer(), 'Proxies cannot be transported')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (ancestors.has(current)) {
|
|
129
|
+
throw new InvalidConfigValueError(pointer(), 'circular references cannot be transported')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(current)) {
|
|
133
|
+
ancestors.add(current)
|
|
134
|
+
|
|
135
|
+
const snapshot = current.map((entry, index) => walk(entry, [...segments, index]))
|
|
136
|
+
|
|
137
|
+
ancestors.delete(current)
|
|
138
|
+
return snapshot
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isPlainObject(current)) {
|
|
142
|
+
throw new InvalidConfigValueError(pointer(), `${describeValue(current)} values cannot be transported`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ancestors.add(current)
|
|
146
|
+
|
|
147
|
+
const snapshot = {}
|
|
148
|
+
const descriptors = Object.getOwnPropertyDescriptors(current)
|
|
149
|
+
|
|
150
|
+
for (const key of Object.keys(descriptors)) {
|
|
151
|
+
const descriptor = descriptors[key]
|
|
152
|
+
|
|
153
|
+
if (!descriptor.enumerable) {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const childSegments = [...segments, key]
|
|
158
|
+
|
|
159
|
+
// A property that computes on read cannot be transported, and permitting it would make the
|
|
160
|
+
// snapshot unreproducible. Rejected wherever it appears, before it is ever read.
|
|
161
|
+
if (typeof descriptor.get === 'function' || typeof descriptor.set === 'function') {
|
|
162
|
+
throw new InvalidConfigValueError(formatPointer(childSegments), 'accessor properties cannot be transported')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (descriptor.value === undefined) {
|
|
166
|
+
// JSON.stringify semantics: the schema's defaults and required rules speak, rather than an
|
|
167
|
+
// error or a silent undefined crossing the boundary.
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const child = walk(descriptor.value, childSegments)
|
|
172
|
+
|
|
173
|
+
if (child === undefined) {
|
|
174
|
+
// A recorded deferred slot. The key stays absent from the snapshot until step 5 splices
|
|
175
|
+
// the resolved value back in.
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
snapshot[key] = child
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ancestors.delete(current)
|
|
183
|
+
return snapshot
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const config = walk(value, [])
|
|
187
|
+
|
|
188
|
+
return { config, deferred: slots }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Step 5 splices each resolved definition back into the slot its function occupied.
|
|
192
|
+
export function spliceDeferredSlot (config, path, value) {
|
|
193
|
+
let current = config
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
196
|
+
current = current[path[i]]
|
|
197
|
+
|
|
198
|
+
if (current === undefined || current === null) {
|
|
199
|
+
throw new InvalidConfigValueError(formatPointer(path), 'the deferred slot no longer exists in the snapshot')
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
current[path[path.length - 1]] = value
|
|
204
|
+
|
|
205
|
+
return config
|
|
206
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join, parse } from 'node:path'
|
|
4
|
+
import semver from 'semver'
|
|
5
|
+
import { CapabilityNotResolvableError } from './errors.js'
|
|
6
|
+
|
|
7
|
+
function readPackageJson (path) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A package with an exports map need not expose ./package.json, so fall back to resolving the
|
|
16
|
+
// entry point and walking up to the manifest that names it.
|
|
17
|
+
function findPackageJson (require, module) {
|
|
18
|
+
try {
|
|
19
|
+
return require.resolve(`${module}/package.json`)
|
|
20
|
+
} catch {
|
|
21
|
+
// Fall through to the entry-point walk.
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let directory = dirname(require.resolve(module))
|
|
25
|
+
const filesystemRoot = parse(directory).root
|
|
26
|
+
|
|
27
|
+
while (true) {
|
|
28
|
+
const candidate = join(directory, 'package.json')
|
|
29
|
+
|
|
30
|
+
if (readPackageJson(candidate)?.name === module) {
|
|
31
|
+
return candidate
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (directory === filesystemRoot) {
|
|
35
|
+
throw new Error(`Cannot locate the package.json of ${module}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
directory = dirname(directory)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
The canonical capability resolution order: application-scoped first, with the runtime-bundled
|
|
44
|
+
copy as the fallback. It inverts v3, whose worker tried a bare import resolved from
|
|
45
|
+
@platformatic/basic — that is, from the runtime's own position — and only fell back to an
|
|
46
|
+
application-scoped require when that threw.
|
|
47
|
+
|
|
48
|
+
The order is not merely different, it is what makes the version-stamp check implementable at all:
|
|
49
|
+
the stamp compares the factory's copy against the copy the worker will run, so a check resolving
|
|
50
|
+
application-first against a worker resolving lexically would compare a copy nobody executes —
|
|
51
|
+
reporting skew where there is none, and missing it where there is. The same order is applied by
|
|
52
|
+
the worker's implementation import, this check, and the main process's schema import, so the
|
|
53
|
+
three cannot disagree.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveCapabilityPackage (module, applicationRoot, { runtimeScope } = {}) {
|
|
56
|
+
const attempts = [
|
|
57
|
+
{ scope: 'application', require: createRequire(join(applicationRoot, 'noop.js')) },
|
|
58
|
+
// Resolved from the caller's position for the same reason the schema import is: foundation
|
|
59
|
+
// depends on no capability, so its own position is not where a bundled copy lives.
|
|
60
|
+
{ scope: 'runtime', require: createRequire(runtimeScope ?? import.meta.filename) }
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for (const { scope, require } of attempts) {
|
|
64
|
+
try {
|
|
65
|
+
const manifestPath = findPackageJson(require, module)
|
|
66
|
+
const manifest = readPackageJson(manifestPath)
|
|
67
|
+
|
|
68
|
+
if (manifest) {
|
|
69
|
+
return { scope, path: dirname(manifestPath), version: manifest.version }
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Try the next scope. The error raised when both fail names the module and the root, which
|
|
73
|
+
// is more useful than either resolution failure on its own.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new CapabilityNotResolvableError(module, applicationRoot)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
Major mismatch is a boot error; minor mismatch is a warning, since mid-upgrade drift is
|
|
82
|
+
legitimate; patch differences are ignored.
|
|
83
|
+
|
|
84
|
+
A prerelease component on either side demands exact identity: 4.0.0-alpha.1, 4.0.0-rc.2 and 4.0.0
|
|
85
|
+
agree on major, minor and patch while differing in schema and factory shape, so the relaxed
|
|
86
|
+
policy would pair incompatible halves precisely during the alpha and RC period, when they move
|
|
87
|
+
fastest and users are explicitly expected to be on them.
|
|
88
|
+
*/
|
|
89
|
+
export function compareCapabilityVersions (stamped, resolved) {
|
|
90
|
+
if (!stamped || !resolved) {
|
|
91
|
+
// A hand-written { module } object carries no stamp and skips the check.
|
|
92
|
+
return { level: 'ok', reason: 'unstamped' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const left = semver.parse(stamped)
|
|
96
|
+
const right = semver.parse(resolved)
|
|
97
|
+
|
|
98
|
+
if (!left || !right) {
|
|
99
|
+
return { level: 'ok', reason: 'unparseable' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (left.prerelease.length > 0 || right.prerelease.length > 0) {
|
|
103
|
+
return stamped === resolved
|
|
104
|
+
? { level: 'ok', reason: 'prerelease-identical' }
|
|
105
|
+
: { level: 'error', reason: 'prerelease-mismatch' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (left.major !== right.major) {
|
|
109
|
+
return { level: 'error', reason: 'major-mismatch' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (left.minor !== right.minor) {
|
|
113
|
+
return { level: 'warning', reason: 'minor-mismatch' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { level: 'ok', reason: 'compatible' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
The stamp closes the root/app skew hole: a root-inline factory resolves from the root's copy of
|
|
121
|
+
the capability while the worker implementation may resolve a different one — with pnpm's strict
|
|
122
|
+
layout those can be different versions, letting a 4.1-only option pass the editor and the factory
|
|
123
|
+
only to be rejected by the 4.0 schema at boot, or silently misapplied where the schemas differ
|
|
124
|
+
more subtly. Hoisted layouts, where factory and worker share one copy, never false-positive.
|
|
125
|
+
*/
|
|
126
|
+
export function checkCapabilityVersionSkew ({ id, module, stamped, applicationRoot, runtimeScope }) {
|
|
127
|
+
if (!stamped) {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const resolved = resolveCapabilityPackage(module, applicationRoot, { runtimeScope })
|
|
132
|
+
const { level, reason } = compareCapabilityVersions(stamped, resolved.version)
|
|
133
|
+
|
|
134
|
+
if (level === 'ok') {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
level,
|
|
140
|
+
reason,
|
|
141
|
+
id,
|
|
142
|
+
module,
|
|
143
|
+
stamped,
|
|
144
|
+
resolved: resolved.version,
|
|
145
|
+
resolvedPath: resolved.path,
|
|
146
|
+
scope: resolved.scope,
|
|
147
|
+
message: `application '${id}' was configured by a ${module} factory stamped ${stamped}, but the copy the worker will load is ${resolved.version} at ${resolved.path}.`
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describeValue, isPlainObject } from './canonicalize.js'
|
|
2
|
+
import { InvalidConfigurationExportError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
export const applicationDefinitionKey = 'module'
|
|
5
|
+
export const rootConfigurationKeys = ['application', 'applications', 'autoload']
|
|
6
|
+
|
|
7
|
+
// Keys that only a root configuration has. They do not decide classification — rule 2 is
|
|
8
|
+
// unconditional — but they make the error actionable when a root config grew a module property.
|
|
9
|
+
export const rootOnlyKeys = ['autoload', 'workers', 'managementApi', 'applications', 'application']
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Four unconditional rules, read off the canonical snapshot and never off the raw export. Rule 1 —
|
|
13
|
+
the function call — has already happened by the time this runs; what is left is total over
|
|
14
|
+
objects, and everything that is not an object is refused ahead of them.
|
|
15
|
+
|
|
16
|
+
null is the one worth spelling out: typeof null === 'object', so it would reach rule 2 as a
|
|
17
|
+
property read on nothing, and the difference between a TypeError from one implementation and an
|
|
18
|
+
AJV error from another is exactly the divergence these rules exist to prevent.
|
|
19
|
+
*/
|
|
20
|
+
export function classifyConfiguration (snapshot, file) {
|
|
21
|
+
if (!isPlainObject(snapshot)) {
|
|
22
|
+
throw new InvalidConfigurationExportError(file, describeValue(snapshot))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Rule 2 is unconditional and carries no key-collision check: capabilities legitimately use
|
|
26
|
+
// option names that are also root keys, so any collision list would misclassify valid factory
|
|
27
|
+
// results. It is safe in the other direction because a v4 root config has no module key.
|
|
28
|
+
if (applicationDefinitionKey in snapshot) {
|
|
29
|
+
return 'application'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Rules 3 and 4 agree on the answer; they are separate only because rule 4 is the one that says
|
|
33
|
+
// an empty config file is a statement rather than an absence. Classification answers "what kind
|
|
34
|
+
// of file is this", not "is it usable" — {} classifies here and is then rejected by validation.
|
|
35
|
+
return 'root'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findRootOnlyKeys (snapshot) {
|
|
39
|
+
return rootOnlyKeys.filter(key => key in snapshot)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Auto-wrapping happens here, on the snapshot, rather than at the point of a shape read: the whole
|
|
43
|
+
// point of the ordering is that nothing reads the raw export's shape.
|
|
44
|
+
export function autoWrapApplicationDefinition (definition) {
|
|
45
|
+
return { application: { config: definition } }
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const configurationCommands = ['dev', 'build', 'start', 'exec']
|
|
4
|
+
|
|
5
|
+
// production is the common-case shortcut: true under start and --production, and under build,
|
|
6
|
+
// because a build produces production artifacts.
|
|
7
|
+
export function isProductionCommand (command) {
|
|
8
|
+
return command === 'start' || command === 'build'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// mode defaults to development under dev and production under build/start. exec is every non-boot
|
|
12
|
+
// evaluation, so it takes its answer from the production flag it was given rather than inventing a
|
|
13
|
+
// third default.
|
|
14
|
+
export function defaultMode (command, production) {
|
|
15
|
+
return (production ?? isProductionCommand(command)) ? 'production' : 'development'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
One context object is handed to every callback in a file, so it is frozen rather than merely
|
|
20
|
+
typed Readonly: a config that wrote to ctx.env would change what later deferred entries observe,
|
|
21
|
+
make the result depend on evaluation order, and do it without tripping the process.env mutation
|
|
22
|
+
warning, which watches a different object.
|
|
23
|
+
*/
|
|
24
|
+
export function createConfigurationContext ({ command, mode, production, env, root, onWatchFile } = {}) {
|
|
25
|
+
const resolvedProduction = production ?? isProductionCommand(command)
|
|
26
|
+
const resolvedMode = mode ?? defaultMode(command, resolvedProduction)
|
|
27
|
+
const snapshot = Object.freeze({ ...env })
|
|
28
|
+
|
|
29
|
+
// addWatchFile is the one member that is a function rather than data, and it does not make the
|
|
30
|
+
// context mutable: it reports a path outward and returns nothing, so two callbacks in one file
|
|
31
|
+
// cannot observe each other through it. Outside a watching command it is a no-op, which keeps a
|
|
32
|
+
// config that calls it from behaving differently under start.
|
|
33
|
+
function addWatchFile (path) {
|
|
34
|
+
if (typeof path !== 'string' || path.length === 0 || !onWatchFile) {
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Relative paths resolve against ctx.root — the config file's own directory, the only stable
|
|
39
|
+
// referent, since a helper that calls this may live anywhere and process.cwd() is wherever the
|
|
40
|
+
// command was typed.
|
|
41
|
+
onWatchFile(isAbsolute(path) ? path : resolve(root, path))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
command,
|
|
46
|
+
mode: resolvedMode,
|
|
47
|
+
production: resolvedProduction,
|
|
48
|
+
env: snapshot,
|
|
49
|
+
root,
|
|
50
|
+
addWatchFile
|
|
51
|
+
})
|
|
52
|
+
}
|