@teqfw/di 2.6.2 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +63 -561
- package/ai/AGENTS.md +10 -54
- package/dist/esm.js +1 -1
- package/dist/umd.js +1 -1
- package/package.json +23 -16
- package/skills/teqfw-di/SKILL.md +36 -0
- package/skills/teqfw-di/agents/openai.yaml +4 -0
- package/skills/teqfw-di/references/compatibility.md +35 -0
- package/skills/teqfw-di/references/concepts.md +43 -0
- package/{ai → skills/teqfw-di/references}/container.md +12 -6
- package/{ai → skills/teqfw-di/references}/dependency-id.md +28 -11
- package/skills/teqfw-di/references/distribution.md +24 -0
- package/{ai → skills/teqfw-di/references}/extensions.md +18 -7
- package/{ai → skills/teqfw-di/references}/package-api.ts +58 -35
- package/skills/teqfw-di/references/usage.md +283 -0
- package/src/AGENTS.md +20 -16
- package/src/Config/NamespaceRegistry.mjs +3 -213
- package/src/Container/Executor.mjs +2 -2
- package/src/Container/GraphResolver.mjs +24 -13
- package/src/Container/Instantiate.mjs +2 -2
- package/src/Container/Lifecycle.mjs +2 -2
- package/src/Container/Pipeline.mjs +20 -22
- package/src/Container.mjs +67 -35
- package/src/Dto/DepId.mjs +1 -1
- package/src/Internal/DepsDecl.mjs +2 -2
- package/src/Node/Registry/Namespace.mjs +208 -0
- package/src/Node/Registry/Package.mjs +215 -0
- package/src/Parser.mjs +18 -18
- package/types.d.ts +9 -4
- package/ai/concepts.md +0 -25
- package/ai/usage.md +0 -194
package/src/Container.mjs
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import TeqFw_Di_Parser from './Parser.mjs';
|
|
9
9
|
import {Factory as TeqFw_Di_Dto_Resolver_Config_Factory} from './Dto/Resolver/Config.mjs';
|
|
10
10
|
import TeqFw_Di_Resolver from './Container/Resolver.mjs';
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
11
|
+
import TeqFw_Di_Container_GraphResolver from './Container/GraphResolver.mjs';
|
|
12
|
+
import TeqFw_Di_Container_Instantiate from './Container/Instantiate.mjs';
|
|
13
|
+
import TeqFw_Di_Container_Lifecycle from './Container/Lifecycle.mjs';
|
|
14
|
+
import TeqFw_Di_Container_Executor from './Container/Executor.mjs';
|
|
15
15
|
import {executeContainerPipeline} from './Container/Pipeline.mjs';
|
|
16
16
|
import TeqFw_Di_Internal_Logger, {TeqFw_Di_Internal_Logger_Noop} from './Internal/Logger.mjs';
|
|
17
17
|
import {buildDependencyKey} from './Internal/DependencyKey.mjs';
|
|
@@ -32,7 +32,7 @@ export default class TeqFw_Di_Container {
|
|
|
32
32
|
let state = 'notConfigured';
|
|
33
33
|
/** @type {((depId: TeqFw_Di_DepId__DTO) => TeqFw_Di_DepId__DTO)[]} */
|
|
34
34
|
const preprocess = [];
|
|
35
|
-
/** @type {((value: unknown) => unknown)[]} */
|
|
35
|
+
/** @type {((value: unknown, context: TeqFw_Di_Container_Postprocess_Context) => unknown)[]} */
|
|
36
36
|
const postprocess = [];
|
|
37
37
|
/** @type {TeqFw_Di_Dto_Resolver_Config_Namespace__DTO[]} */
|
|
38
38
|
const namespaceRoots = [];
|
|
@@ -46,32 +46,27 @@ export default class TeqFw_Di_Container {
|
|
|
46
46
|
const configFactory = new TeqFw_Di_Dto_Resolver_Config_Factory();
|
|
47
47
|
/** @type {TeqFw_Di_Resolver|undefined} */
|
|
48
48
|
let resolver;
|
|
49
|
-
/** @type {
|
|
49
|
+
/** @type {TeqFw_Di_Container_GraphResolver|undefined} */
|
|
50
50
|
let graphResolver;
|
|
51
|
-
/** @type {
|
|
51
|
+
/** @type {TeqFw_Di_Container_Lifecycle|undefined} */
|
|
52
52
|
let lifecycle;
|
|
53
53
|
/** @type {{log(message: string): void, error(message: string, error?: unknown): void}} */
|
|
54
54
|
let logger = TeqFw_Di_Internal_Logger_Noop;
|
|
55
|
-
/** @type {
|
|
56
|
-
const instantiator = new
|
|
57
|
-
/** @type {
|
|
58
|
-
const wrapperExecutor = new
|
|
55
|
+
/** @type {TeqFw_Di_Container_Instantiate} */
|
|
56
|
+
const instantiator = new TeqFw_Di_Container_Instantiate();
|
|
57
|
+
/** @type {TeqFw_Di_Container_Executor} */
|
|
58
|
+
const wrapperExecutor = new TeqFw_Di_Container_Executor();
|
|
59
59
|
|
|
60
60
|
const getKey = buildDependencyKey;
|
|
61
61
|
const getMockKey = buildDependencyKey;
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
let freeze = function (value) {
|
|
64
64
|
if ((value === null) || (value === undefined)) return value;
|
|
65
65
|
const type = typeof value;
|
|
66
66
|
if ((type !== 'object') && (type !== 'function')) return value;
|
|
67
67
|
if (Object.prototype.toString.call(value) === '[object Module]') return value;
|
|
68
68
|
if (Object.isFrozen(value)) return value;
|
|
69
|
-
|
|
70
|
-
Object.freeze(value);
|
|
71
|
-
} catch (error) {
|
|
72
|
-
logger.log(`Container.freeze: skipped (${String(error)}).`);
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
69
|
+
return Object.freeze(value);
|
|
75
70
|
};
|
|
76
71
|
|
|
77
72
|
const applyPreprocess = function (depId) {
|
|
@@ -81,10 +76,26 @@ export default class TeqFw_Di_Container {
|
|
|
81
76
|
return current;
|
|
82
77
|
};
|
|
83
78
|
|
|
84
|
-
const
|
|
79
|
+
const canonicalize = function (specifier) {
|
|
80
|
+
return applyPreprocess(parser.parse(specifier));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const findMock = function (depId) {
|
|
84
|
+
const key = getMockKey(depId);
|
|
85
|
+
return {found: testMode === true && mockRegistry.has(key), value: mockRegistry.get(key)};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const applyPostprocess = function (value, depId) {
|
|
85
89
|
/** @type {unknown} */
|
|
86
90
|
let current = value;
|
|
87
|
-
|
|
91
|
+
/** @type {TeqFw_Di_Container_Postprocess_Context} */
|
|
92
|
+
const context = Object.freeze({depId});
|
|
93
|
+
for (const fn of postprocess) {
|
|
94
|
+
current = fn(current, context);
|
|
95
|
+
if (current instanceof Promise) {
|
|
96
|
+
throw new Error('Postprocess callback must return synchronously (non-Promise).');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
88
99
|
return current;
|
|
89
100
|
};
|
|
90
101
|
|
|
@@ -104,8 +115,8 @@ export default class TeqFw_Di_Container {
|
|
|
104
115
|
const resolverConfig = configFactory.create({namespaces: namespaceRoots});
|
|
105
116
|
if (typeof parser.setLogger === 'function') parser.setLogger(logger);
|
|
106
117
|
resolver = new TeqFw_Di_Resolver({config: resolverConfig, logger});
|
|
107
|
-
graphResolver = new
|
|
108
|
-
lifecycle = new
|
|
118
|
+
graphResolver = new TeqFw_Di_Container_GraphResolver({canonicalize, findMock, resolver, logger});
|
|
119
|
+
lifecycle = new TeqFw_Di_Container_Lifecycle(logger);
|
|
109
120
|
};
|
|
110
121
|
|
|
111
122
|
/**
|
|
@@ -123,7 +134,7 @@ export default class TeqFw_Di_Container {
|
|
|
123
134
|
/**
|
|
124
135
|
* Adds a postprocessing hook.
|
|
125
136
|
*
|
|
126
|
-
* @param {(value: unknown) => unknown} fn
|
|
137
|
+
* @param {(value: unknown, context: TeqFw_Di_Container_Postprocess_Context) => unknown} fn
|
|
127
138
|
* @returns {void}
|
|
128
139
|
*/
|
|
129
140
|
this.addPostprocess = function (fn) {
|
|
@@ -132,6 +143,19 @@ export default class TeqFw_Di_Container {
|
|
|
132
143
|
postprocess.push(fn);
|
|
133
144
|
};
|
|
134
145
|
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Sets the host hardening policy for every resolved value and mock.
|
|
149
|
+
*
|
|
150
|
+
* @param {(value: unknown) => unknown} fn
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
this.setHardener = function (fn) {
|
|
154
|
+
assertBuilderStage();
|
|
155
|
+
logBuilder('setHardener().');
|
|
156
|
+
freeze = fn;
|
|
157
|
+
};
|
|
158
|
+
|
|
135
159
|
/**
|
|
136
160
|
* Registers namespace root mapping.
|
|
137
161
|
*
|
|
@@ -172,29 +196,29 @@ export default class TeqFw_Di_Container {
|
|
|
172
196
|
};
|
|
173
197
|
|
|
174
198
|
/**
|
|
175
|
-
* Registers a mock value for a
|
|
199
|
+
* Registers a mock value for a Dependency Specifier.
|
|
176
200
|
*
|
|
177
|
-
* @param {string}
|
|
201
|
+
* @param {string} specifier
|
|
178
202
|
* @param {any} mock
|
|
179
203
|
* @returns {void}
|
|
180
204
|
*/
|
|
181
|
-
this.register = function (
|
|
205
|
+
this.register = function (specifier, mock) {
|
|
182
206
|
assertBuilderStage();
|
|
183
|
-
logBuilder(`register('${
|
|
207
|
+
logBuilder(`register('${specifier}').`);
|
|
184
208
|
if (testMode !== true) throw new Error('Container test mode is disabled.');
|
|
185
|
-
const depId =
|
|
209
|
+
const depId = canonicalize(specifier);
|
|
186
210
|
mockRegistry.set(getMockKey(depId), mock);
|
|
187
211
|
};
|
|
188
212
|
|
|
189
213
|
/**
|
|
190
|
-
* Resolves a
|
|
214
|
+
* Resolves a Dependency Specifier into a frozen Resolved Value.
|
|
191
215
|
*
|
|
192
|
-
* @param {string}
|
|
216
|
+
* @param {string} specifier
|
|
193
217
|
* @returns {Promise<any>}
|
|
194
218
|
*/
|
|
195
|
-
this.get = async function (
|
|
219
|
+
this.get = async function (specifier) {
|
|
196
220
|
if (state === 'failed') {
|
|
197
|
-
logger.error(`Container.get: rejected in failed state
|
|
221
|
+
logger.error(`Container.get: rejected in failed state specifier='${specifier}'.`);
|
|
198
222
|
throw new Error('Container is in failed state.');
|
|
199
223
|
}
|
|
200
224
|
|
|
@@ -202,7 +226,6 @@ export default class TeqFw_Di_Container {
|
|
|
202
226
|
initializeInfrastructure();
|
|
203
227
|
logger.log(`Container.state: '${state}'.`);
|
|
204
228
|
return await executeContainerPipeline({
|
|
205
|
-
parser,
|
|
206
229
|
resolver,
|
|
207
230
|
graphResolver,
|
|
208
231
|
lifecycle,
|
|
@@ -212,9 +235,9 @@ export default class TeqFw_Di_Container {
|
|
|
212
235
|
testMode,
|
|
213
236
|
mockRegistry,
|
|
214
237
|
freeze,
|
|
215
|
-
|
|
238
|
+
canonicalize,
|
|
216
239
|
applyPostprocess,
|
|
217
|
-
},
|
|
240
|
+
}, specifier);
|
|
218
241
|
} catch (error) {
|
|
219
242
|
logger.error(`Container.transition: operational -> failed.`, error);
|
|
220
243
|
state = 'failed';
|
|
@@ -223,3 +246,12 @@ export default class TeqFw_Di_Container {
|
|
|
223
246
|
};
|
|
224
247
|
}
|
|
225
248
|
}
|
|
249
|
+
|
|
250
|
+
const canonicalize = function (specifier) {
|
|
251
|
+
return applyPreprocess(parser.parse(specifier));
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const findMock = function (depId) {
|
|
255
|
+
const key = getMockKey(depId);
|
|
256
|
+
return {found: testMode === true && mockRegistry.has(key), value: mockRegistry.get(key)};
|
|
257
|
+
};
|
package/src/Dto/DepId.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Reads dependency declaration map for the selected export of a module namespace.
|
|
10
10
|
*
|
|
11
11
|
* @param {object} namespace Loaded module namespace.
|
|
12
|
-
* @param {TeqFw_Di_DepId__DTO} depId
|
|
12
|
+
* @param {TeqFw_Di_DepId__DTO} depId Parsed dependency identity.
|
|
13
13
|
* @returns {Record<string, unknown>} Dependency declaration map.
|
|
14
14
|
*/
|
|
15
15
|
export function readDepsDecl(namespace, depId) {
|
|
@@ -24,7 +24,7 @@ export function readDepsDecl(namespace, depId) {
|
|
|
24
24
|
if ((exportScoped !== undefined) && (exportScoped !== null) && (typeof exportScoped === 'object') && !Array.isArray(exportScoped)) {
|
|
25
25
|
const values = Object.values(/** @type {Record<string, unknown>} */ (exportScoped));
|
|
26
26
|
if (!values.every((value) => typeof value === 'string')) {
|
|
27
|
-
throw new Error('__deps__ export entries must map dependency names to
|
|
27
|
+
throw new Error('__deps__ export entries must map dependency names to Dependency Specifier strings.');
|
|
28
28
|
}
|
|
29
29
|
return /** @type {Record<string, unknown>} */ (exportScoped);
|
|
30
30
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import TeqFw_Di_Node_Registry_Package from './Package.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @namespace TeqFw_Di_Node_Registry_Namespace
|
|
7
|
+
* @description Deterministic Node.js namespace registry derived from the runtime package graph.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} TeqFw_Di_Node_Registry_Namespace_Dependencies
|
|
12
|
+
* @property {{readFile(path: string, encoding: string): Promise<string>, realpath(path: string): Promise<string>, stat(path: string): Promise<{isDirectory(): boolean}>}} fs
|
|
13
|
+
* @property {{join(...paths: string[]): string, dirname(path: string): string, relative(from: string, to: string): string, resolve(...paths: string[]): string, isAbsolute(path: string): boolean}} path
|
|
14
|
+
* @property {string} appRoot
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} TeqFw_Di_Node_Registry_Namespace_Entry
|
|
19
|
+
* @property {string} prefix
|
|
20
|
+
* @property {string} dirAbs
|
|
21
|
+
* @property {string} ext
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds a deterministic immutable Node.js namespace registry from runtime package records.
|
|
26
|
+
*/
|
|
27
|
+
export default class TeqFw_Di_Node_Registry_Namespace {
|
|
28
|
+
/**
|
|
29
|
+
* @param {TeqFw_Di_Node_Registry_Namespace_Dependencies} deps
|
|
30
|
+
*/
|
|
31
|
+
constructor({fs, path, appRoot}) {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} rootAbs
|
|
34
|
+
* @param {string} candidateAbs
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
const isInside = function (rootAbs, candidateAbs) {
|
|
38
|
+
const rel = path.relative(rootAbs, candidateAbs);
|
|
39
|
+
return (rel === '') || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @returns {value is Record<string, unknown>}
|
|
45
|
+
*/
|
|
46
|
+
const isRecord = function (value) {
|
|
47
|
+
return (value !== null) && (typeof value === 'object') && !Array.isArray(value);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{name: string, rootAbs: string}} publisher
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
const publisherLabel = function (publisher) {
|
|
55
|
+
return `package '${publisher.name}' at '${publisher.rootAbs}'`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} absPath
|
|
60
|
+
* @returns {Promise<boolean>}
|
|
61
|
+
*/
|
|
62
|
+
const isDirectory = async function (absPath) {
|
|
63
|
+
try {
|
|
64
|
+
return (await fs.stat(absPath)).isDirectory();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} packageRootAbs
|
|
72
|
+
* @param {string} dirAbs
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
const assertInsidePackageRoot = async function (packageRootAbs, dirAbs) {
|
|
76
|
+
const packageRootReal = await fs.realpath(packageRootAbs);
|
|
77
|
+
const dirReal = await fs.realpath(dirAbs);
|
|
78
|
+
if (!isInside(packageRootReal, dirReal)) {
|
|
79
|
+
throw new Error('Namespace path resolves outside package root: ' + dirAbs + '.');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {unknown} ext
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
const normalizeExt = function (ext) {
|
|
88
|
+
if (ext === undefined) return '.mjs';
|
|
89
|
+
if ((typeof ext !== 'string') || (ext.length === 0)) {
|
|
90
|
+
throw new Error('Namespace extension must be a non-empty string.');
|
|
91
|
+
}
|
|
92
|
+
const normalized = ext.startsWith('.') ? ext : '.' + ext;
|
|
93
|
+
if ((normalized !== '.mjs') && (normalized !== '.js')) {
|
|
94
|
+
throw new Error('Namespace extension is not ESM-compatible: ' + normalized + '.');
|
|
95
|
+
}
|
|
96
|
+
return normalized;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {unknown} raw
|
|
101
|
+
* @param {{name: string, rootAbs: string}} publisher
|
|
102
|
+
* @param {{source: 'canonical'|'legacy', index: number}} origin
|
|
103
|
+
* @returns {Promise<{entry: TeqFw_Di_Node_Registry_Namespace_Entry, origin: {source: 'canonical'|'legacy', index: number}}>}
|
|
104
|
+
*/
|
|
105
|
+
const normalizeEntry = async function (raw, publisher, origin) {
|
|
106
|
+
const packageRootAbs = publisher.rootAbs;
|
|
107
|
+
const source = `${publisherLabel(publisher)} ${origin.source} namespace declaration at index ${origin.index}`;
|
|
108
|
+
if (!isRecord(raw)) {
|
|
109
|
+
throw new Error(`DI namespace declared by ${source} must be an object.`);
|
|
110
|
+
}
|
|
111
|
+
const item = raw;
|
|
112
|
+
const prefix = item.prefix;
|
|
113
|
+
if ((typeof prefix !== 'string') || (prefix.length === 0) || !prefix.endsWith('_')) {
|
|
114
|
+
throw new Error(`DI namespace prefix declared by ${source} is invalid: ${String(prefix)}.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rawPath = item.path;
|
|
118
|
+
if ((typeof rawPath !== 'string') || (rawPath.length === 0) || path.isAbsolute(rawPath)) {
|
|
119
|
+
throw new Error(`DI namespace path declared by ${source} must be a non-empty relative path: ${String(rawPath)}.`);
|
|
120
|
+
}
|
|
121
|
+
const dirAbs = path.resolve(packageRootAbs, rawPath);
|
|
122
|
+
if (!isInside(packageRootAbs, dirAbs)) {
|
|
123
|
+
throw new Error(`DI namespace path declared by ${source} lexically escapes its package root: ${rawPath}.`);
|
|
124
|
+
}
|
|
125
|
+
if (!(await isDirectory(dirAbs))) {
|
|
126
|
+
throw new Error(`DI namespace path declared by ${source} does not point to an existing directory: ${dirAbs}.`);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await assertInsidePackageRoot(packageRootAbs, dirAbs);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new Error(`DI namespace path declared by ${source} resolves outside its package root: ${dirAbs}.`, {cause: error});
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return {entry: {prefix, dirAbs, ext: normalizeExt(item.ext)}, origin};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`DI namespace extension declared by ${source} is invalid: ${String(error)}.`, {cause: error});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Selects one supported declaration set without merging schemas.
|
|
142
|
+
*
|
|
143
|
+
* @param {Readonly<Record<string, unknown>>} packageJson
|
|
144
|
+
* @param {{name: string, rootAbs: string}} publisher
|
|
145
|
+
* @returns {{source: 'canonical'|'legacy', entries: unknown[]}|undefined}
|
|
146
|
+
*/
|
|
147
|
+
const selectDeclarationSet = function (packageJson, publisher) {
|
|
148
|
+
const teqfw = packageJson.teqfw;
|
|
149
|
+
if (teqfw === undefined) return undefined;
|
|
150
|
+
if (!isRecord(teqfw)) throw new Error(`TeqFW metadata for ${publisherLabel(publisher)} must be an object.`);
|
|
151
|
+
const fw = teqfw.fw;
|
|
152
|
+
if (fw !== undefined) {
|
|
153
|
+
if (!isRecord(fw)) throw new Error(`TeqFW framework metadata for ${publisherLabel(publisher)} must be an object.`);
|
|
154
|
+
const di = fw.di;
|
|
155
|
+
if (di !== undefined) {
|
|
156
|
+
if (!isRecord(di)) throw new Error(`TeqFW DI metadata for ${publisherLabel(publisher)} must be an object.`);
|
|
157
|
+
if (Object.hasOwn(di, 'namespaces')) {
|
|
158
|
+
if (!Array.isArray(di.namespaces)) {
|
|
159
|
+
throw new Error(`Canonical DI namespaces declared by ${publisherLabel(publisher)} must be an array.`);
|
|
160
|
+
}
|
|
161
|
+
return {source: 'canonical', entries: di.namespaces};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (Object.hasOwn(teqfw, 'namespaces')) {
|
|
166
|
+
if (!Array.isArray(teqfw.namespaces)) {
|
|
167
|
+
throw new Error(`Legacy DI namespaces declared by ${publisherLabel(publisher)} must be an array.`);
|
|
168
|
+
}
|
|
169
|
+
return {source: 'legacy', entries: teqfw.namespaces};
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @returns {Promise<ReadonlyArray<TeqFw_Di_Node_Registry_Namespace_Entry>>}
|
|
176
|
+
*/
|
|
177
|
+
this.build = async function () {
|
|
178
|
+
const packages = await new TeqFw_Di_Node_Registry_Package({fs, path, appRoot}).build();
|
|
179
|
+
/** @type {Map<string, {publisher: {name: string, rootAbs: string}, origin: {source: 'canonical'|'legacy', index: number}}>} */
|
|
180
|
+
const publisherByPrefix = new Map();
|
|
181
|
+
/** @type {TeqFw_Di_Node_Registry_Namespace_Entry[]} */
|
|
182
|
+
const entries = [];
|
|
183
|
+
|
|
184
|
+
for (const onePackage of packages) {
|
|
185
|
+
const packageJson = onePackage.packageJson;
|
|
186
|
+
const publisher = {name: onePackage.name, rootAbs: onePackage.rootAbs};
|
|
187
|
+
const declarationSet = selectDeclarationSet(packageJson, publisher);
|
|
188
|
+
if (declarationSet === undefined) continue;
|
|
189
|
+
for (const [index, raw] of declarationSet.entries.entries()) {
|
|
190
|
+
const origin = {source: declarationSet.source, index};
|
|
191
|
+
const normalized = await normalizeEntry(raw, publisher, origin);
|
|
192
|
+
const existing = publisherByPrefix.get(normalized.entry.prefix);
|
|
193
|
+
if (existing) {
|
|
194
|
+
throw new Error(`Duplicate DI namespace prefix '${normalized.entry.prefix}' declared by ${publisherLabel(publisher)} ${origin.source} namespace declaration at index ${origin.index} conflicts with ${publisherLabel(existing.publisher)} ${existing.origin.source} namespace declaration at index ${existing.origin.index}.`);
|
|
195
|
+
}
|
|
196
|
+
publisherByPrefix.set(normalized.entry.prefix, {publisher, origin: normalized.origin});
|
|
197
|
+
entries.push(normalized.entry);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
entries.sort((a, b) => (b.prefix.length - a.prefix.length) || a.prefix.localeCompare(b.prefix));
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
Object.freeze(entry);
|
|
204
|
+
}
|
|
205
|
+
return Object.freeze(entries);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @namespace TeqFw_Di_Node_Registry_Package
|
|
5
|
+
* @description Deterministic Node.js composition-stage graph of installed runtime packages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} TeqFw_Di_Node_Registry_Package_Dependencies
|
|
10
|
+
* @property {{readFile(path: string, encoding: string): Promise<string>, realpath(path: string): Promise<string>, stat(path: string): Promise<{isDirectory(): boolean}>}} fs
|
|
11
|
+
* @property {{join(...paths: string[]): string, dirname(path: string): string, relative(from: string, to: string): string, resolve(...paths: string[]): string, isAbsolute(path: string): boolean}} path
|
|
12
|
+
* @property {string} appRoot
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds a deterministic immutable dependency-first list of root and installed runtime packages.
|
|
17
|
+
*/
|
|
18
|
+
export default class TeqFw_Di_Node_Registry_Package {
|
|
19
|
+
/**
|
|
20
|
+
* @param {TeqFw_Di_Node_Registry_Package_Dependencies} deps
|
|
21
|
+
*/
|
|
22
|
+
constructor({fs, path, appRoot}) {
|
|
23
|
+
const appRootAbs = path.resolve(appRoot);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {value is Record<string, unknown>}
|
|
28
|
+
*/
|
|
29
|
+
const isRecord = function (value) {
|
|
30
|
+
return (value !== null) && (typeof value === 'object') && !Array.isArray(value);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} value
|
|
35
|
+
* @returns {object}
|
|
36
|
+
*/
|
|
37
|
+
const freezeDeep = function (value) {
|
|
38
|
+
for (const nested of Object.values(value)) {
|
|
39
|
+
if ((nested !== null) && (typeof nested === 'object') && !Object.isFrozen(nested)) {
|
|
40
|
+
freezeDeep(nested);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} fileAbs
|
|
48
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
49
|
+
*/
|
|
50
|
+
const readJson = async function (fileAbs) {
|
|
51
|
+
let content;
|
|
52
|
+
try {
|
|
53
|
+
content = await fs.readFile(fileAbs, 'utf8');
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error('Unable to read package metadata ' + fileAbs + ': ' + String(error) + '.');
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const value = JSON.parse(content);
|
|
59
|
+
if (!isRecord(value)) {
|
|
60
|
+
throw new Error('root must be an object');
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error('Invalid package metadata ' + fileAbs + ': ' + String(error) + '.');
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} packageRootAbs
|
|
70
|
+
* @returns {Promise<{name: string, packageJson: Record<string, unknown>, dependencies: string[]}>}
|
|
71
|
+
*/
|
|
72
|
+
const readPackageMetadata = async function (packageRootAbs) {
|
|
73
|
+
const packageJsonAbs = path.join(packageRootAbs, 'package.json');
|
|
74
|
+
const packageJson = await readJson(packageJsonAbs);
|
|
75
|
+
const name = packageJson.name;
|
|
76
|
+
if ((typeof name !== 'string') || (name.length === 0)) {
|
|
77
|
+
throw new Error('Invalid package metadata ' + packageJsonAbs + ': name must be a non-empty string.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rawDependencies = packageJson.dependencies;
|
|
81
|
+
if (rawDependencies === undefined) {
|
|
82
|
+
return {name, packageJson, dependencies: []};
|
|
83
|
+
}
|
|
84
|
+
if (!isRecord(rawDependencies)) {
|
|
85
|
+
throw new Error('Invalid dependency metadata ' + packageJsonAbs + ': dependencies must be an object.');
|
|
86
|
+
}
|
|
87
|
+
const dependencies = Object.keys(rawDependencies).sort();
|
|
88
|
+
for (const dependencyName of dependencies) {
|
|
89
|
+
const dependencyRange = rawDependencies[dependencyName];
|
|
90
|
+
if ((dependencyName.length === 0) || (typeof dependencyRange !== 'string') || (dependencyRange.length === 0)) {
|
|
91
|
+
throw new Error('Invalid dependency metadata ' + packageJsonAbs + ': ' + dependencyName + ' must have a non-empty string declaration.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {name, packageJson, dependencies};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} absPath
|
|
99
|
+
* @returns {Promise<boolean>}
|
|
100
|
+
*/
|
|
101
|
+
const isDirectory = async function (absPath) {
|
|
102
|
+
try {
|
|
103
|
+
return (await fs.stat(absPath)).isDirectory();
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} rootAbs
|
|
111
|
+
* @param {string} candidateAbs
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
const isInside = function (rootAbs, candidateAbs) {
|
|
115
|
+
const rel = path.relative(rootAbs, candidateAbs);
|
|
116
|
+
return (rel === '') || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} candidateAbs
|
|
121
|
+
* @returns {Promise<boolean>}
|
|
122
|
+
*/
|
|
123
|
+
const hasPackageJson = async function (candidateAbs) {
|
|
124
|
+
try {
|
|
125
|
+
await fs.stat(path.join(candidateAbs, 'package.json'));
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} packageName
|
|
134
|
+
* @param {string} fromPackageRootAbs
|
|
135
|
+
* @returns {Promise<string>}
|
|
136
|
+
*/
|
|
137
|
+
const resolveDependencyPackageRoot = async function (packageName, fromPackageRootAbs) {
|
|
138
|
+
let cursor = fromPackageRootAbs;
|
|
139
|
+
while (isInside(appRootAbs, cursor)) {
|
|
140
|
+
const candidate = path.join(cursor, 'node_modules', packageName);
|
|
141
|
+
if ((await isDirectory(candidate)) && (await hasPackageJson(candidate))) {
|
|
142
|
+
return path.resolve(candidate);
|
|
143
|
+
}
|
|
144
|
+
if (cursor === appRootAbs) break;
|
|
145
|
+
const parent = path.dirname(cursor);
|
|
146
|
+
if (parent === cursor) break;
|
|
147
|
+
cursor = parent;
|
|
148
|
+
}
|
|
149
|
+
throw new Error('Installed dependency is not found: ' + packageName + ' from ' + fromPackageRootAbs + '.');
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @returns {Promise<ReadonlyArray<TeqFw_Di_Node_Registry_Package_Record>>}
|
|
154
|
+
*/
|
|
155
|
+
this.build = async function () {
|
|
156
|
+
/** @type {Map<string, TeqFw_Di_Node_Registry_Package_Record>} */
|
|
157
|
+
const recordsByRoot = new Map();
|
|
158
|
+
/** @type {Set<string>} */
|
|
159
|
+
const visitingRoots = new Set();
|
|
160
|
+
/** @type {TeqFw_Di_Node_Registry_Package_Record[]} */
|
|
161
|
+
const records = [];
|
|
162
|
+
/** @type {string[]} */
|
|
163
|
+
const pathStack = [];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Visits a package after all of its dependencies.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} rootAbs
|
|
169
|
+
* @returns {Promise<string>}
|
|
170
|
+
*/
|
|
171
|
+
const visit = async function (rootAbs) {
|
|
172
|
+
let rootReal;
|
|
173
|
+
try {
|
|
174
|
+
rootReal = await fs.realpath(rootAbs);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw new Error('Unable to resolve package root ' + rootAbs + ': ' + String(error) + '.');
|
|
177
|
+
}
|
|
178
|
+
if (recordsByRoot.has(rootReal)) return rootReal;
|
|
179
|
+
if (visitingRoots.has(rootReal)) {
|
|
180
|
+
const cycleStart = pathStack.indexOf(rootReal);
|
|
181
|
+
const cycle = [...pathStack.slice(cycleStart), rootReal].join(' -> ');
|
|
182
|
+
throw new Error('Cyclic package dependency detected: ' + cycle + '.');
|
|
183
|
+
}
|
|
184
|
+
visitingRoots.add(rootReal);
|
|
185
|
+
pathStack.push(rootReal);
|
|
186
|
+
|
|
187
|
+
const metadata = await readPackageMetadata(rootAbs);
|
|
188
|
+
const packageJson = /** @type {Readonly<Record<string, unknown>>} */ (freezeDeep(metadata.packageJson));
|
|
189
|
+
/** @type {string[]} */
|
|
190
|
+
const dependencies = [];
|
|
191
|
+
|
|
192
|
+
for (const dependencyName of metadata.dependencies) {
|
|
193
|
+
const dependencyRootAbs = await resolveDependencyPackageRoot(dependencyName, rootAbs);
|
|
194
|
+
dependencies.push(await visit(dependencyRootAbs));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
pathStack.pop();
|
|
198
|
+
visitingRoots.delete(rootReal);
|
|
199
|
+
const record = Object.freeze({
|
|
200
|
+
name: metadata.name,
|
|
201
|
+
rootAbs,
|
|
202
|
+
rootReal,
|
|
203
|
+
packageJson,
|
|
204
|
+
dependencies: Object.freeze([...new Set(dependencies)]),
|
|
205
|
+
});
|
|
206
|
+
recordsByRoot.set(rootReal, record);
|
|
207
|
+
records.push(record);
|
|
208
|
+
return rootReal;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
await visit(appRootAbs);
|
|
212
|
+
return Object.freeze(records);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|